From fa3e59704b28e5809882a6d37e28c05e172cb27a Mon Sep 17 00:00:00 2001 From: neckfire Date: Sun, 19 Jul 2026 03:46:43 +0200 Subject: [PATCH] =?UTF-8?q?i18n=20vocabulaire=20exos=20(fr/pt-BR)=20+=20We?= =?UTF-8?q?b=20Push=20rappels=20de=20s=C3=A9rie?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- .dockerignore | 2 + .env.tmp | 0 composer.json | 1 + composer.lock | 494 +++++++++++++++++- config/webpush.php | 46 ++ ...012718_create_push_subscriptions_table.php | 36 ++ functional/exercises/config/exercise_i18n.php | 78 +++ .../Http/Controllers/ExercisesController.php | 24 +- .../Http/Resources/ExerciseListResource.php | 14 +- .../src/Http/Resources/ExerciseResource.php | 16 +- functional/exercises/src/Models/Exercise.php | 18 + .../Providers/ExercisesServiceProvider.php | 1 + ...013000_add_last_reminded_on_to_streaks.php | 23 + functional/streaks/routes/console.php | 6 +- .../Console/Commands/RemindStreaksCommand.php | 79 +++ functional/streaks/src/Models/Streak.php | 3 +- .../StreakAtRiskNotification.php | 44 ++ .../src/Providers/StreaksServiceProvider.php | 3 +- functional/users/routes/api.php | 12 +- .../src/Http/Controllers/AuthController.php | 4 + .../src/Http/Controllers/PushController.php | 50 ++ functional/users/src/Models/User.php | 3 +- .../Notifications/PushTestNotification.php | 28 + 23 files changed, 954 insertions(+), 31 deletions(-) create mode 100644 .env.tmp create mode 100644 config/webpush.php create mode 100644 database/migrations/2026_07_19_012718_create_push_subscriptions_table.php create mode 100644 functional/exercises/config/exercise_i18n.php create mode 100644 functional/streaks/database/migrations/2026_07_19_013000_add_last_reminded_on_to_streaks.php create mode 100644 functional/streaks/src/Console/Commands/RemindStreaksCommand.php create mode 100644 functional/streaks/src/Notifications/StreakAtRiskNotification.php create mode 100644 functional/users/src/Http/Controllers/PushController.php create mode 100644 functional/users/src/Notifications/PushTestNotification.php diff --git a/.dockerignore b/.dockerignore index d69eef9..e59c564 100644 --- a/.dockerignore +++ b/.dockerignore @@ -4,6 +4,8 @@ .env.* vendor node_modules +bootstrap/cache/packages.php +bootstrap/cache/services.php storage/app/dataset storage/app/private/* storage/logs/* diff --git a/.env.tmp b/.env.tmp new file mode 100644 index 0000000..e69de29 diff --git a/composer.json b/composer.json index 03595bc..66f3424 100644 --- a/composer.json +++ b/composer.json @@ -14,6 +14,7 @@ "functional/streaks": "*", "functional/tracking": "*", "functional/users": "*", + "laravel-notification-channels/webpush": "^11.0", "laravel/framework": "^13.8", "laravel/octane": "^2.17", "laravel/sanctum": "^4.3", diff --git a/composer.lock b/composer.lock index b274741..de77652 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "d4b09f66cc8dc9ea7f3f370e110cd76c", + "content-hash": "1fd71e714394b125185f4b12ac7e5165", "packages": [ { "name": "anourvalar/eloquent-serialize", @@ -374,16 +374,16 @@ }, { "name": "brick/math", - "version": "0.18.0", + "version": "0.17.2", "source": { "type": "git", "url": "https://github.com/brick/math.git", - "reference": "82944324d1c1bdb2c2618e89978d4e2ad78d69ad" + "reference": "8189e751995f9e15729c1aa2f89fa8f166ffe818" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/brick/math/zipball/82944324d1c1bdb2c2618e89978d4e2ad78d69ad", - "reference": "82944324d1c1bdb2c2618e89978d4e2ad78d69ad", + "url": "https://api.github.com/repos/brick/math/zipball/8189e751995f9e15729c1aa2f89fa8f166ffe818", + "reference": "8189e751995f9e15729c1aa2f89fa8f166ffe818", "shasum": "" }, "require": { @@ -421,7 +421,7 @@ ], "support": { "issues": "https://github.com/brick/math/issues", - "source": "https://github.com/brick/math/tree/0.18.0" + "source": "https://github.com/brick/math/tree/0.17.2" }, "funding": [ { @@ -429,7 +429,7 @@ "type": "github" } ], - "time": "2026-06-14T18:21:03+00:00" + "time": "2026-05-25T20:34:43+00:00" }, { "name": "carbonphp/carbon-doctrine-types", @@ -2508,6 +2508,72 @@ ], "time": "2025-10-12T15:31:36+00:00" }, + { + "name": "laravel-notification-channels/webpush", + "version": "11.0.0", + "source": { + "type": "git", + "url": "https://github.com/laravel-notification-channels/webpush.git", + "reference": "85b577e64459a9df06a24062e2b300abbaa99fa9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel-notification-channels/webpush/zipball/85b577e64459a9df06a24062e2b300abbaa99fa9", + "reference": "85b577e64459a9df06a24062e2b300abbaa99fa9", + "shasum": "" + }, + "require": { + "illuminate/notifications": "^12.0|^13.0", + "illuminate/support": "^12.0|^13.0", + "minishlink/web-push": "^10.0.1", + "php": "^8.2" + }, + "require-dev": { + "larastan/larastan": "^3.1", + "laravel/pint": "^1.25", + "mockery/mockery": "^1.0", + "orchestra/testbench": "^9.2|^10.0|^11.0", + "phpunit/phpunit": "^11.5.3|^12.5.12|^13.1.11", + "rector/rector": "^2.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "NotificationChannels\\WebPush\\WebPushServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "NotificationChannels\\WebPush\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Cretu Eusebiu", + "email": "me@cretueusebiu.com", + "homepage": "http://cretueusebiu.com", + "role": "Developer" + }, + { + "name": "Joost de Bruijn", + "email": "joost@aqualabs.nl", + "role": "Maintainer" + } + ], + "description": "Web Push Notifications driver for Laravel.", + "homepage": "https://github.com/laravel-notification-channels/webpush", + "support": { + "issues": "https://github.com/laravel-notification-channels/webpush/issues", + "source": "https://github.com/laravel-notification-channels/webpush/tree/11.0.0" + }, + "time": "2026-05-24T13:22:27+00:00" + }, { "name": "laravel/framework", "version": "v13.20.0", @@ -4089,6 +4155,77 @@ ], "time": "2026-07-13T12:28:52+00:00" }, + { + "name": "minishlink/web-push", + "version": "v10.1.0", + "source": { + "type": "git", + "url": "https://github.com/web-push-libs/web-push-php.git", + "reference": "c922021b4ed1a61e6604d8dc33a2e0378b4382e3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/web-push-libs/web-push-php/zipball/c922021b4ed1a61e6604d8dc33a2e0378b4382e3", + "reference": "c922021b4ed1a61e6604d8dc33a2e0378b4382e3", + "shasum": "" + }, + "require": { + "ext-curl": "*", + "ext-json": "*", + "ext-mbstring": "*", + "ext-openssl": "*", + "guzzlehttp/guzzle": "^7.9.2", + "php": ">=8.2", + "psr/log": "^2.0|^3.0", + "spomky-labs/base64url": "^2.0.4", + "symfony/polyfill-php83": "^1.33", + "web-token/jwt-library": "^3.4.9|^4.0.6" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^v3.92.2", + "phpstan/phpstan": "^2.1.33", + "phpstan/phpstan-deprecation-rules": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^11.5.46|^12.5.2", + "symfony/polyfill-iconv": "^1.33" + }, + "suggest": { + "ext-bcmath": "Optional for performance.", + "ext-gmp": "Optional for performance." + }, + "type": "library", + "autoload": { + "psr-4": { + "Minishlink\\WebPush\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Louis Lagrange", + "email": "lagrange.louis@gmail.com", + "homepage": "https://github.com/Minishlink" + } + ], + "description": "Web Push library for PHP", + "homepage": "https://github.com/web-push-libs/web-push-php", + "keywords": [ + "Push API", + "WebPush", + "notifications", + "push", + "web" + ], + "support": { + "issues": "https://github.com/web-push-libs/web-push-php/issues", + "source": "https://github.com/web-push-libs/web-push-php/tree/v10.1.0" + }, + "time": "2026-05-28T09:37:37+00:00" + }, { "name": "monolog/monolog", "version": "3.10.0", @@ -6214,6 +6351,180 @@ ], "time": "2026-04-27T14:27:52+00:00" }, + { + "name": "spomky-labs/base64url", + "version": "v2.0.4", + "source": { + "type": "git", + "url": "https://github.com/Spomky-Labs/base64url.git", + "reference": "7752ce931ec285da4ed1f4c5aa27e45e097be61d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Spomky-Labs/base64url/zipball/7752ce931ec285da4ed1f4c5aa27e45e097be61d", + "reference": "7752ce931ec285da4ed1f4c5aa27e45e097be61d", + "shasum": "" + }, + "require": { + "php": ">=7.1" + }, + "require-dev": { + "phpstan/extension-installer": "^1.0", + "phpstan/phpstan": "^0.11|^0.12", + "phpstan/phpstan-beberlei-assert": "^0.11|^0.12", + "phpstan/phpstan-deprecation-rules": "^0.11|^0.12", + "phpstan/phpstan-phpunit": "^0.11|^0.12", + "phpstan/phpstan-strict-rules": "^0.11|^0.12" + }, + "type": "library", + "autoload": { + "psr-4": { + "Base64Url\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Florent Morselli", + "homepage": "https://github.com/Spomky-Labs/base64url/contributors" + } + ], + "description": "Base 64 URL Safe Encoding/Decoding PHP Library", + "homepage": "https://github.com/Spomky-Labs/base64url", + "keywords": [ + "base64", + "rfc4648", + "safe", + "url" + ], + "support": { + "issues": "https://github.com/Spomky-Labs/base64url/issues", + "source": "https://github.com/Spomky-Labs/base64url/tree/v2.0.4" + }, + "funding": [ + { + "url": "https://github.com/Spomky", + "type": "github" + }, + { + "url": "https://www.patreon.com/FlorentMorselli", + "type": "patreon" + } + ], + "time": "2020-11-03T09:10:25+00:00" + }, + { + "name": "spomky-labs/pki-framework", + "version": "1.5.0", + "source": { + "type": "git", + "url": "https://github.com/Spomky-Labs/pki-framework.git", + "reference": "e0d61661962560c1cedfef02b51b431e720aae78" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Spomky-Labs/pki-framework/zipball/e0d61661962560c1cedfef02b51b431e720aae78", + "reference": "e0d61661962560c1cedfef02b51b431e720aae78", + "shasum": "" + }, + "require": { + "brick/math": "^0.10|^0.11|^0.12|^0.13|^0.14|^0.15|^0.16|^0.17|^0.18", + "ext-mbstring": "*", + "php": ">=8.1" + }, + "require-dev": { + "ekino/phpstan-banned-code": "^1.0|^2.0|^3.0", + "ext-gmp": "*", + "ext-openssl": "*", + "infection/infection": "^0.28|^0.29|^0.31", + "php-parallel-lint/php-parallel-lint": "^1.3", + "phpstan/extension-installer": "^1.3|^2.0", + "phpstan/phpstan": "^1.8|^2.0", + "phpstan/phpstan-deprecation-rules": "^1.0|^2.0", + "phpstan/phpstan-phpunit": "^1.1|^2.0", + "phpstan/phpstan-strict-rules": "^1.3|^2.0", + "phpunit/phpunit": "^10.1|^11.0|^12.0", + "rector/rector": "^1.0|^2.0", + "roave/security-advisories": "dev-latest", + "symfony/string": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0", + "symplify/easy-coding-standard": "^12.0 || ^13.0" + }, + "suggest": { + "ext-bcmath": "For better performance (or GMP)", + "ext-gmp": "For better performance (or BCMath)", + "ext-openssl": "For OpenSSL based cyphering" + }, + "type": "library", + "autoload": { + "psr-4": { + "SpomkyLabs\\Pki\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Joni Eskelinen", + "email": "jonieske@gmail.com", + "role": "Original developer" + }, + { + "name": "Florent Morselli", + "email": "florent.morselli@spomky-labs.com", + "role": "Spomky-Labs PKI Framework developer" + } + ], + "description": "A PHP framework for managing Public Key Infrastructures. It comprises X.509 public key certificates, attribute certificates, certification requests and certification path validation.", + "homepage": "https://github.com/spomky-labs/pki-framework", + "keywords": [ + "DER", + "Private Key", + "ac", + "algorithm identifier", + "asn.1", + "asn1", + "attribute certificate", + "certificate", + "certification request", + "cryptography", + "csr", + "decrypt", + "ec", + "encrypt", + "pem", + "pkcs", + "public key", + "rsa", + "sign", + "signature", + "verify", + "x.509", + "x.690", + "x509", + "x690" + ], + "support": { + "issues": "https://github.com/Spomky-Labs/pki-framework/issues", + "source": "https://github.com/Spomky-Labs/pki-framework/tree/1.5.0" + }, + "funding": [ + { + "url": "https://github.com/Spomky", + "type": "github" + }, + { + "url": "https://www.patreon.com/FlorentMorselli", + "type": "patreon" + } + ], + "time": "2026-07-16T10:28:45+00:00" + }, { "name": "symfony/clock", "version": "v8.1.0", @@ -7851,6 +8162,86 @@ ], "time": "2026-04-10T16:19:22+00:00" }, + { + "name": "symfony/polyfill-php83", + "version": "v1.38.2", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php83.git", + "reference": "796a26abb75ce49f3a84433cd81bf1009d73d5f8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/796a26abb75ce49f3a84433cd81bf1009d73d5f8", + "reference": "796a26abb75ce49f3a84433cd81bf1009d73d5f8", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php83\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.3+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php83/tree/v1.38.2" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-27T06:51:48+00:00" + }, { "name": "symfony/polyfill-php84", "version": "v1.38.1", @@ -9264,6 +9655,95 @@ ], "time": "2026-04-26T05:33:54+00:00" }, + { + "name": "web-token/jwt-library", + "version": "4.1.7", + "source": { + "type": "git", + "url": "https://github.com/web-token/jwt-library.git", + "reference": "fbcbf2c276d04d8b056f5c2957815abd5dfb704d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/web-token/jwt-library/zipball/fbcbf2c276d04d8b056f5c2957815abd5dfb704d", + "reference": "fbcbf2c276d04d8b056f5c2957815abd5dfb704d", + "shasum": "" + }, + "require": { + "brick/math": "^0.12|^0.13|^0.14|^0.15|^0.16|^0.17", + "php": ">=8.2", + "psr/clock": "^1.0", + "spomky-labs/pki-framework": "^1.2.1" + }, + "conflict": { + "spomky-labs/jose": "*" + }, + "suggest": { + "ext-bcmath": "GMP or BCMath is highly recommended to improve the library performance", + "ext-gmp": "GMP or BCMath is highly recommended to improve the library performance", + "ext-openssl": "For key management (creation, optimization, etc.) and some algorithms (AES, RSA, ECDSA, etc.)", + "ext-sodium": "Sodium is required for OKP key creation, EdDSA signature algorithm and ECDH-ES key encryption with OKP keys", + "paragonie/sodium_compat": "Sodium is required for OKP key creation, EdDSA signature algorithm and ECDH-ES key encryption with OKP keys", + "spomky-labs/aes-key-wrap": "For all Key Wrapping algorithms (AxxxKW, AxxxGCMKW, PBES2-HSxxx+AyyyKW...)", + "symfony/console": "Needed to use console commands", + "symfony/http-client": "To enable JKU/X5U support." + }, + "type": "library", + "autoload": { + "psr-4": { + "Jose\\Component\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Florent Morselli", + "homepage": "https://github.com/Spomky" + }, + { + "name": "All contributors", + "homepage": "https://github.com/web-token/jwt-framework/contributors" + } + ], + "description": "JWT library", + "homepage": "https://github.com/web-token", + "keywords": [ + "JOSE", + "JWE", + "JWK", + "JWKSet", + "JWS", + "Jot", + "RFC7515", + "RFC7516", + "RFC7517", + "RFC7518", + "RFC7519", + "RFC7520", + "bundle", + "jwa", + "jwt", + "symfony" + ], + "support": { + "issues": "https://github.com/web-token/jwt-library/issues", + "source": "https://github.com/web-token/jwt-library/tree/4.1.7" + }, + "funding": [ + { + "url": "https://github.com/Spomky", + "type": "github" + }, + { + "url": "https://www.patreon.com/FlorentMorselli", + "type": "patreon" + } + ], + "time": "2026-06-06T18:12:39+00:00" + }, { "name": "xefi/laravel-osdd", "version": "v2.0.0", diff --git a/config/webpush.php b/config/webpush.php new file mode 100644 index 0000000..92ae4b1 --- /dev/null +++ b/config/webpush.php @@ -0,0 +1,46 @@ + [ + 'subject' => env('VAPID_SUBJECT'), + 'public_key' => env('VAPID_PUBLIC_KEY'), + 'private_key' => env('VAPID_PRIVATE_KEY'), + 'pem_file' => env('VAPID_PEM_FILE'), + ], + + /** + * This is model that will be used to for push subscriptions. + */ + 'model' => PushSubscription::class, + + /** + * This is the name of the table that will be created by the migration and + * used by the PushSubscription model shipped with this package. + */ + 'table_name' => env('WEBPUSH_DB_TABLE', 'push_subscriptions'), + + /** + * This is the database connection that will be used by the migration and + * the PushSubscription model shipped with this package. + */ + 'database_connection' => env('WEBPUSH_DB_CONNECTION', env('DB_CONNECTION', 'mysql')), + + /** + * The Guzzle client options used by Minishlink\WebPush. + */ + 'client_options' => [], + + /** + * The automatic padding in bytes used by Minishlink\WebPush. + * Set to false to support Firefox Android with v1 endpoint. + */ + 'automatic_padding' => env('WEBPUSH_AUTOMATIC_PADDING', true), + +]; diff --git a/database/migrations/2026_07_19_012718_create_push_subscriptions_table.php b/database/migrations/2026_07_19_012718_create_push_subscriptions_table.php new file mode 100644 index 0000000..567a743 --- /dev/null +++ b/database/migrations/2026_07_19_012718_create_push_subscriptions_table.php @@ -0,0 +1,36 @@ +create(config('webpush.table_name'), function (Blueprint $table) { + $table->bigIncrements('id'); + $table->morphs('subscribable', 'push_subscriptions_subscribable_morph_idx'); + $table->string('endpoint', 500)->unique(); + $table->string('public_key')->nullable(); + $table->string('auth_token')->nullable(); + $table->string('content_encoding')->nullable(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::connection(config('webpush.database_connection'))->dropIfExists(config('webpush.table_name')); + } +}; diff --git a/functional/exercises/config/exercise_i18n.php b/functional/exercises/config/exercise_i18n.php new file mode 100644 index 0000000..7749034 --- /dev/null +++ b/functional/exercises/config/exercise_i18n.php @@ -0,0 +1,78 @@ + 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', + ], +]; diff --git a/functional/exercises/src/Http/Controllers/ExercisesController.php b/functional/exercises/src/Http/Controllers/ExercisesController.php index 6ae5860..e26017d 100644 --- a/functional/exercises/src/Http/Controllers/ExercisesController.php +++ b/functional/exercises/src/Http/Controllers/ExercisesController.php @@ -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'), ]); } diff --git a/functional/exercises/src/Http/Resources/ExerciseListResource.php b/functional/exercises/src/Http/Resources/ExerciseListResource.php index 987f729..3cf621c 100644 --- a/functional/exercises/src/Http/Resources/ExerciseListResource.php +++ b/functional/exercises/src/Http/Resources/ExerciseListResource.php @@ -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, ]; diff --git a/functional/exercises/src/Http/Resources/ExerciseResource.php b/functional/exercises/src/Http/Resources/ExerciseResource.php index 2727420..5e08c67 100644 --- a/functional/exercises/src/Http/Resources/ExerciseResource.php +++ b/functional/exercises/src/Http/Resources/ExerciseResource.php @@ -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, diff --git a/functional/exercises/src/Models/Exercise.php b/functional/exercises/src/Models/Exercise.php index 408ea2d..e4549b8 100644 --- a/functional/exercises/src/Models/Exercise.php +++ b/functional/exercises/src/Models/Exercise.php @@ -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|null $values @return array */ + 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 { diff --git a/functional/exercises/src/Providers/ExercisesServiceProvider.php b/functional/exercises/src/Providers/ExercisesServiceProvider.php index 749662d..e9c2452 100644 --- a/functional/exercises/src/Providers/ExercisesServiceProvider.php +++ b/functional/exercises/src/Providers/ExercisesServiceProvider.php @@ -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'); } } diff --git a/functional/streaks/database/migrations/2026_07_19_013000_add_last_reminded_on_to_streaks.php b/functional/streaks/database/migrations/2026_07_19_013000_add_last_reminded_on_to_streaks.php new file mode 100644 index 0000000..0fbaffd --- /dev/null +++ b/functional/streaks/database/migrations/2026_07_19_013000_add_last_reminded_on_to_streaks.php @@ -0,0 +1,23 @@ +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'); + }); + } +}; diff --git a/functional/streaks/routes/console.php b/functional/streaks/routes/console.php index b7035a9..6318cc9 100644 --- a/functional/streaks/routes/console.php +++ b/functional/streaks/routes/console.php @@ -1,6 +1,6 @@ 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(); diff --git a/functional/streaks/src/Console/Commands/RemindStreaksCommand.php b/functional/streaks/src/Console/Commands/RemindStreaksCommand.php new file mode 100644 index 0000000..7c52055 --- /dev/null +++ b/functional/streaks/src/Console/Commands/RemindStreaksCommand.php @@ -0,0 +1,79 @@ +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; + } +} diff --git a/functional/streaks/src/Models/Streak.php b/functional/streaks/src/Models/Streak.php index 07b6e8a..3eebc6c 100644 --- a/functional/streaks/src/Models/Streak.php +++ b/functional/streaks/src/Models/Streak.php @@ -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 diff --git a/functional/streaks/src/Notifications/StreakAtRiskNotification.php b/functional/streaks/src/Notifications/StreakAtRiskNotification.php new file mode 100644 index 0000000..181ee6b --- /dev/null +++ b/functional/streaks/src/Notifications/StreakAtRiskNotification.php @@ -0,0 +1,44 @@ + */ + 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']); + } +} diff --git a/functional/streaks/src/Providers/StreaksServiceProvider.php b/functional/streaks/src/Providers/StreaksServiceProvider.php index 5914734..17d40f5 100644 --- a/functional/streaks/src/Providers/StreaksServiceProvider.php +++ b/functional/streaks/src/Providers/StreaksServiceProvider.php @@ -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); diff --git a/functional/users/routes/api.php b/functional/users/routes/api.php index 2a3bc6a..0ff944e 100644 --- a/functional/users/routes/api.php +++ b/functional/users/routes/api.php @@ -1,7 +1,15 @@ 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']); +}); diff --git a/functional/users/src/Http/Controllers/AuthController.php b/functional/users/src/Http/Controllers/AuthController.php index b2b9285..189009f 100644 --- a/functional/users/src/Http/Controllers/AuthController.php +++ b/functional/users/src/Http/Controllers/AuthController.php @@ -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(), + ], ]); } diff --git a/functional/users/src/Http/Controllers/PushController.php b/functional/users/src/Http/Controllers/PushController.php new file mode 100644 index 0000000..f166da5 --- /dev/null +++ b/functional/users/src/Http/Controllers/PushController.php @@ -0,0 +1,50 @@ +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']); + } +} diff --git a/functional/users/src/Models/User.php b/functional/users/src/Models/User.php index a58ba8e..2f1aee4 100644 --- a/functional/users/src/Models/User.php +++ b/functional/users/src/Models/User.php @@ -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 diff --git a/functional/users/src/Notifications/PushTestNotification.php b/functional/users/src/Notifications/PushTestNotification.php new file mode 100644 index 0000000..9ed921c --- /dev/null +++ b/functional/users/src/Notifications/PushTestNotification.php @@ -0,0 +1,28 @@ + */ + 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]); + } +}