From 4c03902fffce153ad84f30a21801022ab73f7136 Mon Sep 17 00:00:00 2001 From: Giulio Troccoli-Allard Date: Sun, 8 Mar 2026 15:25:45 +0000 Subject: [PATCH 01/63] feat: install Livewire 4 --- composer.json | 1 + composer.lock | 78 ++++++- config/livewire.php | 282 ++++++++++++++++++++++++++ resources/views/layouts/app.blade.php | 18 ++ 4 files changed, 378 insertions(+), 1 deletion(-) create mode 100644 config/livewire.php create mode 100644 resources/views/layouts/app.blade.php diff --git a/composer.json b/composer.json index c1ac4fe..3c15850 100644 --- a/composer.json +++ b/composer.json @@ -13,6 +13,7 @@ "ext-iconv": "*", "laravel/framework": "^12.0", "laravel/tinker": "^2.10.1", + "livewire/livewire": "^4.2", "setasign/fpdf": "^1.8", "setasign/fpdi": "^2.6" }, diff --git a/composer.lock b/composer.lock index 1adac1d..a787fbf 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": "0ca4b78455dc07bb3e2b534844687887", + "content-hash": "05e94318c2a169312455eaa16bbef3c4", "packages": [ { "name": "brick/math", @@ -2019,6 +2019,82 @@ ], "time": "2026-01-15T06:54:53+00:00" }, + { + "name": "livewire/livewire", + "version": "v4.2.1", + "source": { + "type": "git", + "url": "https://github.com/livewire/livewire.git", + "reference": "93e972fa42c1b34fff1550093ab94f778d81ea5a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/livewire/livewire/zipball/93e972fa42c1b34fff1550093ab94f778d81ea5a", + "reference": "93e972fa42c1b34fff1550093ab94f778d81ea5a", + "shasum": "" + }, + "require": { + "illuminate/database": "^10.0|^11.0|^12.0|^13.0", + "illuminate/routing": "^10.0|^11.0|^12.0|^13.0", + "illuminate/support": "^10.0|^11.0|^12.0|^13.0", + "illuminate/validation": "^10.0|^11.0|^12.0|^13.0", + "laravel/prompts": "^0.1.24|^0.2|^0.3", + "league/mime-type-detection": "^1.9", + "php": "^8.1", + "symfony/console": "^6.0|^7.0|^8.0", + "symfony/http-kernel": "^6.2|^7.0|^8.0" + }, + "require-dev": { + "calebporzio/sushi": "^2.1", + "laravel/framework": "^10.15.0|^11.0|^12.0|^13.0", + "mockery/mockery": "^1.3.1", + "orchestra/testbench": "^8.21.0|^9.0|^10.0|^11.0", + "orchestra/testbench-dusk": "^8.24|^9.1|^10.0|^11.0", + "phpunit/phpunit": "^10.4|^11.5|^12.5", + "psy/psysh": "^0.11.22|^0.12" + }, + "type": "library", + "extra": { + "laravel": { + "aliases": { + "Livewire": "Livewire\\Livewire" + }, + "providers": [ + "Livewire\\LivewireServiceProvider" + ] + } + }, + "autoload": { + "files": [ + "src/helpers.php" + ], + "psr-4": { + "Livewire\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Caleb Porzio", + "email": "calebporzio@gmail.com" + } + ], + "description": "A front-end framework for Laravel.", + "support": { + "issues": "https://github.com/livewire/livewire/issues", + "source": "https://github.com/livewire/livewire/tree/v4.2.1" + }, + "funding": [ + { + "url": "https://github.com/livewire", + "type": "github" + } + ], + "time": "2026-02-28T00:01:19+00:00" + }, { "name": "monolog/monolog", "version": "3.10.0", diff --git a/config/livewire.php b/config/livewire.php new file mode 100644 index 0000000..d8f0ae5 --- /dev/null +++ b/config/livewire.php @@ -0,0 +1,282 @@ + [ + resource_path('views/components'), + resource_path('views/livewire'), + ], + + /* + |--------------------------------------------------------------------------- + | Component Namespaces + |--------------------------------------------------------------------------- + | + | This value sets default namespaces that will be used to resolve view-based + | components like single-file and multi-file components. These folders'll + | also be referenced when creating new components via the make command. + | + */ + + 'component_namespaces' => [ + 'layouts' => resource_path('views/layouts'), + 'pages' => resource_path('views/pages'), + ], + + /* + |--------------------------------------------------------------------------- + | Page Layout + |--------------------------------------------------------------------------- + | The view that will be used as the layout when rendering a single component as + | an entire page via `Route::livewire('/post/create', 'pages::create-post')`. + | In this case, the content of pages::create-post will render into $slot. + | + */ + + 'component_layout' => 'layouts::app', + + /* + |--------------------------------------------------------------------------- + | Lazy Loading Placeholder + |--------------------------------------------------------------------------- + | Livewire allows you to lazy load components that would otherwise slow down + | the initial page load. Every component can have a custom placeholder or + | you can define the default placeholder view for all components below. + | + */ + + 'component_placeholder' => null, // Example: 'placeholders::skeleton' + + /* + |--------------------------------------------------------------------------- + | Make Command + |--------------------------------------------------------------------------- + | This value determines the default configuration for the artisan make command + | You can configure the component type (sfc, mfc, class) and whether to use + | the high-voltage (⚡) emoji as a prefix in the sfc|mfc component names. + | + */ + + 'make_command' => [ + 'type' => 'sfc', // Options: 'sfc', 'mfc', 'class' + 'emoji' => true, // Options: true, false + 'with' => [ + 'js' => false, + 'css' => false, + 'test' => false, + ], + ], + + /* + |--------------------------------------------------------------------------- + | Class Namespace + |--------------------------------------------------------------------------- + | + | This value sets the root class namespace for Livewire component classes in + | your application. This value will change where component auto-discovery + | finds components. It's also referenced by the file creation commands. + | + */ + + 'class_namespace' => 'App\\Livewire', + + /* + |--------------------------------------------------------------------------- + | Class Path + |--------------------------------------------------------------------------- + | + | This value is used to specify the path where Livewire component class files + | are created when running creation commands like `artisan make:livewire`. + | This path is customizable to match your projects directory structure. + | + */ + + 'class_path' => app_path('Livewire'), + + /* + |--------------------------------------------------------------------------- + | View Path + |--------------------------------------------------------------------------- + | + | This value is used to specify where Livewire component Blade templates are + | stored when running file creation commands like `artisan make:livewire`. + | It is also used if you choose to omit a component's render() method. + | + */ + + 'view_path' => resource_path('views/livewire'), + + /* + |--------------------------------------------------------------------------- + | Temporary File Uploads + |--------------------------------------------------------------------------- + | + | Livewire handles file uploads by storing uploads in a temporary directory + | before the file is stored permanently. All file uploads are directed to + | a global endpoint for temporary storage. You may configure this below: + | + */ + + 'temporary_file_upload' => [ + 'disk' => env('LIVEWIRE_TEMPORARY_FILE_UPLOAD_DISK'), // Example: 'local', 's3' | Default: 'default' + 'rules' => null, // Example: ['file', 'mimes:png,jpg'] | Default: ['required', 'file', 'max:12288'] (12MB) + 'directory' => null, // Example: 'tmp' | Default: 'livewire-tmp' + 'middleware' => null, // Example: 'throttle:5,1' | Default: 'throttle:60,1' + 'preview_mimes' => [ // Supported file types for temporary pre-signed file URLs... + 'png', 'gif', 'bmp', 'svg', 'wav', 'mp4', + 'mov', 'avi', 'wmv', 'mp3', 'm4a', + 'jpg', 'jpeg', 'mpga', 'webp', 'wma', + ], + 'max_upload_time' => 5, // Max duration (in minutes) before an upload is invalidated... + 'cleanup' => true, // Should cleanup temporary uploads older than 24 hrs... + ], + + /* + |--------------------------------------------------------------------------- + | Render On Redirect + |--------------------------------------------------------------------------- + | + | This value determines if Livewire will run a component's `render()` method + | after a redirect has been triggered using something like `redirect(...)` + | Setting this to true will render the view once more before redirecting + | + */ + + 'render_on_redirect' => false, + + /* + |--------------------------------------------------------------------------- + | Eloquent Model Binding + |--------------------------------------------------------------------------- + | + | Previous versions of Livewire supported binding directly to eloquent model + | properties using wire:model by default. However, this behavior has been + | deemed too "magical" and has therefore been put under a feature flag. + | + */ + + 'legacy_model_binding' => false, + + /* + |--------------------------------------------------------------------------- + | Auto-inject Frontend Assets + |--------------------------------------------------------------------------- + | + | By default, Livewire automatically injects its JavaScript and CSS into the + | and of pages containing Livewire components. By disabling + | this behavior, you need to use @livewireStyles and @livewireScripts. + | + */ + + 'inject_assets' => true, + + /* + |--------------------------------------------------------------------------- + | Navigate (SPA mode) + |--------------------------------------------------------------------------- + | + | By adding `wire:navigate` to links in your Livewire application, Livewire + | will prevent the default link handling and instead request those pages + | via AJAX, creating an SPA-like effect. Configure this behavior here. + | + */ + + 'navigate' => [ + 'show_progress_bar' => true, + 'progress_bar_color' => '#2299dd', + ], + + /* + |--------------------------------------------------------------------------- + | HTML Morph Markers + |--------------------------------------------------------------------------- + | + | Livewire intelligently "morphs" existing HTML into the newly rendered HTML + | after each update. To make this process more reliable, Livewire injects + | "markers" into the rendered Blade surrounding @if, @class & @foreach. + | + */ + + 'inject_morph_markers' => true, + + /* + |--------------------------------------------------------------------------- + | Smart Wire Keys + |--------------------------------------------------------------------------- + | + | Livewire uses loops and keys used within loops to generate smart keys that + | are applied to nested components that don't have them. This makes using + | nested components more reliable by ensuring that they all have keys. + | + */ + + 'smart_wire_keys' => true, + + /* + |--------------------------------------------------------------------------- + | Pagination Theme + |--------------------------------------------------------------------------- + | + | When enabling Livewire's pagination feature by using the `WithPagination` + | trait, Livewire will use Tailwind templates to render pagination views + | on the page. If you want Bootstrap CSS, you can specify: "bootstrap" + | + */ + + 'pagination_theme' => 'tailwind', + + /* + |--------------------------------------------------------------------------- + | Release Token + |--------------------------------------------------------------------------- + | + | This token is stored client-side and sent along with each request to check + | a users session to see if a new release has invalidated it. If there is + | a mismatch it will throw an error and prompt for a browser refresh. + | + */ + + 'release_token' => 'a', + + /* + |--------------------------------------------------------------------------- + | CSP Safe + |--------------------------------------------------------------------------- + | + | This config is used to determine if Livewire will use the CSP-safe version + | of Alpine in its bundle. This is useful for applications that are using + | strict Content Security Policy (CSP) to protect against XSS attacks. + | + */ + + 'csp_safe' => false, + + /* + |--------------------------------------------------------------------------- + | Payload Guards + |--------------------------------------------------------------------------- + | + | These settings protect against malicious or oversized payloads that could + | cause denial of service. The default values should feel reasonable for + | most web applications. Each can be set to null to disable the limit. + | + */ + + 'payload' => [ + 'max_size' => 1024 * 1024, // 1MB - maximum request payload size in bytes + 'max_nesting_depth' => 10, // Maximum depth of dot-notation property paths + 'max_calls' => 50, // Maximum method calls per request + 'max_components' => 20, // Maximum components per batch request + ], +]; diff --git a/resources/views/layouts/app.blade.php b/resources/views/layouts/app.blade.php new file mode 100644 index 0000000..5100c31 --- /dev/null +++ b/resources/views/layouts/app.blade.php @@ -0,0 +1,18 @@ + + + + + + + {{ $title ?? config('app.name') }} + + @vite(['resources/css/app.css', 'resources/js/app.js']) + + @livewireStyles + + + {{ $slot }} + + @livewireScripts + + From 7853fba840a55cb5266999b5925ef9d59ac03120 Mon Sep 17 00:00:00 2001 From: Giulio Troccoli-Allard Date: Sun, 8 Mar 2026 15:28:56 +0000 Subject: [PATCH 02/63] feat: install Flux and Flux Pro --- composer.json | 9 +- composer.lock | 141 +++++++++++++++++++++++++- resources/css/app.css | 3 +- resources/views/layouts/app.blade.php | 5 + 4 files changed, 155 insertions(+), 3 deletions(-) diff --git a/composer.json b/composer.json index 3c15850..3b136b2 100644 --- a/composer.json +++ b/composer.json @@ -13,6 +13,8 @@ "ext-iconv": "*", "laravel/framework": "^12.0", "laravel/tinker": "^2.10.1", + "livewire/flux": "^2.13", + "livewire/flux-pro": "^2.13", "livewire/livewire": "^4.2", "setasign/fpdf": "^1.8", "setasign/fpdi": "^2.6" @@ -108,5 +110,10 @@ } }, "minimum-stability": "stable", - "prefer-stable": true + "prefer-stable": true, + "repositories": [{ + "name": "flux-pro", + "type": "composer", + "url": "https://composer.fluxui.dev" + }] } diff --git a/composer.lock b/composer.lock index a787fbf..68834c0 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": "05e94318c2a169312455eaa16bbef3c4", + "content-hash": "c7bfcf11f9dc9bf9ddd8d5231e410378", "packages": [ { "name": "brick/math", @@ -2019,6 +2019,145 @@ ], "time": "2026-01-15T06:54:53+00:00" }, + { + "name": "livewire/flux", + "version": "v2.13.0", + "source": { + "type": "git", + "url": "https://github.com/livewire/flux.git", + "reference": "741be2d4526e90b97c7a59e079a2f27ecdce2461" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/livewire/flux/zipball/741be2d4526e90b97c7a59e079a2f27ecdce2461", + "reference": "741be2d4526e90b97c7a59e079a2f27ecdce2461", + "shasum": "" + }, + "require": { + "illuminate/console": "^10.0|^11.0|^12.0|^13.0", + "illuminate/support": "^10.0|^11.0|^12.0|^13.0", + "illuminate/view": "^10.0|^11.0|^12.0|^13.0", + "laravel/prompts": "^0.1|^0.2|^0.3", + "livewire/livewire": "^3.7.4|^4.0", + "php": "^8.1", + "symfony/console": "^6.0|^7.0|^8.0" + }, + "conflict": { + "livewire/blaze": "<1.0.0-beta.2" + }, + "type": "library", + "extra": { + "laravel": { + "aliases": { + "Flux": "Flux\\Flux" + }, + "providers": [ + "Flux\\FluxServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Flux\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "proprietary" + ], + "authors": [ + { + "name": "Caleb Porzio", + "email": "calebporzio@gmail.com" + } + ], + "description": "The official UI component library for Livewire.", + "keywords": [ + "components", + "flux", + "laravel", + "livewire", + "ui" + ], + "support": { + "issues": "https://github.com/livewire/flux/issues", + "source": "https://github.com/livewire/flux/tree/v2.13.0" + }, + "time": "2026-03-03T03:32:35+00:00" + }, + { + "name": "livewire/flux-pro", + "version": "2.13.0", + "dist": { + "type": "zip", + "url": "https://composer.fluxui.dev/download/a1357bcd-0c9f-48ec-a532-2aced09459e7/flux-pro-2.13.0.zip", + "reference": "3d91059d053ac94a2f522944f581c3876963656c", + "shasum": "dc3d4e5baedc6e525f41f25474885413e5c1e13e" + }, + "require": { + "illuminate/console": "^10.0|^11.0|^12.0|^13.0", + "illuminate/support": "^10.0|^11.0|^12.0|^13.0", + "illuminate/view": "^10.0|^11.0|^12.0|^13.0", + "laravel/prompts": "^0.1.24|^0.2|^0.3", + "livewire/flux": "2.13.0|dev-main", + "livewire/livewire": "^3.7.4|^4.0", + "php": "^8.1", + "symfony/console": "^6.0|^7.0|^8.0" + }, + "require-dev": { + "livewire/volt": "*", + "orchestra/testbench": "^10.8|^11.0" + }, + "type": "library", + "extra": { + "laravel": { + "aliases": { + "Flux": "FluxPro\\FluxPro" + }, + "providers": [ + "FluxPro\\FluxProServiceProvider" + ] + } + }, + "autoload": { + "files": [ + "src/helpers.php" + ], + "psr-4": { + "FluxPro\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "App\\": "workbench/app/" + } + }, + "scripts": { + "serve": [ + "Composer\\Config::disableProcessTimeout", + "@php vendor/bin/testbench workbench:build --ansi", + "@php vendor/bin/testbench serve --port 3000 --ansi" + ] + }, + "license": [ + "proprietary" + ], + "authors": [ + { + "name": "Caleb Porzio", + "email": "calebporzio@gmail.com" + } + ], + "description": "The pro version of Flux, the official UI component library for Livewire.", + "keywords": [ + "components", + "flux", + "laravel", + "livewire", + "ui" + ], + "time": "2026-03-03T04:07:04+00:00" + }, { "name": "livewire/livewire", "version": "v4.2.1", diff --git a/resources/css/app.css b/resources/css/app.css index 3e6abea..694f746 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -1,4 +1,5 @@ @import 'tailwindcss'; +@import '../../vendor/livewire/flux/dist/flux.css'; @source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php'; @source '../../storage/framework/views/*.php'; @@ -6,6 +7,6 @@ @source '../**/*.js'; @theme { - --font-sans: 'Instrument Sans', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', + --font-sans: 'Saira', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'; } diff --git a/resources/views/layouts/app.blade.php b/resources/views/layouts/app.blade.php index 5100c31..360b588 100644 --- a/resources/views/layouts/app.blade.php +++ b/resources/views/layouts/app.blade.php @@ -6,6 +6,10 @@ {{ $title ?? config('app.name') }} + + + + @vite(['resources/css/app.css', 'resources/js/app.js']) @livewireStyles @@ -14,5 +18,6 @@ {{ $slot }} @livewireScripts + @fluxScripts From 48a34f7171d78e33ad4e472aea183bc3054b5841 Mon Sep 17 00:00:00 2001 From: Giulio Troccoli-Allard Date: Sun, 8 Mar 2026 17:35:35 +0000 Subject: [PATCH 03/63] feat: implement front-end --- app/Data/GameState/GameState.php | 4 +- app/Livewire/Court.php | 32 ++++++ app/Livewire/Game.php | 37 +++++++ app/Livewire/LineupSubmission.php | 78 +++++++++++++ app/Livewire/TossResultSubmission.php | 103 ++++++++++++++++++ app/Services/GameState/GameStateProjector.php | 2 +- .../GameState/GameStateRecalculator.php | 2 +- rector.php | 8 +- resources/views/livewire/court.blade.php | 10 ++ resources/views/livewire/game.blade.php | 12 ++ .../livewire/lineup-submission.blade.php | 32 ++++++ .../livewire/toss-result-submission.blade.php | 56 ++++++++++ routes/web.php | 4 + tests/Feature/ExampleTest.php | 31 +++++- tests/Feature/GameComponentStateTest.php | 36 ++++++ tests/Feature/GameStateProjectionTest.php | 38 +++++++ tests/Feature/LineupSubmissionTest.php | 59 ++++++++++ tests/Feature/TossResultSubmissionTest.php | 63 +++++++++++ 18 files changed, 598 insertions(+), 9 deletions(-) create mode 100644 app/Livewire/Court.php create mode 100644 app/Livewire/Game.php create mode 100644 app/Livewire/LineupSubmission.php create mode 100644 app/Livewire/TossResultSubmission.php create mode 100644 resources/views/livewire/court.blade.php create mode 100644 resources/views/livewire/game.blade.php create mode 100644 resources/views/livewire/lineup-submission.blade.php create mode 100644 resources/views/livewire/toss-result-submission.blade.php create mode 100644 tests/Feature/GameComponentStateTest.php create mode 100644 tests/Feature/LineupSubmissionTest.php create mode 100644 tests/Feature/TossResultSubmissionTest.php diff --git a/app/Data/GameState/GameState.php b/app/Data/GameState/GameState.php index 16ef972..eddfecd 100644 --- a/app/Data/GameState/GameState.php +++ b/app/Data/GameState/GameState.php @@ -61,7 +61,7 @@ public static function fromSnapshot(GameStateSnapshot $snapshot): self /** * @return array */ - public function toSnapshotAttributes(): array + public function toAttributes(): array { return [ 'set_number' => $this->setNumber, @@ -73,7 +73,7 @@ public function toSnapshotAttributes(): array 'timeouts_team_b' => $this->timeoutsTeamB, 'substitutions_team_a' => $this->substitutionsTeamA, 'substitutions_team_b' => $this->substitutionsTeamB, - 'serving_team' => $this->servingTeam, + 'serving_team' => $this->servingTeam?->value, 'rotation_team_a' => $this->rotationTeamA, 'rotation_team_b' => $this->rotationTeamB, 'set_in_progress' => $this->setInProgress, diff --git a/app/Livewire/Court.php b/app/Livewire/Court.php new file mode 100644 index 0000000..9aef8e9 --- /dev/null +++ b/app/Livewire/Court.php @@ -0,0 +1,32 @@ + */ + #[Reactive] + public array $gameState = []; + + /** + * @param array $gameState + */ + public function mount(?int $gameId = null, array $gameState = []): void + { + $this->gameId = $gameId; + $this->gameState = $gameState; + } + + public function render(): \Illuminate\Contracts\View\View + { + return view('livewire.court'); + } +} diff --git a/app/Livewire/Game.php b/app/Livewire/Game.php new file mode 100644 index 0000000..9d4c7d5 --- /dev/null +++ b/app/Livewire/Game.php @@ -0,0 +1,37 @@ + */ + public array $gameState = []; + + public function mount(GameModel $game): void + { + $this->gameId = $game->getKey(); + $this->synchronizeGameContext(); + } + + #[On('game-event-recorded')] + public function synchronizeGameContext(): void + { + $game = GameModel::query()->findSole($this->gameId); + $state = $game->stateAt(); + + $this->gameState = $state->toAttributes(); + } + + public function render(): \Illuminate\Contracts\View\View + { + return view('livewire.game'); + } +} diff --git a/app/Livewire/LineupSubmission.php b/app/Livewire/LineupSubmission.php new file mode 100644 index 0000000..9156658 --- /dev/null +++ b/app/Livewire/LineupSubmission.php @@ -0,0 +1,78 @@ +value; + + #[Reactive] + #[Locked] + public ?int $gameId = null; + + /** @var array */ + #[Reactive] + public array $gameState = []; + + /** @var array */ + public array $lineup = []; + + /** + * @param array $gameState + */ + public function mount(string $team, ?int $gameId = null, array $gameState = []): void + { + if (! in_array($team, array_column(TeamAB::cases(), 'value'), true)) { + throw new \InvalidArgumentException('Unsupported team value for lineup submission.'); + } + + $this->team = $team; + $this->gameId = $gameId; + $this->gameState = $gameState; + $this->lineup = [ + 1 => '', + 2 => '', + 3 => '', + 4 => '', + 5 => '', + 6 => '', + ]; + } + + public function modalName(): string + { + return 'submit-lineup-'.$this->team; + } + + public function buttonLabel(): string + { + return $this->team === TeamAB::TeamA->value + ? 'Submit Team A Lineup' + : 'Submit Team B Lineup'; + } + + public function modalHeading(): string + { + return $this->team === TeamAB::TeamA->value + ? 'Team A Lineup' + : 'Team B Lineup'; + } + + public function submit(): void + { + Flux::modal($this->modalName())->close(); + } + + public function render(): \Illuminate\Contracts\View\View + { + return view('livewire.lineup-submission'); + } +} diff --git a/app/Livewire/TossResultSubmission.php b/app/Livewire/TossResultSubmission.php new file mode 100644 index 0000000..207dc70 --- /dev/null +++ b/app/Livewire/TossResultSubmission.php @@ -0,0 +1,103 @@ + */ + #[Reactive] + public array $gameState = []; + + public string $teamA = TeamSide::Home->value; + + public string $serving = TeamAB::TeamA->value; + + /** + * @param array $gameState + */ + public function mount(?int $gameId = null, array $gameState = []): void + { + $this->gameId = $gameId; + $this->gameState = $gameState; + } + + /** + * @return array> + */ + protected function rules(): array + { + return [ + 'teamA' => ['required', Rule::enum(TeamSide::class)], + 'serving' => ['required', Rule::enum(TeamAB::class)], + ]; + } + + /** + * @return array + */ + protected function messages(): array + { + return [ + 'teamA.required' => 'Select whether Team A is home or away.', + 'serving.required' => 'Select whether Team A or Team B serves first.', + ]; + } + + public function submit(): void + { + $this->validate(); + + if ($this->gameId === null) { + $this->addError('submit', 'No active game is available to record the toss.'); + + return; + } + + $activeGame = Game::query()->whereKey($this->gameId)->first(); + + if ($activeGame === null) { + $this->addError('submit', 'No active game is available to record the toss.'); + + return; + } + + try { + $activeGame->recordToss( + teamA: TeamSide::from($this->teamA), + serving: TeamAB::from($this->serving), + ); + } catch (InvalidGameEventTransition $exception) { + $this->addError('submit', $exception->getMessage()); + + return; + } + + Flux::modal('submit-toss-result')->close(); + $this->dispatch('game-event-recorded'); + + $this->resetValidation(); + $this->teamA = TeamSide::Home->value; + $this->serving = TeamAB::TeamA->value; + } + + public function render(): \Illuminate\Contracts\View\View + { + return view('livewire.toss-result-submission'); + } +} diff --git a/app/Services/GameState/GameStateProjector.php b/app/Services/GameState/GameStateProjector.php index 6d11e8a..72d8a7d 100644 --- a/app/Services/GameState/GameStateProjector.php +++ b/app/Services/GameState/GameStateProjector.php @@ -58,7 +58,7 @@ public function projectAndStore(GameEvent $event): GameStateSnapshot return GameStateSnapshot::query()->create([ 'game_id' => $event->game_id, 'game_event_id' => $event->getKey(), - ...$state->toSnapshotAttributes(), + ...$state->toAttributes(), 'created_at' => $event->created_at, ]); } diff --git a/app/Services/GameState/GameStateRecalculator.php b/app/Services/GameState/GameStateRecalculator.php index 59bb0ba..c93c2da 100644 --- a/app/Services/GameState/GameStateRecalculator.php +++ b/app/Services/GameState/GameStateRecalculator.php @@ -39,7 +39,7 @@ public function recalculate(Game $game, ?CarbonImmutable $upTo = null): void GameStateSnapshot::query()->create([ 'game_id' => $game->getKey(), 'game_event_id' => $event->getKey(), - ...$state->toSnapshotAttributes(), + ...$state->toAttributes(), 'created_at' => $event->created_at, ]); } diff --git a/rector.php b/rector.php index a436820..8714b12 100644 --- a/rector.php +++ b/rector.php @@ -3,6 +3,8 @@ declare(strict_types=1); use Rector\Config\RectorConfig; +use RectorLaravel\Set\LaravelLevelSetList; +use RectorLaravel\Set\LaravelSetProvider; return RectorConfig::configure() ->withPaths([ @@ -14,9 +16,11 @@ __DIR__.'/routes', __DIR__.'/tests', ]) - // uncomment to reach your current PHP version ->withPhpSets() - ->withSetProviders(\RectorLaravel\Set\LaravelSetProvider::class) + ->withSets([ + LaravelLevelSetList::UP_TO_LARAVEL_120, + ]) +// ->withSetProviders(LaravelSetProvider::class) ->withComposerBased(laravel: true) ->withTypeCoverageLevel(0) ->withDeadCodeLevel(0) diff --git a/resources/views/livewire/court.blade.php b/resources/views/livewire/court.blade.php new file mode 100644 index 0000000..065938e --- /dev/null +++ b/resources/views/livewire/court.blade.php @@ -0,0 +1,10 @@ +
+
+
+
+
+
diff --git a/resources/views/livewire/game.blade.php b/resources/views/livewire/game.blade.php new file mode 100644 index 0000000..3066fc4 --- /dev/null +++ b/resources/views/livewire/game.blade.php @@ -0,0 +1,12 @@ +
+
+
+ +
+ + +
+
+ +
+
diff --git a/resources/views/livewire/lineup-submission.blade.php b/resources/views/livewire/lineup-submission.blade.php new file mode 100644 index 0000000..fae8340 --- /dev/null +++ b/resources/views/livewire/lineup-submission.blade.php @@ -0,0 +1,32 @@ +
+ + + {{ $this->buttonLabel() }} + + + + +
+ {{ $this->modalHeading() }} + +
+ @for ($position = 1; $position <= 6; $position++) + + @endfor +
+ +
+ + Submit +
+
+
+
diff --git a/resources/views/livewire/toss-result-submission.blade.php b/resources/views/livewire/toss-result-submission.blade.php new file mode 100644 index 0000000..19980b0 --- /dev/null +++ b/resources/views/livewire/toss-result-submission.blade.php @@ -0,0 +1,56 @@ +
+ + + Submit Toss Result + + + + +
+
+ Submit Toss Result + + Select which side is Team A and which team serves first. + +
+ + + + + + @error('teamA') + {{ $message }} + @enderror + + + + + + @error('serving') + {{ $message }} + @enderror + + @error('submit') + {{ $message }} + @enderror + +
+ + + Cancel + + Save Toss Result +
+
+
+
diff --git a/routes/web.php b/routes/web.php index 9d02afd..8eb0fe3 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,5 +1,9 @@ view('welcome')); +Route::livewire('/game/{game}', Game::class)->name('game'); diff --git a/tests/Feature/ExampleTest.php b/tests/Feature/ExampleTest.php index 0e7bde0..977c702 100644 --- a/tests/Feature/ExampleTest.php +++ b/tests/Feature/ExampleTest.php @@ -1,7 +1,32 @@ get('/'); +declare(strict_types=1); - $response->assertSuccessful(); +use App\Livewire\Game; +use App\Models\Game as GameModel; +use Illuminate\Foundation\Testing\RefreshDatabase; +use Livewire\Livewire; + +uses(RefreshDatabase::class); + +test('the game page returns a successful response and renders the fixed canvas with the court', function (): void { + $game = GameModel::factory()->create(); + + $response = $this->get(route('game', ['game' => $game])); + + $response->assertSuccessful() + ->assertSee('id="game-canvas"', false) + ->assertSee('bg-sky-100') + ->assertSee('id="volleyball-court"', false) + ->assertSee('Submit Toss Result') + ->assertSee('Submit Team A Lineup') + ->assertSee('Submit Team B Lineup'); +}); + +test('the game livewire component renders the court component', function (): void { + $game = GameModel::factory()->create(); + + Livewire::test(Game::class, ['game' => $game]) + ->assertSeeHtml('id="game-canvas"') + ->assertSeeHtml('id="volleyball-court"'); }); diff --git a/tests/Feature/GameComponentStateTest.php b/tests/Feature/GameComponentStateTest.php new file mode 100644 index 0000000..8c9f519 --- /dev/null +++ b/tests/Feature/GameComponentStateTest.php @@ -0,0 +1,36 @@ +create(); + $game->recordToss(TeamSide::Away, TeamAB::TeamB); + + Livewire::test(Game::class, ['game' => $game]) + ->assertSet('gameId', $game->getKey()) + ->assertSet('gameState.serving_team', TeamAB::TeamB->value) + ->assertSet('gameState.set_number', 0) + ->assertSet('gameState.rotation_team_a', []) + ->assertSet('gameState.rotation_team_b', []); +}); + +test('game component uses the passed game id instead of the latest game', function (): void { + $targetGame = GameModel::factory()->create(); + $targetGame->recordToss(TeamSide::Home, TeamAB::TeamA); + + $latestGame = GameModel::factory()->create(); + $latestGame->recordToss(TeamSide::Away, TeamAB::TeamB); + + Livewire::test(Game::class, ['game' => $targetGame]) + ->assertSet('gameId', $targetGame->getKey()) + ->assertSet('gameState.serving_team', TeamAB::TeamA->value); +}); diff --git a/tests/Feature/GameStateProjectionTest.php b/tests/Feature/GameStateProjectionTest.php index 9bf483d..a9a5d6e 100644 --- a/tests/Feature/GameStateProjectionTest.php +++ b/tests/Feature/GameStateProjectionTest.php @@ -1,10 +1,14 @@ and($latest->scoreTeamB)->toBe(0) ->and($latest->servingTeam)->toBe(TeamAB::TeamA); }); + +test('game state snapshot accepts a serialized serving team and casts it back to enum', function (): void { + $game = Game::factory()->create(); + + $event = GameEvent::withoutEvents(fn (): GameEvent => GameEvent::query()->create([ + 'game_id' => $game->getKey(), + 'type' => GameEventType::SetStarted, + 'payload' => new SetStartedPayload, + 'created_at' => now(), + ])); + + $snapshot = GameStateSnapshot::query()->create([ + 'game_id' => $game->getKey(), + 'game_event_id' => $event->getKey(), + 'set_number' => 1, + 'score_team_a' => 0, + 'score_team_b' => 0, + 'sets_won_team_a' => 0, + 'sets_won_team_b' => 0, + 'timeouts_team_a' => 0, + 'timeouts_team_b' => 0, + 'substitutions_team_a' => 0, + 'substitutions_team_b' => 0, + 'serving_team' => TeamAB::TeamB->value, + 'rotation_team_a' => [], + 'rotation_team_b' => [], + 'set_in_progress' => true, + 'game_ended' => false, + 'created_at' => now(), + ]); + + expect($snapshot->serving_team)->toBe(TeamAB::TeamB) + ->and($snapshot->getRawOriginal('serving_team'))->toBe(TeamAB::TeamB->value); +}); diff --git a/tests/Feature/LineupSubmissionTest.php b/tests/Feature/LineupSubmissionTest.php new file mode 100644 index 0000000..3565a53 --- /dev/null +++ b/tests/Feature/LineupSubmissionTest.php @@ -0,0 +1,59 @@ + TeamAB::TeamA->value]) + ->assertSee('Submit Team A Lineup') + ->assertSee('Team A Lineup') + ->assertSeeHtml('submit-lineup-team_a') + ->assertSeeHtml('name="lineup[1]"') + ->assertSeeHtml('name="lineup[6]"') + ->assertSee('Submit'); +}); + +test('lineup submission renders team b button and modal', function (): void { + Livewire::test(LineupSubmission::class, ['team' => TeamAB::TeamB->value]) + ->assertSee('Submit Team B Lineup') + ->assertSee('Team B Lineup') + ->assertSeeHtml('submit-lineup-team_b') + ->assertSeeHtml('name="lineup[1]"') + ->assertSeeHtml('name="lineup[6]"') + ->assertSee('Submit'); +}); + +test('lineup submission rejects unsupported team value', function (): void { + expect(fn (): \Livewire\Features\SupportTesting\Testable => Livewire::test(LineupSubmission::class, ['team' => 'invalid'])) + ->toThrow(ViewException::class, 'Unsupported team value for lineup submission.'); +}); + +test('lineup submission accepts submit action', function (): void { + Livewire::test(LineupSubmission::class, ['team' => TeamAB::TeamA->value]) + ->set('lineup.1', '1') + ->set('lineup.2', '2') + ->set('lineup.3', '3') + ->set('lineup.4', '4') + ->set('lineup.5', '5') + ->set('lineup.6', '6') + ->call('submit') + ->assertHasNoErrors(); +}); + +test('lineup submission is aware of the injected game context', function (): void { + Livewire::test(LineupSubmission::class, [ + 'team' => TeamAB::TeamA->value, + 'gameId' => 42, + 'gameState' => [ + 'set_number' => 2, + 'serving_team' => TeamAB::TeamB->value, + ], + ]) + ->assertSet('gameId', 42) + ->assertSet('gameState.set_number', 2) + ->assertSet('gameState.serving_team', TeamAB::TeamB->value); +}); diff --git a/tests/Feature/TossResultSubmissionTest.php b/tests/Feature/TossResultSubmissionTest.php new file mode 100644 index 0000000..f32e2a1 --- /dev/null +++ b/tests/Feature/TossResultSubmissionTest.php @@ -0,0 +1,63 @@ +create(); + + Livewire::test(TossResultSubmission::class, ['gameId' => $game->getKey()]) + ->set('teamA', TeamSide::Away->value) + ->set('serving', TeamAB::TeamB->value) + ->call('submit') + ->assertHasNoErrors(); + + $event = $game->fresh()->events->first(); + + expect($event)->not->toBeNull() + ->and($event->type)->toBe(GameEventType::TossCompleted) + ->and($event->payload->teamA)->toBe(TeamSide::Away) + ->and($event->payload->serving)->toBe(TeamAB::TeamB); +}); + +test('submitting toss result fails when there is no active game', function (): void { + Livewire::test(TossResultSubmission::class) + ->call('submit') + ->assertHasErrors(['submit']); +}); + +test('submitting toss result fails when toss has already been recorded', function (): void { + $game = Game::factory()->create(); + $game->recordToss(TeamSide::Home, TeamAB::TeamA); + + Livewire::test(TossResultSubmission::class, ['gameId' => $game->getKey()]) + ->set('teamA', TeamSide::Away->value) + ->set('serving', TeamAB::TeamB->value) + ->call('submit') + ->assertHasErrors(['submit']); + + expect($game->fresh()->events)->toHaveCount(1); +}); + +test('submitting toss result records the event against the provided game id', function (): void { + $targetGame = Game::factory()->create(); + $otherGame = Game::factory()->create(); + + Livewire::test(TossResultSubmission::class, ['gameId' => $targetGame->getKey()]) + ->set('teamA', TeamSide::Away->value) + ->set('serving', TeamAB::TeamA->value) + ->call('submit') + ->assertHasNoErrors(); + + expect($targetGame->fresh()->events)->toHaveCount(1) + ->and($otherGame->fresh()->events)->toHaveCount(0); +}); From cf74c2fa4f96326f65ff8d5a373581a12bc92399 Mon Sep 17 00:00:00 2001 From: Giulio Troccoli-Allard Date: Sun, 8 Mar 2026 17:39:27 +0000 Subject: [PATCH 04/63] fix: updating the project's guidelines to run rector befor finalising the changes --- .ai/guidelines/testing.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.ai/guidelines/testing.md b/.ai/guidelines/testing.md index 2223896..50e252c 100644 --- a/.ai/guidelines/testing.md +++ b/.ai/guidelines/testing.md @@ -2,6 +2,6 @@ - Never pass attributes directly to `create()` or `make()` in tests. Instead, define named state methods on the factory and chain them in the test (e.g. `->asCaptain()`, `->withRole(...)`). -## Static Analysis +## Rector -- After modifying PHP files, run `vendor/bin/phpstan analyse --memory-limit=256M` and fix any errors before finalizing changes. +- After modifying PHP files, run `vendor/bin/rector process --clear-cache --memory-limit=2G` and fix any issues before finalizing changes. From 676f2fa3e1e9afdf2bfee6cb37581c9ea5a0d91f Mon Sep 17 00:00:00 2001 From: Giulio Troccoli-Allard Date: Sun, 15 Mar 2026 17:09:44 +0000 Subject: [PATCH 05/63] fix: update composer and node packages --- app/Livewire/Court.php | 3 +- app/Livewire/Game.php | 3 +- app/Livewire/LineupSubmission.php | 3 +- app/Livewire/TossResultSubmission.php | 3 +- app/Models/Concerns/RecordsEndOfGame.php | 3 +- app/Models/Concerns/RecordsEndOfRally.php | 3 +- app/Models/Concerns/RecordsEndOfSet.php | 3 +- app/Models/Concerns/RecordsLineup.php | 3 +- app/Models/Concerns/RecordsStartOfSet.php | 3 +- app/Models/Concerns/RecordsSubstitution.php | 3 +- app/Models/Concerns/RecordsTimeOut.php | 3 +- app/Models/Concerns/RecordsToss.php | 3 +- app/Models/User.php | 3 +- bootstrap/providers.php | 4 +- composer.lock | 250 +++++++------------- config/auth.php | 4 +- config/database.php | 5 +- package-lock.json | 214 ++++++++--------- tests/Feature/GameEventTest.php | 10 +- tests/Feature/LineupSubmissionTest.php | 3 +- 20 files changed, 233 insertions(+), 296 deletions(-) diff --git a/app/Livewire/Court.php b/app/Livewire/Court.php index 9aef8e9..9c3e1a0 100644 --- a/app/Livewire/Court.php +++ b/app/Livewire/Court.php @@ -4,6 +4,7 @@ namespace App\Livewire; +use Illuminate\Contracts\View\View; use Livewire\Attributes\Reactive; use Livewire\Component; @@ -25,7 +26,7 @@ public function mount(?int $gameId = null, array $gameState = []): void $this->gameState = $gameState; } - public function render(): \Illuminate\Contracts\View\View + public function render(): View { return view('livewire.court'); } diff --git a/app/Livewire/Game.php b/app/Livewire/Game.php index 9d4c7d5..97616b7 100644 --- a/app/Livewire/Game.php +++ b/app/Livewire/Game.php @@ -5,6 +5,7 @@ namespace App\Livewire; use App\Models\Game as GameModel; +use Illuminate\Contracts\View\View; use Livewire\Attributes\On; use Livewire\Component; @@ -30,7 +31,7 @@ public function synchronizeGameContext(): void $this->gameState = $state->toAttributes(); } - public function render(): \Illuminate\Contracts\View\View + public function render(): View { return view('livewire.game'); } diff --git a/app/Livewire/LineupSubmission.php b/app/Livewire/LineupSubmission.php index 9156658..4dfb63c 100644 --- a/app/Livewire/LineupSubmission.php +++ b/app/Livewire/LineupSubmission.php @@ -6,6 +6,7 @@ use App\Enums\TeamAB; use Flux\Flux; +use Illuminate\Contracts\View\View; use Livewire\Attributes\Locked; use Livewire\Attributes\Reactive; use Livewire\Component; @@ -71,7 +72,7 @@ public function submit(): void Flux::modal($this->modalName())->close(); } - public function render(): \Illuminate\Contracts\View\View + public function render(): View { return view('livewire.lineup-submission'); } diff --git a/app/Livewire/TossResultSubmission.php b/app/Livewire/TossResultSubmission.php index 207dc70..9a9d590 100644 --- a/app/Livewire/TossResultSubmission.php +++ b/app/Livewire/TossResultSubmission.php @@ -9,6 +9,7 @@ use App\Exceptions\InvalidGameEventTransition; use App\Models\Game; use Flux\Flux; +use Illuminate\Contracts\View\View; use Illuminate\Validation\Rule; use Livewire\Attributes\Locked; use Livewire\Attributes\Reactive; @@ -96,7 +97,7 @@ public function submit(): void $this->serving = TeamAB::TeamA->value; } - public function render(): \Illuminate\Contracts\View\View + public function render(): View { return view('livewire.toss-result-submission'); } diff --git a/app/Models/Concerns/RecordsEndOfGame.php b/app/Models/Concerns/RecordsEndOfGame.php index c6615c8..a7f42df 100644 --- a/app/Models/Concerns/RecordsEndOfGame.php +++ b/app/Models/Concerns/RecordsEndOfGame.php @@ -6,10 +6,11 @@ use App\Enums\GameEventType; use App\Events\Payloads\GameEndedPayload; +use App\Models\Game; use App\Services\GameState\GameEventRuleValidator; /** - * @mixin \App\Models\Game + * @mixin Game */ trait RecordsEndOfGame { diff --git a/app/Models/Concerns/RecordsEndOfRally.php b/app/Models/Concerns/RecordsEndOfRally.php index 2a5624f..258d996 100644 --- a/app/Models/Concerns/RecordsEndOfRally.php +++ b/app/Models/Concerns/RecordsEndOfRally.php @@ -7,10 +7,11 @@ use App\Enums\GameEventType; use App\Enums\TeamAB; use App\Events\Payloads\RallyEndedPayload; +use App\Models\Game; use App\Services\GameState\GameEventRuleValidator; /** - * @mixin \App\Models\Game + * @mixin Game */ trait RecordsEndOfRally { diff --git a/app/Models/Concerns/RecordsEndOfSet.php b/app/Models/Concerns/RecordsEndOfSet.php index b0228ec..99e2ad6 100644 --- a/app/Models/Concerns/RecordsEndOfSet.php +++ b/app/Models/Concerns/RecordsEndOfSet.php @@ -6,10 +6,11 @@ use App\Enums\GameEventType; use App\Events\Payloads\SetEndedPayload; +use App\Models\Game; use App\Services\GameState\GameEventRuleValidator; /** - * @mixin \App\Models\Game + * @mixin Game */ trait RecordsEndOfSet { diff --git a/app/Models/Concerns/RecordsLineup.php b/app/Models/Concerns/RecordsLineup.php index f8dde7a..83817b5 100644 --- a/app/Models/Concerns/RecordsLineup.php +++ b/app/Models/Concerns/RecordsLineup.php @@ -9,13 +9,14 @@ use App\Enums\TeamSide; use App\Events\Payloads\LineupSubmittedPayload; use App\Events\Payloads\TossCompletedPayload; +use App\Models\Game; use App\Models\GameEvent; use App\Services\GameState\GameEventRuleValidator; use Illuminate\Support\Collection; use LogicException; /** - * @mixin \App\Models\Game + * @mixin Game */ trait RecordsLineup { diff --git a/app/Models/Concerns/RecordsStartOfSet.php b/app/Models/Concerns/RecordsStartOfSet.php index cd5b969..e27c641 100644 --- a/app/Models/Concerns/RecordsStartOfSet.php +++ b/app/Models/Concerns/RecordsStartOfSet.php @@ -6,10 +6,11 @@ use App\Enums\GameEventType; use App\Events\Payloads\SetStartedPayload; +use App\Models\Game; use App\Services\GameState\GameEventRuleValidator; /** - * @mixin \App\Models\Game + * @mixin Game */ trait RecordsStartOfSet { diff --git a/app/Models/Concerns/RecordsSubstitution.php b/app/Models/Concerns/RecordsSubstitution.php index c652e0f..5eb53ec 100644 --- a/app/Models/Concerns/RecordsSubstitution.php +++ b/app/Models/Concerns/RecordsSubstitution.php @@ -7,10 +7,11 @@ use App\Enums\GameEventType; use App\Enums\TeamAB; use App\Events\Payloads\SubstitutionCompletedPayload; +use App\Models\Game; use App\Services\GameState\GameEventRuleValidator; /** - * @mixin \App\Models\Game + * @mixin Game */ trait RecordsSubstitution { diff --git a/app/Models/Concerns/RecordsTimeOut.php b/app/Models/Concerns/RecordsTimeOut.php index 3dd6e4d..8764453 100644 --- a/app/Models/Concerns/RecordsTimeOut.php +++ b/app/Models/Concerns/RecordsTimeOut.php @@ -7,10 +7,11 @@ use App\Enums\GameEventType; use App\Enums\TeamAB; use App\Events\Payloads\TimeOutRequestedPayload; +use App\Models\Game; use App\Services\GameState\GameEventRuleValidator; /** - * @mixin \App\Models\Game + * @mixin Game */ trait RecordsTimeOut { diff --git a/app/Models/Concerns/RecordsToss.php b/app/Models/Concerns/RecordsToss.php index 0a044f7..959a1d1 100644 --- a/app/Models/Concerns/RecordsToss.php +++ b/app/Models/Concerns/RecordsToss.php @@ -8,10 +8,11 @@ use App\Enums\TeamAB; use App\Enums\TeamSide; use App\Events\Payloads\TossCompletedPayload; +use App\Models\Game; use App\Services\GameState\GameEventRuleValidator; /** - * @mixin \App\Models\Game + * @mixin Game */ trait RecordsToss { diff --git a/app/Models/User.php b/app/Models/User.php index 0322b84..3dbc51b 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -5,13 +5,14 @@ namespace App\Models; // use Illuminate\Contracts\Auth\MustVerifyEmail; +use Database\Factories\UserFactory; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; class User extends Authenticatable { - /** @use HasFactory<\Database\Factories\UserFactory> */ + /** @use HasFactory */ use HasFactory, Notifiable; /** diff --git a/bootstrap/providers.php b/bootstrap/providers.php index 38b258d..fc94ae6 100644 --- a/bootstrap/providers.php +++ b/bootstrap/providers.php @@ -1,5 +1,7 @@ =7.2 <=8.5.99999" }, "conflict": { "setasign/tfpdf": "<1.31" }, "require-dev": { - "phpunit/phpunit": "^7", + "phpunit/phpunit": "^8.5.52", "setasign/fpdf": "~1.8.6", "setasign/tfpdf": "~1.33", "squizlabs/php_codesniffer": "^3.5", @@ -3617,7 +3618,7 @@ ], "support": { "issues": "https://github.com/Setasign/FPDI/issues", - "source": "https://github.com/Setasign/FPDI/tree/v2.6.4" + "source": "https://github.com/Setasign/FPDI/tree/v2.6.6" }, "funding": [ { @@ -3625,7 +3626,7 @@ "type": "tidelift" } ], - "time": "2025-08-05T09:57:14+00:00" + "time": "2026-03-13T08:38:20+00:00" }, { "name": "symfony/clock", @@ -6477,45 +6478,24 @@ }, { "name": "driftingly/rector-laravel", - "version": "2.1.10", + "version": "2.1.12", "source": { "type": "git", "url": "https://github.com/driftingly/rector-laravel.git", - "reference": "8f90418f1f17e29852a54791fd859547b23f00f4" + "reference": "2a2175eefabca6d15c247d55de17c75dc2f787a9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/driftingly/rector-laravel/zipball/8f90418f1f17e29852a54791fd859547b23f00f4", - "reference": "8f90418f1f17e29852a54791fd859547b23f00f4", + "url": "https://api.github.com/repos/driftingly/rector-laravel/zipball/2a2175eefabca6d15c247d55de17c75dc2f787a9", + "reference": "2a2175eefabca6d15c247d55de17c75dc2f787a9", "shasum": "" }, "require": { - "php": ">=8.3", - "rector/rector": "^2.3.0", - "symplify/rule-doc-generator-contracts": "^11.2", - "webmozart/assert": "^1.11 || ^2.0" - }, - "require-dev": { - "nette/utils": "^4.0", - "nikic/php-parser": "^5.6", - "phpstan/extension-installer": "^1.3", - "phpstan/phpstan": "^2.0", - "phpstan/phpstan-deprecation-rules": "^2.0", - "phpstan/phpstan-strict-rules": "^2.0", - "phpstan/phpstan-webmozart-assert": "^2.0", - "phpunit/phpunit": "^10.5", - "symplify/rule-doc-generator": "^12.2", - "tightenco/duster": "^3.1", - "tracy/tracy": "^2.10" + "php": "^7.4 || ^8.0", + "rector/rector": "^2.2.7", + "webmozart/assert": "^1.11" }, "type": "rector-extension", - "extra": { - "rector": { - "includes": [ - "config/config.php" - ] - } - }, "autoload": { "psr-4": { "RectorLaravel\\": "src" @@ -6528,9 +6508,9 @@ "description": "Rector upgrades rules for Laravel Framework", "support": { "issues": "https://github.com/driftingly/rector-laravel/issues", - "source": "https://github.com/driftingly/rector-laravel/tree/2.1.10" + "source": "https://github.com/driftingly/rector-laravel/tree/2.1.12" }, - "time": "2026-03-03T11:27:42+00:00" + "time": "2026-03-06T19:59:21+00:00" }, { "name": "fakerphp/faker", @@ -6971,16 +6951,16 @@ }, { "name": "laravel/boost", - "version": "v2.2.3", + "version": "v2.3.1", "source": { "type": "git", "url": "https://github.com/laravel/boost.git", - "reference": "44ab65a5455c2d6fceb71d6145f8d5d89c02d889" + "reference": "ba0a9e6497398b6ce8243f5517b67d6761509150" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/boost/zipball/44ab65a5455c2d6fceb71d6145f8d5d89c02d889", - "reference": "44ab65a5455c2d6fceb71d6145f8d5d89c02d889", + "url": "https://api.github.com/repos/laravel/boost/zipball/ba0a9e6497398b6ce8243f5517b67d6761509150", + "reference": "ba0a9e6497398b6ce8243f5517b67d6761509150", "shasum": "" }, "require": { @@ -7033,20 +7013,20 @@ "issues": "https://github.com/laravel/boost/issues", "source": "https://github.com/laravel/boost" }, - "time": "2026-03-06T20:20:28+00:00" + "time": "2026-03-12T09:06:47+00:00" }, { "name": "laravel/mcp", - "version": "v0.6.0", + "version": "v0.6.2", "source": { "type": "git", "url": "https://github.com/laravel/mcp.git", - "reference": "28860a10ca0cc5433e25d897ba7af844e6c7b6a2" + "reference": "f696e44735b95ff275392eab8ce5a3b4b42a2223" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/mcp/zipball/28860a10ca0cc5433e25d897ba7af844e6c7b6a2", - "reference": "28860a10ca0cc5433e25d897ba7af844e6c7b6a2", + "url": "https://api.github.com/repos/laravel/mcp/zipball/f696e44735b95ff275392eab8ce5a3b4b42a2223", + "reference": "f696e44735b95ff275392eab8ce5a3b4b42a2223", "shasum": "" }, "require": { @@ -7106,7 +7086,7 @@ "issues": "https://github.com/laravel/mcp/issues", "source": "https://github.com/laravel/mcp" }, - "time": "2026-02-24T08:43:06+00:00" + "time": "2026-03-10T20:00:23+00:00" }, { "name": "laravel/pail", @@ -7190,16 +7170,16 @@ }, { "name": "laravel/pint", - "version": "v1.27.1", + "version": "v1.29.0", "source": { "type": "git", "url": "https://github.com/laravel/pint.git", - "reference": "54cca2de13790570c7b6f0f94f37896bee4abcb5" + "reference": "bdec963f53172c5e36330f3a400604c69bf02d39" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/pint/zipball/54cca2de13790570c7b6f0f94f37896bee4abcb5", - "reference": "54cca2de13790570c7b6f0f94f37896bee4abcb5", + "url": "https://api.github.com/repos/laravel/pint/zipball/bdec963f53172c5e36330f3a400604c69bf02d39", + "reference": "bdec963f53172c5e36330f3a400604c69bf02d39", "shasum": "" }, "require": { @@ -7210,13 +7190,14 @@ "php": "^8.2.0" }, "require-dev": { - "friendsofphp/php-cs-fixer": "^3.93.1", - "illuminate/view": "^12.51.0", - "larastan/larastan": "^3.9.2", + "friendsofphp/php-cs-fixer": "^3.94.2", + "illuminate/view": "^12.54.1", + "larastan/larastan": "^3.9.3", "laravel-zero/framework": "^12.0.5", "mockery/mockery": "^1.6.12", - "nunomaduro/termwind": "^2.3.3", - "pestphp/pest": "^3.8.5" + "nunomaduro/termwind": "^2.4.0", + "pestphp/pest": "^3.8.6", + "shipfastlabs/agent-detector": "^1.1.0" }, "bin": [ "builds/pint" @@ -7253,7 +7234,7 @@ "issues": "https://github.com/laravel/pint/issues", "source": "https://github.com/laravel/pint" }, - "time": "2026-02-10T20:00:20+00:00" + "time": "2026-03-12T15:51:39+00:00" }, { "name": "laravel/roster", @@ -7620,21 +7601,21 @@ }, { "name": "pestphp/pest", - "version": "v4.4.1", + "version": "v4.4.2", "source": { "type": "git", "url": "https://github.com/pestphp/pest.git", - "reference": "f96a1b27864b585b0b29b0ee7331176726f7e54a" + "reference": "5d42e8fe3ae1d9fdf7c9f73ee88138fd30265701" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/pestphp/pest/zipball/f96a1b27864b585b0b29b0ee7331176726f7e54a", - "reference": "f96a1b27864b585b0b29b0ee7331176726f7e54a", + "url": "https://api.github.com/repos/pestphp/pest/zipball/5d42e8fe3ae1d9fdf7c9f73ee88138fd30265701", + "reference": "5d42e8fe3ae1d9fdf7c9f73ee88138fd30265701", "shasum": "" }, "require": { "brianium/paratest": "^7.19.0", - "nunomaduro/collision": "^8.9.0", + "nunomaduro/collision": "^8.9.1", "nunomaduro/termwind": "^2.4.0", "pestphp/pest-plugin": "^4.0.0", "pestphp/pest-plugin-arch": "^4.0.0", @@ -7654,7 +7635,7 @@ "pestphp/pest-dev-tools": "^4.1.0", "pestphp/pest-plugin-browser": "^4.3.0", "pestphp/pest-plugin-type-coverage": "^4.0.3", - "psy/psysh": "^0.12.20" + "psy/psysh": "^0.12.21" }, "bin": [ "bin/pest" @@ -7720,7 +7701,7 @@ ], "support": { "issues": "https://github.com/pestphp/pest/issues", - "source": "https://github.com/pestphp/pest/tree/v4.4.1" + "source": "https://github.com/pestphp/pest/tree/v4.4.2" }, "funding": [ { @@ -7732,7 +7713,7 @@ "type": "github" } ], - "time": "2026-02-17T15:27:18+00:00" + "time": "2026-03-10T21:09:12+00:00" }, { "name": "pestphp/pest-plugin", @@ -9322,16 +9303,16 @@ }, { "name": "sebastian/environment", - "version": "8.0.3", + "version": "8.0.4", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/environment.git", - "reference": "24a711b5c916efc6d6e62aa65aa2ec98fef77f68" + "reference": "7b8842c2d8e85d0c3a5831236bf5869af6ab2a11" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/24a711b5c916efc6d6e62aa65aa2ec98fef77f68", - "reference": "24a711b5c916efc6d6e62aa65aa2ec98fef77f68", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/7b8842c2d8e85d0c3a5831236bf5869af6ab2a11", + "reference": "7b8842c2d8e85d0c3a5831236bf5869af6ab2a11", "shasum": "" }, "require": { @@ -9374,7 +9355,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/environment/issues", "security": "https://github.com/sebastianbergmann/environment/security/policy", - "source": "https://github.com/sebastianbergmann/environment/tree/8.0.3" + "source": "https://github.com/sebastianbergmann/environment/tree/8.0.4" }, "funding": [ { @@ -9394,7 +9375,7 @@ "type": "tidelift" } ], - "time": "2025-08-12T14:11:56+00:00" + "time": "2026-03-15T07:05:40+00:00" }, { "name": "sebastian/exporter", @@ -10058,65 +10039,6 @@ ], "time": "2026-02-09T10:14:57+00:00" }, - { - "name": "symplify/rule-doc-generator-contracts", - "version": "11.2.0", - "source": { - "type": "git", - "url": "https://github.com/symplify/rule-doc-generator-contracts.git", - "reference": "479cfcfd46047f80624aba931d9789e50475b5c6" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symplify/rule-doc-generator-contracts/zipball/479cfcfd46047f80624aba931d9789e50475b5c6", - "reference": "479cfcfd46047f80624aba931d9789e50475b5c6", - "shasum": "" - }, - "require": { - "php": ">=8.1" - }, - "require-dev": { - "php-parallel-lint/php-parallel-lint": "^1.3", - "phpstan/extension-installer": "^1.2", - "rector/rector": "^0.15.10", - "symplify/easy-ci": "^11.1", - "symplify/easy-coding-standard": "^11.1", - "symplify/easy-testing": "^11.1", - "symplify/phpstan-extensions": "^11.1", - "symplify/phpstan-rules": "11.2.3.72", - "tomasvotruba/unused-public": "^0.0.34" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-main": "11.2-dev" - } - }, - "autoload": { - "psr-4": { - "Symplify\\RuleDocGenerator\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "description": "Contracts for production code of RuleDocGenerator", - "support": { - "source": "https://github.com/symplify/rule-doc-generator-contracts/tree/11.2.0" - }, - "funding": [ - { - "url": "https://www.paypal.me/rectorphp", - "type": "custom" - }, - { - "url": "https://github.com/tomasvotruba", - "type": "github" - } - ], - "time": "2024-03-18T22:02:54+00:00" - }, { "name": "ta-tikoma/phpunit-architecture-test", "version": "0.8.7", @@ -10285,23 +10207,23 @@ }, { "name": "webmozart/assert", - "version": "2.1.6", + "version": "1.12.1", "source": { "type": "git", "url": "https://github.com/webmozarts/assert.git", - "reference": "ff31ad6efc62e66e518fbab1cde3453d389bcdc8" + "reference": "9be6926d8b485f55b9229203f962b51ed377ba68" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/webmozarts/assert/zipball/ff31ad6efc62e66e518fbab1cde3453d389bcdc8", - "reference": "ff31ad6efc62e66e518fbab1cde3453d389bcdc8", + "url": "https://api.github.com/repos/webmozarts/assert/zipball/9be6926d8b485f55b9229203f962b51ed377ba68", + "reference": "9be6926d8b485f55b9229203f962b51ed377ba68", "shasum": "" }, "require": { "ext-ctype": "*", "ext-date": "*", "ext-filter": "*", - "php": "^8.2" + "php": "^7.2 || ^8.0" }, "suggest": { "ext-intl": "", @@ -10311,7 +10233,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-feature/2-0": "2.0-dev" + "dev-master": "1.10-dev" } }, "autoload": { @@ -10327,10 +10249,6 @@ { "name": "Bernhard Schussek", "email": "bschussek@gmail.com" - }, - { - "name": "Woody Gilk", - "email": "woody.gilk@gmail.com" } ], "description": "Assertions to validate method input/output with nice error messages.", @@ -10341,9 +10259,9 @@ ], "support": { "issues": "https://github.com/webmozarts/assert/issues", - "source": "https://github.com/webmozarts/assert/tree/2.1.6" + "source": "https://github.com/webmozarts/assert/tree/1.12.1" }, - "time": "2026-02-27T10:28:38+00:00" + "time": "2025-10-29T15:56:20+00:00" } ], "aliases": [], diff --git a/config/auth.php b/config/auth.php index 7d1eb0d..d7568ff 100644 --- a/config/auth.php +++ b/config/auth.php @@ -1,5 +1,7 @@ [ 'users' => [ 'driver' => 'eloquent', - 'model' => env('AUTH_MODEL', App\Models\User::class), + 'model' => env('AUTH_MODEL', User::class), ], // 'users' => [ diff --git a/config/database.php b/config/database.php index 7bf4986..6627e4f 100644 --- a/config/database.php +++ b/config/database.php @@ -1,6 +1,7 @@ true, 'engine' => null, 'options' => extension_loaded('pdo_mysql') ? array_filter([ - (PHP_VERSION_ID >= 80500 ? \Pdo\Mysql::ATTR_SSL_CA : \Pdo\Mysql::ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'), + (PHP_VERSION_ID >= 80500 ? Mysql::ATTR_SSL_CA : Mysql::ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'), ]) : [], ], @@ -79,7 +80,7 @@ 'strict' => true, 'engine' => null, 'options' => extension_loaded('pdo_mysql') ? array_filter([ - (PHP_VERSION_ID >= 80500 ? \Pdo\Mysql::ATTR_SSL_CA : \Pdo\Mysql::ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'), + (PHP_VERSION_ID >= 80500 ? Mysql::ATTR_SSL_CA : Mysql::ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'), ]) : [], ], diff --git a/package-lock.json b/package-lock.json index 4ff5b0e..1305d4c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,9 +14,9 @@ } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", - "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz", + "integrity": "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==", "cpu": [ "ppc64" ], @@ -31,9 +31,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", - "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.4.tgz", + "integrity": "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==", "cpu": [ "arm" ], @@ -48,9 +48,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", - "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz", + "integrity": "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==", "cpu": [ "arm64" ], @@ -65,9 +65,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", - "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.4.tgz", + "integrity": "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==", "cpu": [ "x64" ], @@ -82,9 +82,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", - "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz", + "integrity": "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==", "cpu": [ "arm64" ], @@ -99,9 +99,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", - "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz", + "integrity": "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==", "cpu": [ "x64" ], @@ -116,9 +116,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", - "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.4.tgz", + "integrity": "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==", "cpu": [ "arm64" ], @@ -133,9 +133,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", - "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz", + "integrity": "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==", "cpu": [ "x64" ], @@ -150,9 +150,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", - "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.4.tgz", + "integrity": "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==", "cpu": [ "arm" ], @@ -167,9 +167,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", - "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz", + "integrity": "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==", "cpu": [ "arm64" ], @@ -184,9 +184,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", - "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz", + "integrity": "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==", "cpu": [ "ia32" ], @@ -201,9 +201,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", - "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz", + "integrity": "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==", "cpu": [ "loong64" ], @@ -218,9 +218,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", - "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.4.tgz", + "integrity": "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==", "cpu": [ "mips64el" ], @@ -235,9 +235,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", - "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz", + "integrity": "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==", "cpu": [ "ppc64" ], @@ -252,9 +252,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", - "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz", + "integrity": "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==", "cpu": [ "riscv64" ], @@ -269,9 +269,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", - "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.4.tgz", + "integrity": "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==", "cpu": [ "s390x" ], @@ -286,9 +286,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", - "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz", + "integrity": "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==", "cpu": [ "x64" ], @@ -303,9 +303,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", - "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.4.tgz", + "integrity": "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==", "cpu": [ "arm64" ], @@ -320,9 +320,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", - "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz", + "integrity": "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==", "cpu": [ "x64" ], @@ -337,9 +337,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", - "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.4.tgz", + "integrity": "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==", "cpu": [ "arm64" ], @@ -354,9 +354,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", - "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz", + "integrity": "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==", "cpu": [ "x64" ], @@ -371,9 +371,9 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", - "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz", + "integrity": "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==", "cpu": [ "arm64" ], @@ -388,9 +388,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", - "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz", + "integrity": "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==", "cpu": [ "x64" ], @@ -405,9 +405,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", - "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz", + "integrity": "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==", "cpu": [ "arm64" ], @@ -422,9 +422,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", - "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz", + "integrity": "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==", "cpu": [ "ia32" ], @@ -439,9 +439,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", - "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz", + "integrity": "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==", "cpu": [ "x64" ], @@ -1402,9 +1402,9 @@ } }, "node_modules/esbuild": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", - "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz", + "integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -1415,32 +1415,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.3", - "@esbuild/android-arm": "0.27.3", - "@esbuild/android-arm64": "0.27.3", - "@esbuild/android-x64": "0.27.3", - "@esbuild/darwin-arm64": "0.27.3", - "@esbuild/darwin-x64": "0.27.3", - "@esbuild/freebsd-arm64": "0.27.3", - "@esbuild/freebsd-x64": "0.27.3", - "@esbuild/linux-arm": "0.27.3", - "@esbuild/linux-arm64": "0.27.3", - "@esbuild/linux-ia32": "0.27.3", - "@esbuild/linux-loong64": "0.27.3", - "@esbuild/linux-mips64el": "0.27.3", - "@esbuild/linux-ppc64": "0.27.3", - "@esbuild/linux-riscv64": "0.27.3", - "@esbuild/linux-s390x": "0.27.3", - "@esbuild/linux-x64": "0.27.3", - "@esbuild/netbsd-arm64": "0.27.3", - "@esbuild/netbsd-x64": "0.27.3", - "@esbuild/openbsd-arm64": "0.27.3", - "@esbuild/openbsd-x64": "0.27.3", - "@esbuild/openharmony-arm64": "0.27.3", - "@esbuild/sunos-x64": "0.27.3", - "@esbuild/win32-arm64": "0.27.3", - "@esbuild/win32-ia32": "0.27.3", - "@esbuild/win32-x64": "0.27.3" + "@esbuild/aix-ppc64": "0.27.4", + "@esbuild/android-arm": "0.27.4", + "@esbuild/android-arm64": "0.27.4", + "@esbuild/android-x64": "0.27.4", + "@esbuild/darwin-arm64": "0.27.4", + "@esbuild/darwin-x64": "0.27.4", + "@esbuild/freebsd-arm64": "0.27.4", + "@esbuild/freebsd-x64": "0.27.4", + "@esbuild/linux-arm": "0.27.4", + "@esbuild/linux-arm64": "0.27.4", + "@esbuild/linux-ia32": "0.27.4", + "@esbuild/linux-loong64": "0.27.4", + "@esbuild/linux-mips64el": "0.27.4", + "@esbuild/linux-ppc64": "0.27.4", + "@esbuild/linux-riscv64": "0.27.4", + "@esbuild/linux-s390x": "0.27.4", + "@esbuild/linux-x64": "0.27.4", + "@esbuild/netbsd-arm64": "0.27.4", + "@esbuild/netbsd-x64": "0.27.4", + "@esbuild/openbsd-arm64": "0.27.4", + "@esbuild/openbsd-x64": "0.27.4", + "@esbuild/openharmony-arm64": "0.27.4", + "@esbuild/sunos-x64": "0.27.4", + "@esbuild/win32-arm64": "0.27.4", + "@esbuild/win32-ia32": "0.27.4", + "@esbuild/win32-x64": "0.27.4" } }, "node_modules/escalade": { diff --git a/tests/Feature/GameEventTest.php b/tests/Feature/GameEventTest.php index 35125f5..2625b8e 100644 --- a/tests/Feature/GameEventTest.php +++ b/tests/Feature/GameEventTest.php @@ -115,7 +115,7 @@ function winSet(Game $game, TeamAB $winner): void $event = $game->events->first(); $event->payload = new TossCompletedPayload(teamA: TeamSide::Away, serving: TeamAB::TeamB); - expect(fn () => $event->save())->toThrow(\LogicException::class); + expect(fn () => $event->save())->toThrow(LogicException::class); }); test('the type attribute is cast to a GameEventType enum', function (): void { @@ -159,7 +159,7 @@ function winSet(Game $game, TeamAB $winner): void $positions = $players->mapWithKeys(fn (Player $player, int $index) => [$index + 1 => $player->getKey()])->all(); expect(fn () => $game->recordLineup(1, TeamAB::TeamA, $positions)) - ->toThrow(\InvalidArgumentException::class, 'A lineup must have exactly 6 positions.'); + ->toThrow(InvalidArgumentException::class, 'A lineup must have exactly 6 positions.'); }); test('a lineup with 0-based keys throws an InvalidArgumentException', function (): void { @@ -178,7 +178,7 @@ function winSet(Game $game, TeamAB $winner): void $positions = $players->mapWithKeys(fn (Player $player, int $index) => [$index => $player->getKey()])->all(); expect(fn () => $game->recordLineup(1, TeamAB::TeamA, $positions)) - ->toThrow(\InvalidArgumentException::class, 'Lineup positions must be keyed 1 through 6.'); + ->toThrow(InvalidArgumentException::class, 'Lineup positions must be keyed 1 through 6.'); }); test('a lineup with a duplicate player ID throws an InvalidArgumentException', function (): void { @@ -198,7 +198,7 @@ function winSet(Game $game, TeamAB $winner): void $positions = [1 => $firstPlayerId, 2 => $firstPlayerId, 3 => $players[2]->getKey(), 4 => $players[3]->getKey(), 5 => $players[4]->getKey(), 6 => $players[5]->getKey()]; expect(fn () => $game->recordLineup(1, TeamAB::TeamA, $positions)) - ->toThrow(\InvalidArgumentException::class, 'All 6 lineup positions must have different players.'); + ->toThrow(InvalidArgumentException::class, 'All 6 lineup positions must have different players.'); }); test('a lineup submitted before the toss is rejected', function (): void { @@ -258,7 +258,7 @@ function winSet(Game $game, TeamAB $winner): void $positions = $awayPlayers->mapWithKeys(fn (Player $player, int $index) => [$index + 1 => $player->getKey()])->all(); expect(fn () => $game->recordLineup(1, TeamAB::TeamA, $positions)) - ->toThrow(\InvalidArgumentException::class, 'is not on the roster for the specified team.'); + ->toThrow(InvalidArgumentException::class, 'is not on the roster for the specified team.'); }); test('a substitution can be recorded with the correct type and payload', function (): void { diff --git a/tests/Feature/LineupSubmissionTest.php b/tests/Feature/LineupSubmissionTest.php index 3565a53..2d2d9cc 100644 --- a/tests/Feature/LineupSubmissionTest.php +++ b/tests/Feature/LineupSubmissionTest.php @@ -5,6 +5,7 @@ use App\Enums\TeamAB; use App\Livewire\LineupSubmission; use Illuminate\View\ViewException; +use Livewire\Features\SupportTesting\Testable; use Livewire\Livewire; test('lineup submission renders team a button and modal', function (): void { @@ -28,7 +29,7 @@ }); test('lineup submission rejects unsupported team value', function (): void { - expect(fn (): \Livewire\Features\SupportTesting\Testable => Livewire::test(LineupSubmission::class, ['team' => 'invalid'])) + expect(fn (): Testable => Livewire::test(LineupSubmission::class, ['team' => 'invalid'])) ->toThrow(ViewException::class, 'Unsupported team value for lineup submission.'); }); From 3677f53fe64916b8900e6befb2953203cae6644f Mon Sep 17 00:00:00 2001 From: Giulio Troccoli-Allard Date: Sun, 15 Mar 2026 18:04:17 +0000 Subject: [PATCH 06/63] feat: add roster on either side of the court --- app/Enums/TeamAB.php | 7 + app/Livewire/Court.php | 90 +++++++++- app/Livewire/LineupSubmission.php | 23 +-- app/Livewire/TeamRoster.php | 103 +++++++++++ app/Livewire/TossResultSubmission.php | 18 +- app/Services/CacheRepository.php | 74 ++++++++ database/factories/PlayerFactory.php | 8 + resources/views/livewire/court.blade.php | 50 ++++-- resources/views/livewire/game.blade.php | 4 - .../livewire/lineup-submission.blade.php | 6 +- .../views/livewire/team-roster.blade.php | 19 ++ .../livewire/toss-result-submission.blade.php | 104 +++++------ tests/Feature/CacheRepositoryTest.php | 115 ++++++++++++ tests/Feature/CourtTest.php | 163 ++++++++++++++++++ tests/Feature/ExampleTest.php | 3 +- tests/Feature/LineupSubmissionTest.php | 31 ++-- tests/Feature/TeamRosterTest.php | 95 ++++++++++ tests/Feature/TossResultSubmissionTest.php | 9 + 18 files changed, 824 insertions(+), 98 deletions(-) create mode 100644 app/Livewire/TeamRoster.php create mode 100644 app/Services/CacheRepository.php create mode 100644 resources/views/livewire/team-roster.blade.php create mode 100644 tests/Feature/CacheRepositoryTest.php create mode 100644 tests/Feature/CourtTest.php create mode 100644 tests/Feature/TeamRosterTest.php diff --git a/app/Enums/TeamAB.php b/app/Enums/TeamAB.php index 2f0b367..abfbd02 100644 --- a/app/Enums/TeamAB.php +++ b/app/Enums/TeamAB.php @@ -8,4 +8,11 @@ enum TeamAB: string { case TeamA = 'team_a'; case TeamB = 'team_b'; + + public function label(): string + { + return $this === self::TeamA + ? 'Team A' + : 'Team B'; + } } diff --git a/app/Livewire/Court.php b/app/Livewire/Court.php index 9c3e1a0..209cd06 100644 --- a/app/Livewire/Court.php +++ b/app/Livewire/Court.php @@ -4,6 +4,12 @@ namespace App\Livewire; +use App\Enums\GameEventType; +use App\Enums\TeamAB; +use App\Enums\TeamSide; +use App\Events\Payloads\TossCompletedPayload; +use App\Models\Game; +use App\Models\GameEvent; use Illuminate\Contracts\View\View; use Livewire\Attributes\Reactive; use Livewire\Component; @@ -28,6 +34,88 @@ public function mount(?int $gameId = null, array $gameState = []): void public function render(): View { - return view('livewire.court'); + return view('livewire.court', $this->courtContext()); + } + + /** + * @return array{ + * leftTeam: TeamAB, + * rightTeam: TeamAB + * } + */ + private function courtContext(): array + { + $defaultContext = [ + 'leftTeam' => TeamAB::TeamA, + 'rightTeam' => TeamAB::TeamB, + ]; + + if ($this->gameId === null) { + return $defaultContext; + } + + $game = Game::query()->find($this->gameId); + + if ($game === null) { + return $defaultContext; + } + + if ($this->isTeamAOnLeft($game)) { + return $defaultContext; + } + + return [ + 'leftTeam' => TeamAB::TeamB, + 'rightTeam' => TeamAB::TeamA, + ]; + } + + private function isTeamAOnLeft(Game $game): bool + { + $baseTeamAOnLeft = $this->teamASideForTossSets($game) === TeamSide::Home; + $setNumber = $this->setNumber(); + + if ($setNumber >= 2 && $setNumber <= 4) { + return $setNumber % 2 === 1 + ? $baseTeamAOnLeft + : ! $baseTeamAOnLeft; + } + + return $baseTeamAOnLeft; + } + + private function teamASideForTossSets(Game $game): TeamSide + { + $tossPayload = $this->latestTossPayload($game); + + if ($tossPayload === null) { + return TeamSide::Home; + } + + return $tossPayload->teamA; + } + + private function setNumber(): int + { + $setNumber = $this->gameState['set_number'] ?? 0; + + return is_int($setNumber) ? $setNumber : 0; + } + + private function latestTossPayload(Game $game): ?TossCompletedPayload + { + /** @var GameEvent|null $tossEvent */ + $tossEvent = $game->events() + ->where('type', GameEventType::TossCompleted) + ->orderByDesc('created_at') + ->orderByDesc('id') + ->first(); + + if ($tossEvent === null) { + return null; + } + + /** @var TossCompletedPayload */ + return $tossEvent->payload; } } diff --git a/app/Livewire/LineupSubmission.php b/app/Livewire/LineupSubmission.php index 4dfb63c..858b4a9 100644 --- a/app/Livewire/LineupSubmission.php +++ b/app/Livewire/LineupSubmission.php @@ -13,11 +13,11 @@ class LineupSubmission extends Component { - public string $team = TeamAB::TeamA->value; - #[Reactive] #[Locked] - public ?int $gameId = null; + public int $gameId; + + public TeamAB $team = TeamAB::TeamA; /** @var array */ #[Reactive] @@ -29,11 +29,9 @@ class LineupSubmission extends Component /** * @param array $gameState */ - public function mount(string $team, ?int $gameId = null, array $gameState = []): void + public function mount(TeamAB $team, ?int $gameId = null, array $gameState = []): void { - if (! in_array($team, array_column(TeamAB::cases(), 'value'), true)) { - throw new \InvalidArgumentException('Unsupported team value for lineup submission.'); - } + abort_if(is_null($gameId), 404); $this->team = $team; $this->gameId = $gameId; @@ -50,19 +48,12 @@ public function mount(string $team, ?int $gameId = null, array $gameState = []): public function modalName(): string { - return 'submit-lineup-'.$this->team; - } - - public function buttonLabel(): string - { - return $this->team === TeamAB::TeamA->value - ? 'Submit Team A Lineup' - : 'Submit Team B Lineup'; + return 'submit-lineup-'.$this->team->value; } public function modalHeading(): string { - return $this->team === TeamAB::TeamA->value + return $this->team === TeamAB::TeamA ? 'Team A Lineup' : 'Team B Lineup'; } diff --git a/app/Livewire/TeamRoster.php b/app/Livewire/TeamRoster.php new file mode 100644 index 0000000..81c98dd --- /dev/null +++ b/app/Livewire/TeamRoster.php @@ -0,0 +1,103 @@ +cacheRepository = $cacheRepository; + $this->gameId = $gameId; + $this->team = $team; + $this->leftSide = $leftSide; + } + + public function render(): View + { + return view('livewire.team-roster', [ + 'teamLabel' => $this->team->label(), + 'players' => $this->players(), + 'numberFirst' => ! $this->leftSide, + 'alignRight' => $this->leftSide, + 'keyPrefix' => $this->leftSide ? 'left-player' : 'right-player', + ]); + } + + /** + * @return array + */ + private function players(): array + { + $game = Game::query()->find($this->gameId); + + if ($game === null) { + return []; + } + + return $this->playersForTeam($game, $this->team); + } + + /** + * @return array + */ + private function playersForTeam(Game $game, TeamAB $team): array + { + $teamASide = $this->teamASideForTossSets($game); + $targetSide = $team === TeamAB::TeamA + ? $teamASide + : $this->oppositeSide($teamASide); + + return $this->cacheRepository->playersForSide($game, $targetSide); + } + + private function teamASideForTossSets(Game $game): TeamSide + { + $tossPayload = $this->cacheRepository->latestTossPayload($game); + + if ($tossPayload === null) { + return TeamSide::Home; + } + + return $tossPayload->teamA; + } + + private function oppositeSide(TeamSide $side): TeamSide + { + return $side === TeamSide::Home + ? TeamSide::Away + : TeamSide::Home; + } +} diff --git a/app/Livewire/TossResultSubmission.php b/app/Livewire/TossResultSubmission.php index 9a9d590..5c1bac0 100644 --- a/app/Livewire/TossResultSubmission.php +++ b/app/Livewire/TossResultSubmission.php @@ -4,12 +4,14 @@ namespace App\Livewire; +use App\Enums\GameEventType; use App\Enums\TeamAB; use App\Enums\TeamSide; use App\Exceptions\InvalidGameEventTransition; use App\Models\Game; use Flux\Flux; use Illuminate\Contracts\View\View; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Validation\Rule; use Livewire\Attributes\Locked; use Livewire\Attributes\Reactive; @@ -99,6 +101,20 @@ public function submit(): void public function render(): View { - return view('livewire.toss-result-submission'); + return view('livewire.toss-result-submission', [ + 'hasSubmittedToss' => $this->hasSubmittedToss(), + ]); + } + + private function hasSubmittedToss(): bool + { + if ($this->gameId === null) { + return false; + } + + return Game::query() + ->whereKey($this->gameId) + ->whereHas('events', fn (Builder $query) => $query->where('type', GameEventType::TossCompleted)) + ->exists(); } } diff --git a/app/Services/CacheRepository.php b/app/Services/CacheRepository.php new file mode 100644 index 0000000..19411bc --- /dev/null +++ b/app/Services/CacheRepository.php @@ -0,0 +1,74 @@ + + */ + public function playersForSide(Game $game, TeamSide $side): array + { + return Cache::remember( + sprintf('game:%d:roster:players-for-side:%s', $game->getKey(), $side->value), + now()->addSeconds(self::PLAYERS_FOR_SIDE_CACHE_SECONDS), + function () use ($game, $side): array { + $players = $side === TeamSide::Home + ? $game->homePlayers() + : $game->awayPlayers(); + + return $players + ->wherePivot('is_libero', false) + ->orderByPivot('number') + ->get() + ->map(fn (Player $player): array => [ + 'player_key' => $player->getKey(), + 'number' => $player->roster->number, + 'last_name' => $player->last_name, + ]) + ->all(); + } + ); + } + + public function latestTossPayload(Game $game): ?TossCompletedPayload + { + return Cache::remember( + sprintf('game:%d:events:latest-toss-payload', $game->getKey()), + now()->addSeconds(self::LATEST_TOSS_PAYLOAD_CACHE_SECONDS), + function () use ($game): ?TossCompletedPayload { + /** @var GameEvent|null $tossEvent */ + $tossEvent = $game->events() + ->where('type', GameEventType::TossCompleted) + ->orderByDesc('created_at') + ->orderByDesc('id') + ->first(); + + if ($tossEvent === null) { + return null; + } + + /** @var TossCompletedPayload */ + return $tossEvent->payload; + } + ); + } +} diff --git a/database/factories/PlayerFactory.php b/database/factories/PlayerFactory.php index 055c7d0..7ff7ee0 100644 --- a/database/factories/PlayerFactory.php +++ b/database/factories/PlayerFactory.php @@ -44,4 +44,12 @@ public function forCountry(string $code): static { return $this->withLocale($this->getLocaleForCountry($code)); } + + public function named(string $firstName, string $lastName): static + { + return $this->state(fn (array $attributes) => [ + 'first_name' => $firstName, + 'last_name' => $lastName, + ]); + } } diff --git a/resources/views/livewire/court.blade.php b/resources/views/livewire/court.blade.php index 065938e..b523536 100644 --- a/resources/views/livewire/court.blade.php +++ b/resources/views/livewire/court.blade.php @@ -1,10 +1,40 @@ -
-
-
-
-
-
+
+
+ + +
+
+
+
+
+
+ + +
+
+ + +
+
diff --git a/resources/views/livewire/game.blade.php b/resources/views/livewire/game.blade.php index 3066fc4..db86d20 100644 --- a/resources/views/livewire/game.blade.php +++ b/resources/views/livewire/game.blade.php @@ -2,10 +2,6 @@
-
- - -
diff --git a/resources/views/livewire/lineup-submission.blade.php b/resources/views/livewire/lineup-submission.blade.php index fae8340..a00a460 100644 --- a/resources/views/livewire/lineup-submission.blade.php +++ b/resources/views/livewire/lineup-submission.blade.php @@ -1,8 +1,6 @@
- - {{ $this->buttonLabel() }} - + Submit Lineup @@ -15,7 +13,7 @@ label="{{ $position }}" label:class="mb-0!" field:class="flex flex-col items-center" - wire:key="{{ $this->team }}-position-{{ $position }}" + wire:key="{{ $this->team->value }}-position-{{ $position }}" name="lineup[{{ $position }}]" wire:model="lineup.{{ $position }}" class="h-12! w-12!" diff --git a/resources/views/livewire/team-roster.blade.php b/resources/views/livewire/team-roster.blade.php new file mode 100644 index 0000000..3c383b9 --- /dev/null +++ b/resources/views/livewire/team-roster.blade.php @@ -0,0 +1,19 @@ + diff --git a/resources/views/livewire/toss-result-submission.blade.php b/resources/views/livewire/toss-result-submission.blade.php index 19980b0..39daa00 100644 --- a/resources/views/livewire/toss-result-submission.blade.php +++ b/resources/views/livewire/toss-result-submission.blade.php @@ -1,56 +1,60 @@ -
- - - Submit Toss Result - - +
+ @unless ($hasSubmittedToss) +
+ + + Submit Toss Result + + - -
-
- Submit Toss Result - - Select which side is Team A and which team serves first. - -
+ + +
+ Submit Toss Result + + Select which side is Team A and which team serves first. + +
- - - - - @error('teamA') - {{ $message }} - @enderror + + + + + @error('teamA') + {{ $message }} + @enderror - - - - - @error('serving') - {{ $message }} - @enderror + + + + + @error('serving') + {{ $message }} + @enderror - @error('submit') - {{ $message }} - @enderror + @error('submit') + {{ $message }} + @enderror -
- - - Cancel - - Save Toss Result -
- -
+
+ + + Cancel + + Save Toss Result +
+ +
+
+ @endunless
diff --git a/tests/Feature/CacheRepositoryTest.php b/tests/Feature/CacheRepositoryTest.php new file mode 100644 index 0000000..b008040 --- /dev/null +++ b/tests/Feature/CacheRepositoryTest.php @@ -0,0 +1,115 @@ +playersForSide($game, TeamSide::Home); + $awayPlayers = $repository->playersForSide($game, TeamSide::Away); + + expect(collect($homePlayers)->pluck('number')->all())->toBe([3, 12]) + ->and(collect($homePlayers)->pluck('last_name')->all())->toBe(['Anderson', 'Zephyr']) + ->and(collect($awayPlayers)->pluck('number')->all())->toBe([2, 9]) + ->and(collect($awayPlayers)->pluck('last_name')->all())->toBe(['Baker', 'Young']) + ->and(Cache::has(sprintf('game:%d:roster:players-for-side:%s', $game->getKey(), TeamSide::Home->value)))->toBeTrue() + ->and(Cache::has(sprintf('game:%d:roster:players-for-side:%s', $game->getKey(), TeamSide::Away->value)))->toBeTrue(); +}); + +test('players for side reuses cached value for repeated reads', function (): void { + Cache::flush(); + + $game = gameWithNumberedRostersForCacheRepository(); + $repository = app(CacheRepository::class); + + $cachedPlayers = $repository->playersForSide($game, TeamSide::Home); + + $newHomePlayer = Player::factory()->for($game->homeTeam)->named('Nina', 'Newest')->create(); + $game->addPlayer($newHomePlayer, number: 8); + + $playersAfterRosterChange = $repository->playersForSide($game, TeamSide::Home); + + expect($playersAfterRosterChange)->toBe($cachedPlayers) + ->and(collect($playersAfterRosterChange)->pluck('player_key')->all())->not->toContain($newHomePlayer->getKey()); +}); + +test('latest toss payload is cached with a game specific key', function (): void { + Cache::flush(); + + $game = gameWithNumberedRostersForCacheRepository(); + $game->recordToss(TeamSide::Away, TeamAB::TeamB); + + $repository = app(CacheRepository::class); + $payload = $repository->latestTossPayload($game); + + expect($payload)->not->toBeNull() + ->and($payload?->teamA)->toBe(TeamSide::Away) + ->and($payload?->serving)->toBe(TeamAB::TeamB) + ->and(Cache::has(sprintf('game:%d:events:latest-toss-payload', $game->getKey())))->toBeTrue(); +}); + +test('latest toss payload reuses cached value for repeated reads', function (): void { + Cache::flush(); + + $game = gameWithNumberedRostersForCacheRepository(); + $game->recordToss(TeamSide::Home, TeamAB::TeamA); + + $repository = app(CacheRepository::class); + + $cachedPayload = $repository->latestTossPayload($game); + $game->events()->create([ + 'type' => GameEventType::TossCompleted, + 'payload' => new TossCompletedPayload( + teamA: TeamSide::Away, + serving: TeamAB::TeamB, + ), + ]); + + $payloadAfterRecordingToss = $repository->latestTossPayload($game); + + expect($cachedPayload)->not->toBeNull() + ->and($cachedPayload?->teamA)->toBe(TeamSide::Home) + ->and($cachedPayload?->serving)->toBe(TeamAB::TeamA) + ->and($payloadAfterRecordingToss?->teamA)->toBe(TeamSide::Home) + ->and($payloadAfterRecordingToss?->serving)->toBe(TeamAB::TeamA); +}); + +function gameWithNumberedRostersForCacheRepository(): Game +{ + $homeTeam = Team::factory()->create(); + $awayTeam = Team::factory()->create(); + $game = Game::factory()->betweenTeams($homeTeam, $awayTeam)->create(); + + $homePlayerOne = Player::factory()->for($homeTeam)->named('Anna', 'Zephyr')->create(); + $homePlayerTwo = Player::factory()->for($homeTeam)->named('Beth', 'Anderson')->create(); + $homeLibero = Player::factory()->for($homeTeam)->named('Cara', 'Libero')->create(); + + $awayPlayerOne = Player::factory()->for($awayTeam)->named('Dora', 'Young')->create(); + $awayPlayerTwo = Player::factory()->for($awayTeam)->named('Etta', 'Baker')->create(); + $awayLibero = Player::factory()->for($awayTeam)->named('Faye', 'Keeper')->create(); + + $game->addPlayer($homePlayerOne, number: 12); + $game->addPlayer($homePlayerTwo, number: 3); + $game->addPlayer($homeLibero, number: 1, isLibero: true); + $game->addPlayer($awayPlayerOne, number: 9); + $game->addPlayer($awayPlayerTwo, number: 2); + $game->addPlayer($awayLibero, number: 20, isLibero: true); + + return $game; +} diff --git a/tests/Feature/CourtTest.php b/tests/Feature/CourtTest.php new file mode 100644 index 0000000..b38806b --- /dev/null +++ b/tests/Feature/CourtTest.php @@ -0,0 +1,163 @@ + $game->getKey(), 'gameState' => ['set_number' => 0]]) + ->assertSeeInOrder([ + 'Team A', + 'Anderson 3', + 'Zephyr 12', + 'Team B', + '2 Baker', + '9 Young', + ]) + ->assertDontSee('1 Libero') + ->assertDontSee('20 Keeper') + ->assertDontSee('Anna') + ->assertDontSee('Beth') + ->assertDontSee('Dora') + ->assertDontSee('Etta'); +}); + +test('court swaps team sides in sets two three and four', function (): void { + $game = gameWithNumberedRosters(); + $game->recordToss(TeamSide::Home, TeamAB::TeamA); + + Livewire::test(Court::class, ['gameId' => $game->getKey(), 'gameState' => ['set_number' => 2]]) + ->assertSeeInOrder([ + 'Team B', + 'Baker 2', + 'Young 9', + 'Team A', + '3 Anderson', + '12 Zephyr', + ]); + + Livewire::test(Court::class, ['gameId' => $game->getKey(), 'gameState' => ['set_number' => 3]]) + ->assertSeeInOrder([ + 'Team A', + 'Anderson 3', + 'Zephyr 12', + 'Team B', + '2 Baker', + '9 Young', + ]); + + Livewire::test(Court::class, ['gameId' => $game->getKey(), 'gameState' => ['set_number' => 4]]) + ->assertSeeInOrder([ + 'Team B', + 'Baker 2', + 'Young 9', + 'Team A', + '3 Anderson', + '12 Zephyr', + ]); +}); + +test('court alternates left and right rosters from set one to set four', function (): void { + $game = gameWithNumberedRosters(); + $game->recordToss(TeamSide::Home, TeamAB::TeamA); + + $setExpectations = [ + 1 => [ + 'Team A', + 'Anderson 3', + 'Zephyr 12', + 'Team B', + '2 Baker', + '9 Young', + ], + 2 => [ + 'Team B', + 'Baker 2', + 'Young 9', + 'Team A', + '3 Anderson', + '12 Zephyr', + ], + 3 => [ + 'Team A', + 'Anderson 3', + 'Zephyr 12', + 'Team B', + '2 Baker', + '9 Young', + ], + 4 => [ + 'Team B', + 'Baker 2', + 'Young 9', + 'Team A', + '3 Anderson', + '12 Zephyr', + ], + ]; + + foreach ($setExpectations as $setNumber => $expectedVisibleText) { + Livewire::test(Court::class, ['gameId' => $game->getKey(), 'gameState' => ['set_number' => $setNumber]]) + ->assertSeeInOrder($expectedVisibleText); + } +}); + +test('court uses toss assignment for first and fifth set side labels', function (): void { + $game = gameWithNumberedRosters(); + $game->recordToss(TeamSide::Away, TeamAB::TeamA); + + Livewire::test(Court::class, ['gameId' => $game->getKey(), 'gameState' => ['set_number' => 1]]) + ->assertSeeInOrder([ + 'Team B', + 'Anderson 3', + 'Zephyr 12', + 'Team A', + '2 Baker', + '9 Young', + ]); + + Livewire::test(Court::class, ['gameId' => $game->getKey(), 'gameState' => ['set_number' => 5]]) + ->assertSeeInOrder([ + 'Team B', + 'Anderson 3', + 'Zephyr 12', + 'Team A', + '2 Baker', + '9 Young', + ]); +}); + +function gameWithNumberedRosters(): Game +{ + $homeTeam = Team::factory()->create(); + $awayTeam = Team::factory()->create(); + $game = Game::factory()->betweenTeams($homeTeam, $awayTeam)->create(); + + $homePlayerOne = Player::factory()->for($homeTeam)->named('Anna', 'Zephyr')->create(); + $homePlayerTwo = Player::factory()->for($homeTeam)->named('Beth', 'Anderson')->create(); + $homeLibero = Player::factory()->for($homeTeam)->named('Cara', 'Libero')->create(); + + $awayPlayerOne = Player::factory()->for($awayTeam)->named('Dora', 'Young')->create(); + $awayPlayerTwo = Player::factory()->for($awayTeam)->named('Etta', 'Baker')->create(); + $awayLibero = Player::factory()->for($awayTeam)->named('Faye', 'Keeper')->create(); + + $game->addPlayer($homePlayerOne, number: 12); + $game->addPlayer($homePlayerTwo, number: 3); + $game->addPlayer($homeLibero, number: 1, isLibero: true); + $game->addPlayer($awayPlayerOne, number: 9); + $game->addPlayer($awayPlayerTwo, number: 2); + $game->addPlayer($awayLibero, number: 20, isLibero: true); + + return $game; +} diff --git a/tests/Feature/ExampleTest.php b/tests/Feature/ExampleTest.php index 977c702..db5428c 100644 --- a/tests/Feature/ExampleTest.php +++ b/tests/Feature/ExampleTest.php @@ -19,8 +19,7 @@ ->assertSee('bg-sky-100') ->assertSee('id="volleyball-court"', false) ->assertSee('Submit Toss Result') - ->assertSee('Submit Team A Lineup') - ->assertSee('Submit Team B Lineup'); + ->assertSee('Submit Lineup'); }); test('the game livewire component renders the court component', function (): void { diff --git a/tests/Feature/LineupSubmissionTest.php b/tests/Feature/LineupSubmissionTest.php index 2d2d9cc..a63715d 100644 --- a/tests/Feature/LineupSubmissionTest.php +++ b/tests/Feature/LineupSubmissionTest.php @@ -4,13 +4,16 @@ use App\Enums\TeamAB; use App\Livewire\LineupSubmission; +use App\Models\Game; use Illuminate\View\ViewException; use Livewire\Features\SupportTesting\Testable; use Livewire\Livewire; test('lineup submission renders team a button and modal', function (): void { - Livewire::test(LineupSubmission::class, ['team' => TeamAB::TeamA->value]) - ->assertSee('Submit Team A Lineup') + $game = Game::factory()->create(); + + Livewire::test(LineupSubmission::class, ['team' => TeamAB::TeamA, 'gameId' => $game->getKey()]) + ->assertSee('Submit Lineup') ->assertSee('Team A Lineup') ->assertSeeHtml('submit-lineup-team_a') ->assertSeeHtml('name="lineup[1]"') @@ -19,8 +22,10 @@ }); test('lineup submission renders team b button and modal', function (): void { - Livewire::test(LineupSubmission::class, ['team' => TeamAB::TeamB->value]) - ->assertSee('Submit Team B Lineup') + $game = Game::factory()->create(); + + Livewire::test(LineupSubmission::class, ['team' => TeamAB::TeamB, 'gameId' => $game->getKey()]) + ->assertSee('Submit Lineup') ->assertSee('Team B Lineup') ->assertSeeHtml('submit-lineup-team_b') ->assertSeeHtml('name="lineup[1]"') @@ -29,12 +34,16 @@ }); test('lineup submission rejects unsupported team value', function (): void { - expect(fn (): Testable => Livewire::test(LineupSubmission::class, ['team' => 'invalid'])) - ->toThrow(ViewException::class, 'Unsupported team value for lineup submission.'); + $game = Game::factory()->create(); + + expect(fn (): Testable => Livewire::test(LineupSubmission::class, ['team' => 'invalid', 'gameId' => $game->getKey()])) + ->toThrow(ViewException::class); }); test('lineup submission accepts submit action', function (): void { - Livewire::test(LineupSubmission::class, ['team' => TeamAB::TeamA->value]) + $game = Game::factory()->create(); + + Livewire::test(LineupSubmission::class, ['team' => TeamAB::TeamA, 'gameId' => $game->getKey()]) ->set('lineup.1', '1') ->set('lineup.2', '2') ->set('lineup.3', '3') @@ -46,15 +55,17 @@ }); test('lineup submission is aware of the injected game context', function (): void { + $game = Game::factory()->create(); + Livewire::test(LineupSubmission::class, [ - 'team' => TeamAB::TeamA->value, - 'gameId' => 42, + 'team' => TeamAB::TeamA, + 'gameId' => $game->getKey(), 'gameState' => [ 'set_number' => 2, 'serving_team' => TeamAB::TeamB->value, ], ]) - ->assertSet('gameId', 42) + ->assertSet('gameId', $game->getKey()) ->assertSet('gameState.set_number', 2) ->assertSet('gameState.serving_team', TeamAB::TeamB->value); }); diff --git a/tests/Feature/TeamRosterTest.php b/tests/Feature/TeamRosterTest.php new file mode 100644 index 0000000..fee60d8 --- /dev/null +++ b/tests/Feature/TeamRosterTest.php @@ -0,0 +1,95 @@ +create(); + $awayTeam = Team::factory()->create(); + $game = Game::factory()->betweenTeams($homeTeam, $awayTeam)->create(); + + Livewire::test(TeamRoster::class, [ + 'gameId' => $game->getKey(), + 'team' => TeamAB::TeamA, + 'leftSide' => true, + ]) + ->assertSee('Team A') + ->assertSee('No players available.'); +}); + +test('team roster renders team a on the left with last name before number', function (): void { + $game = gameWithNumberedRostersForTeamRoster(); + + Livewire::test(TeamRoster::class, [ + 'gameId' => $game->getKey(), + 'team' => TeamAB::TeamA, + 'leftSide' => true, + ])->assertSeeInOrder([ + 'Team A', + 'Anderson 3', + 'Zephyr 12', + ]); +}); + +test('team roster renders team b on the right with number before last name', function (): void { + $game = gameWithNumberedRostersForTeamRoster(); + + Livewire::test(TeamRoster::class, [ + 'gameId' => $game->getKey(), + 'team' => TeamAB::TeamB, + 'leftSide' => false, + ])->assertSeeInOrder([ + 'Team B', + '2 Baker', + '9 Young', + ]); +}); + +test('team roster resolves team a players from toss assignment', function (): void { + $game = gameWithNumberedRostersForTeamRoster(); + $game->recordToss(TeamSide::Away, TeamAB::TeamA); + + Livewire::test(TeamRoster::class, [ + 'gameId' => $game->getKey(), + 'team' => TeamAB::TeamA, + 'leftSide' => true, + ])->assertSeeInOrder([ + 'Team A', + 'Baker 2', + 'Young 9', + ]); +}); + +function gameWithNumberedRostersForTeamRoster(): Game +{ + $homeTeam = Team::factory()->create(); + $awayTeam = Team::factory()->create(); + $game = Game::factory()->betweenTeams($homeTeam, $awayTeam)->create(); + + $homePlayerOne = Player::factory()->for($homeTeam)->named('Anna', 'Zephyr')->create(); + $homePlayerTwo = Player::factory()->for($homeTeam)->named('Beth', 'Anderson')->create(); + $homeLibero = Player::factory()->for($homeTeam)->named('Cara', 'Libero')->create(); + + $awayPlayerOne = Player::factory()->for($awayTeam)->named('Dora', 'Young')->create(); + $awayPlayerTwo = Player::factory()->for($awayTeam)->named('Etta', 'Baker')->create(); + $awayLibero = Player::factory()->for($awayTeam)->named('Faye', 'Keeper')->create(); + + $game->addPlayer($homePlayerOne, number: 12); + $game->addPlayer($homePlayerTwo, number: 3); + $game->addPlayer($homeLibero, number: 1, isLibero: true); + $game->addPlayer($awayPlayerOne, number: 9); + $game->addPlayer($awayPlayerTwo, number: 2); + $game->addPlayer($awayLibero, number: 20, isLibero: true); + + return $game; +} diff --git a/tests/Feature/TossResultSubmissionTest.php b/tests/Feature/TossResultSubmissionTest.php index f32e2a1..2b798bf 100644 --- a/tests/Feature/TossResultSubmissionTest.php +++ b/tests/Feature/TossResultSubmissionTest.php @@ -61,3 +61,12 @@ expect($targetGame->fresh()->events)->toHaveCount(1) ->and($otherGame->fresh()->events)->toHaveCount(0); }); + +test('toss submit button is hidden when toss has already been recorded', function (): void { + $game = Game::factory()->create(); + $game->recordToss(TeamSide::Home, TeamAB::TeamA); + + Livewire::test(TossResultSubmission::class, ['gameId' => $game->getKey()]) + ->assertDontSee('Submit Toss Result') + ->assertDontSee('Save Toss Result'); +}); From 840a0287138f89f393712f2d1233ecaa823643f0 Mon Sep 17 00:00:00 2001 From: Giulio Troccoli-Allard Date: Thu, 19 Mar 2026 18:43:36 +0000 Subject: [PATCH 07/63] feat: implement lineup submission --- .../Payloads/LineupSubmittedPayload.php | 16 +- app/Livewire/Court.php | 42 +++- app/Livewire/LineupSubmission.php | 230 +++++++++++++++++- app/Livewire/TeamRoster.php | 13 +- app/Models/Concerns/RecordsLineup.php | 10 +- .../GameState/GameEventRuleValidator.php | 14 +- app/Services/GameState/GameStateProjector.php | 11 +- resources/views/livewire/court.blade.php | 47 ++++ .../livewire/lineup-submission.blade.php | 56 +++-- tests/Feature/CourtTest.php | 36 +++ tests/Feature/GameEventTest.php | 125 ++++++++-- tests/Feature/GameStateProjectionTest.php | 35 ++- tests/Feature/LineupSubmissionTest.php | 158 +++++++++++- tests/Feature/TeamRosterTest.php | 13 + 14 files changed, 699 insertions(+), 107 deletions(-) diff --git a/app/Events/Payloads/LineupSubmittedPayload.php b/app/Events/Payloads/LineupSubmittedPayload.php index 044c6e1..55cec7c 100644 --- a/app/Events/Payloads/LineupSubmittedPayload.php +++ b/app/Events/Payloads/LineupSubmittedPayload.php @@ -18,9 +18,9 @@ private function __construct( /** * @param array $positions - * @param Collection $validPlayerIds + * @param Collection $validRosterNumbers */ - public static function create(int $set, TeamAB $team, array $positions, Collection $validPlayerIds): static + public static function create(int $set, TeamAB $team, array $positions, Collection $validRosterNumbers): static { if (count($positions) !== 6) { throw new \InvalidArgumentException('A lineup must have exactly 6 positions.'); @@ -33,12 +33,16 @@ public static function create(int $set, TeamAB $team, array $positions, Collecti } if (count(array_unique($positions)) !== 6) { - throw new \InvalidArgumentException('All 6 lineup positions must have different players.'); + throw new \InvalidArgumentException('All 6 lineup positions must have different roster numbers.'); } - foreach ($positions as $playerId) { - if (! $validPlayerIds->contains($playerId)) { - throw new \InvalidArgumentException("Player {$playerId} is not on the roster for the specified team."); + foreach ($positions as $position => $rosterNumber) { + if ($rosterNumber <= 0) { + throw new \InvalidArgumentException("Lineup position {$position} must contain a positive roster number."); + } + + if (! $validRosterNumbers->contains($rosterNumber)) { + throw new \InvalidArgumentException("Roster number {$rosterNumber} is not on the non-libero roster for the specified team."); } } diff --git a/app/Livewire/Court.php b/app/Livewire/Court.php index 209cd06..640d749 100644 --- a/app/Livewire/Court.php +++ b/app/Livewire/Court.php @@ -40,7 +40,9 @@ public function render(): View /** * @return array{ * leftTeam: TeamAB, - * rightTeam: TeamAB + * rightTeam: TeamAB, + * leftRotation: array, + * rightRotation: array * } */ private function courtContext(): array @@ -48,6 +50,8 @@ private function courtContext(): array $defaultContext = [ 'leftTeam' => TeamAB::TeamA, 'rightTeam' => TeamAB::TeamB, + 'leftRotation' => $this->rotationForTeam(TeamAB::TeamA), + 'rightRotation' => $this->rotationForTeam(TeamAB::TeamB), ]; if ($this->gameId === null) { @@ -67,6 +71,8 @@ private function courtContext(): array return [ 'leftTeam' => TeamAB::TeamB, 'rightTeam' => TeamAB::TeamA, + 'leftRotation' => $this->rotationForTeam(TeamAB::TeamB), + 'rightRotation' => $this->rotationForTeam(TeamAB::TeamA), ]; } @@ -118,4 +124,38 @@ private function latestTossPayload(Game $game): ?TossCompletedPayload /** @var TossCompletedPayload */ return $tossEvent->payload; } + + /** + * @return array + */ + private function rotationForTeam(TeamAB $team): array + { + $key = $team === TeamAB::TeamA ? 'rotation_team_a' : 'rotation_team_b'; + $rotation = $this->gameState[$key] ?? []; + + if (! is_array($rotation)) { + return []; + } + + $normalizedRotation = []; + + foreach ($rotation as $position => $number) { + if (! is_numeric((string) $position) || ! is_numeric((string) $number)) { + continue; + } + + $normalizedPosition = (int) $position; + $normalizedNumber = (int) $number; + + if ($normalizedPosition < 1 || $normalizedPosition > 6 || $normalizedNumber <= 0) { + continue; + } + + $normalizedRotation[$normalizedPosition] = $normalizedNumber; + } + + ksort($normalizedRotation); + + return $normalizedRotation; + } } diff --git a/app/Livewire/LineupSubmission.php b/app/Livewire/LineupSubmission.php index 858b4a9..c440432 100644 --- a/app/Livewire/LineupSubmission.php +++ b/app/Livewire/LineupSubmission.php @@ -4,9 +4,17 @@ namespace App\Livewire; +use App\Enums\GameEventType; use App\Enums\TeamAB; +use App\Enums\TeamSide; +use App\Events\Payloads\TossCompletedPayload; +use App\Exceptions\InvalidGameEventTransition; +use App\Models\Game; +use App\Models\GameEvent; use Flux\Flux; use Illuminate\Contracts\View\View; +use Illuminate\Support\Facades\Validator; +use InvalidArgumentException; use Livewire\Attributes\Locked; use Livewire\Attributes\Reactive; use Livewire\Component; @@ -36,14 +44,7 @@ public function mount(TeamAB $team, ?int $gameId = null, array $gameState = []): $this->team = $team; $this->gameId = $gameId; $this->gameState = $gameState; - $this->lineup = [ - 1 => '', - 2 => '', - 3 => '', - 4 => '', - 5 => '', - 6 => '', - ]; + $this->lineup = $this->defaultLineup(); } public function modalName(): string @@ -60,11 +61,222 @@ public function modalHeading(): string public function submit(): void { + $this->resetValidation(); + + if (! $this->validateLineup()) { + return; + } + + $activeGame = Game::query()->whereKey($this->gameId)->first(); + + if ($activeGame === null) { + $this->addError('submit', 'No active game is available to record the lineup.'); + + return; + } + + $positions = $this->lineupPositions(); + $eligibleNumbers = $this->eligibleRosterNumbers($activeGame); + + if ($eligibleNumbers !== [] && ! $this->lineupUsesEligibleRosterNumbers($positions, $eligibleNumbers)) { + return; + } + + $set = $activeGame->stateAt()->setNumber + 1; + + try { + $activeGame->recordLineup($set, $this->team, $positions); + } catch (InvalidGameEventTransition|InvalidArgumentException $exception) { + $this->addError('submit', $exception->getMessage()); + + return; + } + Flux::modal($this->modalName())->close(); + $this->dispatch('game-event-recorded'); + + $this->resetValidation(); + $this->lineup = $this->defaultLineup(); } public function render(): View { - return view('livewire.lineup-submission'); + return view('livewire.lineup-submission', [ + 'canSubmitLineup' => $this->canSubmitLineup(), + ]); + } + + /** + * @return array> + */ + protected function rules(): array + { + return [ + 'lineup' => ['required', 'array', 'size:6'], + 'lineup.*' => ['required', 'integer', 'min:1', 'distinct'], + ]; + } + + /** + * @return array + */ + protected function messages(): array + { + return [ + 'lineup.size' => 'A lineup must contain exactly 6 roster numbers.', + 'lineup.*.required' => 'Each lineup position must have a roster number.', + 'lineup.*.integer' => 'Roster numbers must be positive integers.', + 'lineup.*.min' => 'Roster numbers must be positive integers.', + 'lineup.*.distinct' => 'All lineup roster numbers must be different.', + ]; + } + + /** + * @return array + */ + private function lineupPositions(): array + { + $positions = []; + + foreach (range(1, 6) as $position) { + $positions[$position] = (int) $this->lineup[$position]; + } + + return $positions; + } + + /** + * @return array + */ + private function eligibleRosterNumbers(Game $game): array + { + /** @var GameEvent|null $tossEvent */ + $tossEvent = $game->events() + ->where('type', GameEventType::TossCompleted) + ->orderByDesc('created_at') + ->orderByDesc('id') + ->first(); + + if ($tossEvent === null) { + return []; + } + + /** @var TossCompletedPayload $tossPayload */ + $tossPayload = $tossEvent->payload; + + $side = $this->team === TeamAB::TeamA + ? $tossPayload->teamA + : ($tossPayload->teamA === TeamSide::Home ? TeamSide::Away : TeamSide::Home); + + $rosterNumbers = $side === TeamSide::Home + ? $game->homePlayers()->wherePivot('is_libero', false)->pluck('game_player.number') + : $game->awayPlayers()->wherePivot('is_libero', false)->pluck('game_player.number'); + + return $rosterNumbers + ->map(fn (mixed $number): int => (int) $number) + ->all(); + } + + /** + * @param array $positions + * @param array $eligibleNumbers + */ + private function lineupUsesEligibleRosterNumbers(array $positions, array $eligibleNumbers): bool + { + foreach ($positions as $position => $rosterNumber) { + if (! in_array($rosterNumber, $eligibleNumbers, true)) { + $this->addError('submit', "Lineup position {$position} is not in this team roster or is a libero."); + + return false; + } + } + + return true; + } + + private function validateLineup(): bool + { + $validator = Validator::make( + data: ['lineup' => $this->lineup], + rules: $this->rules(), + messages: $this->messages(), + )->stopOnFirstFailure(); + + if ($validator->fails()) { + $this->addError('submit', $validator->errors()->first()); + + return false; + } + + return true; + } + + private function canSubmitLineup(): bool + { + if ($this->isSetInProgress() || $this->isGameEnded()) { + return false; + } + + return ! $this->hasSubmittedLineupForUpcomingSet(); + } + + private function hasSubmittedLineupForUpcomingSet(): bool + { + $activeGame = Game::query()->whereKey($this->gameId)->first(); + + if ($activeGame === null) { + return false; + } + + return $activeGame->events() + ->where('type', GameEventType::LineupSubmitted) + ->where('payload->set', $this->upcomingSetNumber()) + ->where('payload->team', $this->team->value) + ->exists(); + } + + private function upcomingSetNumber(): int + { + return $this->currentSetNumber() + 1; + } + + private function currentSetNumber(): int + { + $setNumber = $this->gameState['set_number'] ?? 0; + + if (is_int($setNumber)) { + return $setNumber; + } + + return is_numeric($setNumber) ? (int) $setNumber : 0; + } + + private function isSetInProgress(): bool + { + $setInProgress = $this->gameState['set_in_progress'] ?? false; + + return (bool) $setInProgress; + } + + private function isGameEnded(): bool + { + $gameEnded = $this->gameState['game_ended'] ?? false; + + return (bool) $gameEnded; + } + + /** + * @return array + */ + private function defaultLineup(): array + { + return [ + 1 => '', + 2 => '', + 3 => '', + 4 => '', + 5 => '', + 6 => '', + ]; } } diff --git a/app/Livewire/TeamRoster.php b/app/Livewire/TeamRoster.php index 81c98dd..a61921c 100644 --- a/app/Livewire/TeamRoster.php +++ b/app/Livewire/TeamRoster.php @@ -15,8 +15,6 @@ class TeamRoster extends Component { - protected CacheRepository $cacheRepository; - #[Reactive] #[Locked] public int $gameId; @@ -28,14 +26,12 @@ class TeamRoster extends Component public bool $leftSide = true; public function mount( - CacheRepository $cacheRepository, ?int $gameId = null, TeamAB $team = TeamAB::TeamA, bool $leftSide = true, ): void { abort_if(is_null($gameId), 404); - $this->cacheRepository = $cacheRepository; $this->gameId = $gameId; $this->team = $team; $this->leftSide = $leftSide; @@ -80,12 +76,12 @@ private function playersForTeam(Game $game, TeamAB $team): array ? $teamASide : $this->oppositeSide($teamASide); - return $this->cacheRepository->playersForSide($game, $targetSide); + return $this->cacheRepository()->playersForSide($game, $targetSide); } private function teamASideForTossSets(Game $game): TeamSide { - $tossPayload = $this->cacheRepository->latestTossPayload($game); + $tossPayload = $this->cacheRepository()->latestTossPayload($game); if ($tossPayload === null) { return TeamSide::Home; @@ -94,6 +90,11 @@ private function teamASideForTossSets(Game $game): TeamSide return $tossPayload->teamA; } + private function cacheRepository(): CacheRepository + { + return app(CacheRepository::class); + } + private function oppositeSide(TeamSide $side): TeamSide { return $side === TeamSide::Home diff --git a/app/Models/Concerns/RecordsLineup.php b/app/Models/Concerns/RecordsLineup.php index 83817b5..a74e187 100644 --- a/app/Models/Concerns/RecordsLineup.php +++ b/app/Models/Concerns/RecordsLineup.php @@ -26,7 +26,7 @@ public function recordLineup(int $set, TeamAB $team, array $positions): void app(GameEventRuleValidator::class)->assertCanRecordLineup($this, $set); $tossPayload = $this->getLatestTossPayload(); - $validPlayerIds = $this->resolveRosterForTeam($team, $tossPayload); + $validRosterNumbers = $this->resolveRosterNumbersForTeam($team, $tossPayload); $this->events()->create([ 'type' => GameEventType::LineupSubmitted, @@ -34,7 +34,7 @@ public function recordLineup(int $set, TeamAB $team, array $positions): void set: $set, team: $team, positions: $positions, - validPlayerIds: $validPlayerIds, + validRosterNumbers: $validRosterNumbers, ), ]); } @@ -58,14 +58,14 @@ private function getLatestTossPayload(): TossCompletedPayload /** * @return Collection */ - private function resolveRosterForTeam(TeamAB $team, TossCompletedPayload $tossPayload): Collection + private function resolveRosterNumbersForTeam(TeamAB $team, TossCompletedPayload $tossPayload): Collection { $side = $team === TeamAB::TeamA ? $tossPayload->teamA : ($tossPayload->teamA === TeamSide::Home ? TeamSide::Away : TeamSide::Home); return $side === TeamSide::Home - ? $this->homePlayers()->pluck('players.id') - : $this->awayPlayers()->pluck('players.id'); + ? $this->homePlayers()->wherePivot('is_libero', false)->pluck('game_player.number') + : $this->awayPlayers()->wherePivot('is_libero', false)->pluck('game_player.number'); } } diff --git a/app/Services/GameState/GameEventRuleValidator.php b/app/Services/GameState/GameEventRuleValidator.php index 32d225e..af768cd 100644 --- a/app/Services/GameState/GameEventRuleValidator.php +++ b/app/Services/GameState/GameEventRuleValidator.php @@ -45,12 +45,18 @@ public function assertCanRecordLineup(Game $game, int $set): void $this->fail('A lineup cannot be submitted before the toss has been recorded.'); } - if (! $state->setInProgress) { - $this->fail('A lineup can only be submitted during an active set.'); + if ($state->gameEnded || $state->setsWonTeamA >= 3 || $state->setsWonTeamB >= 3) { + $this->fail('A lineup cannot be submitted after the game has ended.'); } - if ($set !== $state->setNumber) { - $this->fail('The lineup set number must match the current active set.'); + if ($state->setInProgress) { + $this->fail('A lineup can only be submitted before the set starts.'); + } + + $expectedSet = $state->setNumber + 1; + + if ($set !== $expectedSet) { + $this->fail('The lineup set number must match the upcoming set.'); } } diff --git a/app/Services/GameState/GameStateProjector.php b/app/Services/GameState/GameStateProjector.php index 72d8a7d..dbc975d 100644 --- a/app/Services/GameState/GameStateProjector.php +++ b/app/Services/GameState/GameStateProjector.php @@ -77,13 +77,6 @@ private function applyLineupSubmitted(GameState $state, GameEvent $event): GameS /** @var LineupSubmittedPayload $payload */ $payload = $event->payload; - if ($payload->set > $state->setNumber) { - $state->setNumber = $payload->set; - $state->resetCurrentSetCounters(); - $state->rotationTeamA = []; - $state->rotationTeamB = []; - } - if ($payload->team === TeamAB::TeamA) { $state->rotationTeamA = $payload->positions; } else { @@ -148,8 +141,6 @@ private function applySetStarted(GameState $state): GameState { $state->setNumber = max(1, $state->setNumber + 1); $state->resetCurrentSetCounters(); - $state->rotationTeamA = []; - $state->rotationTeamB = []; $state->setInProgress = true; return $state; @@ -169,6 +160,8 @@ private function applySetEnded(GameState $state): GameState : TeamAB::TeamA; } + $state->rotationTeamA = []; + $state->rotationTeamB = []; $state->setInProgress = false; return $state; diff --git a/resources/views/livewire/court.blade.php b/resources/views/livewire/court.blade.php index b523536..fa0bb20 100644 --- a/resources/views/livewire/court.blade.php +++ b/resources/views/livewire/court.blade.php @@ -1,4 +1,27 @@
+ @php + $leftCourtPositionClasses = [ + 1 => 'left-[12%] bottom-[14%]', + 2 => 'left-[38%] bottom-[14%]', + 3 => 'left-[38%] top-1/2 -translate-y-1/2', + 4 => 'left-[38%] top-[14%]', + 5 => 'left-[12%] top-[14%]', + 6 => 'left-[12%] top-1/2 -translate-y-1/2', + ]; + + $rightCourtPositionClasses = [ + 1 => 'right-[12%] top-[14%]', + 2 => 'right-[38%] top-[14%]', + 3 => 'right-[38%] top-1/2 -translate-y-1/2', + 4 => 'right-[38%] bottom-[14%]', + 5 => 'right-[12%] bottom-[14%]', + 6 => 'right-[12%] top-1/2 -translate-y-1/2', + ]; + + $leftMarkerTone = $leftTeam === \App\Enums\TeamAB::TeamA ? 'bg-blue-600' : 'bg-red-600'; + $rightMarkerTone = $rightTeam === \App\Enums\TeamAB::TeamA ? 'bg-blue-600' : 'bg-red-600'; + @endphp +
+ + @foreach ($leftRotation as $position => $number) +
+ {{ $number }} +
+ @endforeach + + @foreach ($rightRotation as $position => $number) +
+ {{ $number }} +
+ @endforeach - - Submit Lineup - + @if ($canSubmitLineup) + + Submit Lineup + - -
- {{ $this->modalHeading() }} + + + {{ $this->modalHeading() }} -
- @for ($position = 1; $position <= 6; $position++) - - @endfor -
+
+ @for ($position = 1; $position <= 6; $position++) + + @endfor +
-
- - Submit -
- -
+ @error('submit') + {{ $message }} + @enderror + +
+ + Submit +
+ +
+ @endif
diff --git a/tests/Feature/CourtTest.php b/tests/Feature/CourtTest.php index b38806b..c82a036 100644 --- a/tests/Feature/CourtTest.php +++ b/tests/Feature/CourtTest.php @@ -138,6 +138,42 @@ ]); }); +test('court shows lineup position one as bottom left for the left side and top right for the right side', function (): void { + $game = gameWithNumberedRosters(); + $game->recordToss(TeamSide::Home, TeamAB::TeamA); + + Livewire::test(Court::class, [ + 'gameId' => $game->getKey(), + 'gameState' => [ + 'set_number' => 1, + 'rotation_team_a' => [1 => 12], + 'rotation_team_b' => [1 => 9], + ], + ]) + ->assertSeeHtml('data-court-marker="left-team_a-1"') + ->assertSeeHtml('data-court-marker="right-team_b-1"') + ->assertSeeHtml('left-[12%] bottom-[14%]') + ->assertSeeHtml('right-[12%] top-[14%]'); +}); + +test('court position one anchors follow the side after team swap', function (): void { + $game = gameWithNumberedRosters(); + $game->recordToss(TeamSide::Home, TeamAB::TeamA); + + Livewire::test(Court::class, [ + 'gameId' => $game->getKey(), + 'gameState' => [ + 'set_number' => 2, + 'rotation_team_a' => [1 => 12], + 'rotation_team_b' => [1 => 9], + ], + ]) + ->assertSeeHtml('data-court-marker="left-team_b-1"') + ->assertSeeHtml('data-court-marker="right-team_a-1"') + ->assertSeeHtml('left-[12%] bottom-[14%]') + ->assertSeeHtml('right-[12%] top-[14%]'); +}); + function gameWithNumberedRosters(): Game { $homeTeam = Team::factory()->create(); diff --git a/tests/Feature/GameEventTest.php b/tests/Feature/GameEventTest.php index 2625b8e..694ec06 100644 --- a/tests/Feature/GameEventTest.php +++ b/tests/Feature/GameEventTest.php @@ -33,6 +33,21 @@ function winSet(Game $game, TeamAB $winner): void } } +/** + * @return array + */ +function lineupWithRosterNumbers(int $start = 1): array +{ + return [ + 1 => $start, + 2 => $start + 1, + 3 => $start + 2, + 4 => $start + 3, + 5 => $start + 4, + 6 => $start + 5, + ]; +} + test('a toss can be recorded with the correct type and payload', function (): void { $homeTeam = Team::factory()->create(); $awayTeam = Team::factory()->create(); @@ -65,18 +80,17 @@ function winSet(Game $game, TeamAB $winner): void $awayTeam = Team::factory()->create(); $game = Game::factory()->betweenTeams($homeTeam, $awayTeam)->create(); + $positions = lineupWithRosterNumbers(); $players = Player::factory()->for($homeTeam)->count(6)->create(); - $positions = $players->mapWithKeys(fn (Player $player, int $index) => [$index + 1 => $player->getKey()])->all(); $game->recordToss(TeamSide::Home, TeamAB::TeamA); - $game->recordSetStarted(); foreach ($players as $i => $player) { $game->addPlayer($player, number: $i + 1); } $game->recordLineup(1, TeamAB::TeamA, $positions); - expect($game->events)->toHaveCount(3); + expect($game->events)->toHaveCount(2); $event = $game->events->last(); expect($event->type)->toBe(GameEventType::LineupSubmitted) @@ -91,11 +105,10 @@ function winSet(Game $game, TeamAB $winner): void $awayTeam = Team::factory()->create(); $game = Game::factory()->betweenTeams($homeTeam, $awayTeam)->create(); + $positions = lineupWithRosterNumbers(); $players = Player::factory()->for($homeTeam)->count(6)->create(); - $positions = $players->mapWithKeys(fn (Player $player, int $index) => [$index + 1 => $player->getKey()])->all(); $game->recordToss(TeamSide::Home, TeamAB::TeamA); - $game->recordSetStarted(); foreach ($players as $i => $player) { $game->addPlayer($player, number: $i + 1); } @@ -149,14 +162,19 @@ function winSet(Game $game, TeamAB $winner): void $game = Game::factory()->betweenTeams($homeTeam, $awayTeam)->create(); $game->recordToss(TeamSide::Home, TeamAB::TeamA); - $game->recordSetStarted(); $players = Player::factory()->for($homeTeam)->count(5)->create(); foreach ($players as $i => $player) { $game->addPlayer($player, number: $i + 1); } - $positions = $players->mapWithKeys(fn (Player $player, int $index) => [$index + 1 => $player->getKey()])->all(); + $positions = [ + 1 => 1, + 2 => 2, + 3 => 3, + 4 => 4, + 5 => 5, + ]; expect(fn () => $game->recordLineup(1, TeamAB::TeamA, $positions)) ->toThrow(InvalidArgumentException::class, 'A lineup must have exactly 6 positions.'); @@ -168,37 +186,41 @@ function winSet(Game $game, TeamAB $winner): void $game = Game::factory()->betweenTeams($homeTeam, $awayTeam)->create(); $game->recordToss(TeamSide::Home, TeamAB::TeamA); - $game->recordSetStarted(); $players = Player::factory()->for($homeTeam)->count(6)->create(); foreach ($players as $i => $player) { $game->addPlayer($player, number: $i + 1); } - $positions = $players->mapWithKeys(fn (Player $player, int $index) => [$index => $player->getKey()])->all(); + $positions = [ + 0 => 1, + 1 => 2, + 2 => 3, + 3 => 4, + 4 => 5, + 5 => 6, + ]; expect(fn () => $game->recordLineup(1, TeamAB::TeamA, $positions)) ->toThrow(InvalidArgumentException::class, 'Lineup positions must be keyed 1 through 6.'); }); -test('a lineup with a duplicate player ID throws an InvalidArgumentException', function (): void { +test('a lineup with a duplicate roster number throws an InvalidArgumentException', function (): void { $homeTeam = Team::factory()->create(); $awayTeam = Team::factory()->create(); $game = Game::factory()->betweenTeams($homeTeam, $awayTeam)->create(); $game->recordToss(TeamSide::Home, TeamAB::TeamA); - $game->recordSetStarted(); $players = Player::factory()->for($homeTeam)->count(6)->create(); foreach ($players as $i => $player) { $game->addPlayer($player, number: $i + 1); } - $firstPlayerId = $players->first()->getKey(); - $positions = [1 => $firstPlayerId, 2 => $firstPlayerId, 3 => $players[2]->getKey(), 4 => $players[3]->getKey(), 5 => $players[4]->getKey(), 6 => $players[5]->getKey()]; + $positions = [1 => 1, 2 => 1, 3 => 3, 4 => 4, 5 => 5, 6 => 6]; expect(fn () => $game->recordLineup(1, TeamAB::TeamA, $positions)) - ->toThrow(InvalidArgumentException::class, 'All 6 lineup positions must have different players.'); + ->toThrow(InvalidArgumentException::class, 'All 6 lineup positions must have different roster numbers.'); }); test('a lineup submitted before the toss is rejected', function (): void { @@ -206,13 +228,53 @@ function winSet(Game $game, TeamAB $winner): void $awayTeam = Team::factory()->create(); $game = Game::factory()->betweenTeams($homeTeam, $awayTeam)->create(); - $players = Player::factory()->for($homeTeam)->count(6)->create(); - $positions = $players->mapWithKeys(fn (Player $player, int $index) => [$index + 1 => $player->getKey()])->all(); + $positions = lineupWithRosterNumbers(); expect(fn () => $game->recordLineup(1, TeamAB::TeamA, $positions)) ->toThrow(InvalidGameEventTransition::class, 'A lineup cannot be submitted before the toss has been recorded.'); }); +test('a lineup cannot be submitted after the set has started', function (): void { + $homeTeam = Team::factory()->create(); + $awayTeam = Team::factory()->create(); + $game = Game::factory()->betweenTeams($homeTeam, $awayTeam)->create(); + + $players = Player::factory()->for($homeTeam)->count(6)->create(); + foreach ($players as $i => $player) { + $game->addPlayer($player, number: $i + 1); + } + + $game->recordToss(TeamSide::Home, TeamAB::TeamA); + $game->recordSetStarted(); + + $positions = lineupWithRosterNumbers(); + + expect(fn () => $game->recordLineup(1, TeamAB::TeamA, $positions)) + ->toThrow(InvalidGameEventTransition::class, 'A lineup can only be submitted before the set starts.'); +}); + +test('a lineup for the next set can be submitted after the previous set ends', function (): void { + $homeTeam = Team::factory()->create(); + $awayTeam = Team::factory()->create(); + $game = Game::factory()->betweenTeams($homeTeam, $awayTeam)->create(); + + $players = Player::factory()->for($homeTeam)->count(6)->create(); + foreach ($players as $i => $player) { + $game->addPlayer($player, number: $i + 1); + } + + $game->recordToss(TeamSide::Home, TeamAB::TeamA); + $game->recordSetStarted(); + winSet($game, TeamAB::TeamA); + + $positions = lineupWithRosterNumbers(); + $game->recordLineup(2, TeamAB::TeamA, $positions); + + $event = $game->events->last(); + expect($event->type)->toBe(GameEventType::LineupSubmitted) + ->and($event->payload->set)->toBe(2); +}); + test('a rally ended event can be recorded with the correct type and payload', function (): void { $homeTeam = Team::factory()->create(); $awayTeam = Team::factory()->create(); @@ -242,23 +304,46 @@ function winSet(Game $game, TeamAB $winner): void 'team B' => [TeamAB::TeamB], ]); -test('a lineup with a player not on the team roster throws an InvalidArgumentException', function (): void { +test('a lineup with a roster number not on the team roster throws an InvalidArgumentException', function (): void { $homeTeam = Team::factory()->create(); $awayTeam = Team::factory()->create(); $game = Game::factory()->betweenTeams($homeTeam, $awayTeam)->create(); $game->recordToss(TeamSide::Home, TeamAB::TeamA); - $game->recordSetStarted(); + + $homePlayers = Player::factory()->for($homeTeam)->count(6)->create(); + foreach ($homePlayers as $i => $player) { + $game->addPlayer($player, number: $i + 1); + } $awayPlayers = Player::factory()->for($awayTeam)->count(6)->create(); foreach ($awayPlayers as $i => $player) { + $game->addPlayer($player, number: $i + 11); + } + + $positions = lineupWithRosterNumbers(start: 11); + + expect(fn () => $game->recordLineup(1, TeamAB::TeamA, $positions)) + ->toThrow(InvalidArgumentException::class, 'is not on the non-libero roster for the specified team.'); +}); + +test('a lineup with non-positive roster numbers throws an InvalidArgumentException', function (): void { + $homeTeam = Team::factory()->create(); + $awayTeam = Team::factory()->create(); + $game = Game::factory()->betweenTeams($homeTeam, $awayTeam)->create(); + + $players = Player::factory()->for($homeTeam)->count(6)->create(); + foreach ($players as $i => $player) { $game->addPlayer($player, number: $i + 1); } - $positions = $awayPlayers->mapWithKeys(fn (Player $player, int $index) => [$index + 1 => $player->getKey()])->all(); + $game->recordToss(TeamSide::Home, TeamAB::TeamA); + + $positions = lineupWithRosterNumbers(); + $positions[1] = 0; expect(fn () => $game->recordLineup(1, TeamAB::TeamA, $positions)) - ->toThrow(InvalidArgumentException::class, 'is not on the roster for the specified team.'); + ->toThrow(InvalidArgumentException::class, 'must contain a positive roster number.'); }); test('a substitution can be recorded with the correct type and payload', function (): void { diff --git a/tests/Feature/GameStateProjectionTest.php b/tests/Feature/GameStateProjectionTest.php index a9a5d6e..988da5b 100644 --- a/tests/Feature/GameStateProjectionTest.php +++ b/tests/Feature/GameStateProjectionTest.php @@ -32,26 +32,35 @@ $game->addPlayer($player, number: $index + 1); } - $homeLineup = $homePlayers - ->take(6) - ->mapWithKeys(fn (Player $player, int $index) => [$index + 1 => $player->getKey()]) - ->all(); - - $awayLineup = $awayPlayers - ->mapWithKeys(fn (Player $player, int $index) => [$index + 1 => $player->getKey()]) - ->all(); + $homeLineup = [ + 1 => 1, + 2 => 2, + 3 => 3, + 4 => 4, + 5 => 5, + 6 => 6, + ]; + + $awayLineup = [ + 1 => 1, + 2 => 2, + 3 => 3, + 4 => 4, + 5 => 5, + 6 => 6, + ]; $game->recordToss(TeamSide::Home, TeamAB::TeamA); - $game->recordSetStarted(); $game->recordLineup(1, TeamAB::TeamA, $homeLineup); $game->recordLineup(1, TeamAB::TeamB, $awayLineup); + $game->recordSetStarted(); $game->recordRallyWinner(TeamAB::TeamA); $game->recordRallyWinner(TeamAB::TeamB); $game->recordTimeOut(TeamAB::TeamB); $game->recordSubstitution( TeamAB::TeamA, - playerOut: $homePlayers->first()->getKey(), - playerIn: $homePlayers->last()->getKey(), + playerOut: 1, + playerIn: 7, ); /** @var GameState $state */ @@ -67,8 +76,8 @@ ->and($state->substitutionsTeamA)->toBe(1) ->and($state->substitutionsTeamB)->toBe(0) ->and($state->servingTeam)->toBe(TeamAB::TeamB) - ->and($state->rotationTeamA[1])->toBe($homePlayers->last()->getKey()) - ->and($state->rotationTeamB[1])->toBe($awayPlayers[1]->getKey()); + ->and($state->rotationTeamA[1])->toBe(7) + ->and($state->rotationTeamB[1])->toBe(2); }); test('recalculation job rebuilds snapshots from scratch up to a cutoff time', function (): void { diff --git a/tests/Feature/LineupSubmissionTest.php b/tests/Feature/LineupSubmissionTest.php index a63715d..d359a10 100644 --- a/tests/Feature/LineupSubmissionTest.php +++ b/tests/Feature/LineupSubmissionTest.php @@ -2,13 +2,74 @@ declare(strict_types=1); +use App\Enums\GameEventType; use App\Enums\TeamAB; +use App\Enums\TeamSide; use App\Livewire\LineupSubmission; use App\Models\Game; +use App\Models\Player; +use App\Models\Team; +use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\View\ViewException; use Livewire\Features\SupportTesting\Testable; use Livewire\Livewire; +uses(RefreshDatabase::class); + +/** + * @return array + */ +function validLineupInput(): array +{ + return [ + 1 => '1', + 2 => '2', + 3 => '3', + 4 => '4', + 5 => '5', + 6 => '6', + ]; +} + +/** + * @return array + */ +function validLineupPositions(): array +{ + return [ + 1 => 1, + 2 => 2, + 3 => 3, + 4 => 4, + 5 => 5, + 6 => 6, + ]; +} + +function prepareGameForLineupSubmission(): Game +{ + $homeTeam = Team::factory()->create(); + $awayTeam = Team::factory()->create(); + $game = Game::factory()->betweenTeams($homeTeam, $awayTeam)->create(); + + $homePlayers = Player::factory()->for($homeTeam)->count(6)->create(); + foreach ($homePlayers as $index => $player) { + $game->addPlayer($player, number: $index + 1); + } + + $homeLibero = Player::factory()->for($homeTeam)->create(); + $game->addPlayer($homeLibero, number: 99, isLibero: true); + + $awayPlayers = Player::factory()->for($awayTeam)->count(6)->create(); + foreach ($awayPlayers as $index => $player) { + $game->addPlayer($player, number: $index + 11); + } + + $game->recordToss(TeamSide::Home, TeamAB::TeamA); + + return $game; +} + test('lineup submission renders team a button and modal', function (): void { $game = Game::factory()->create(); @@ -40,20 +101,73 @@ ->toThrow(ViewException::class); }); -test('lineup submission accepts submit action', function (): void { - $game = Game::factory()->create(); +test('lineup submission records an event and dispatches a refresh event', function (): void { + $game = prepareGameForLineupSubmission(); + + Livewire::test(LineupSubmission::class, ['team' => TeamAB::TeamA, 'gameId' => $game->getKey()]) + ->set('lineup', validLineupInput()) + ->call('submit') + ->assertHasNoErrors() + ->assertDispatched('game-event-recorded') + ->assertSet('lineup.1', ''); + + $lineupEvent = $game->fresh()->events->last(); + + expect($lineupEvent)->not->toBeNull() + ->and($lineupEvent->type)->toBe(GameEventType::LineupSubmitted) + ->and($lineupEvent->payload->positions)->toBe([ + 1 => 1, + 2 => 2, + 3 => 3, + 4 => 4, + 5 => 5, + 6 => 6, + ]); +}); + +test('lineup submission requires positive integers', function (): void { + $game = prepareGameForLineupSubmission(); + $lineup = validLineupInput(); + $lineup[1] = '0'; + + Livewire::test(LineupSubmission::class, ['team' => TeamAB::TeamA, 'gameId' => $game->getKey()]) + ->set('lineup', $lineup) + ->call('submit') + ->assertHasErrors(['submit']) + ->assertHasNoErrors(['lineup.1', 'lineup.2', 'lineup.3', 'lineup.4', 'lineup.5', 'lineup.6']); +}); + +test('lineup submission requires all roster numbers to be different', function (): void { + $game = prepareGameForLineupSubmission(); + $lineup = validLineupInput(); + $lineup[2] = '1'; Livewire::test(LineupSubmission::class, ['team' => TeamAB::TeamA, 'gameId' => $game->getKey()]) - ->set('lineup.1', '1') - ->set('lineup.2', '2') - ->set('lineup.3', '3') - ->set('lineup.4', '4') - ->set('lineup.5', '5') - ->set('lineup.6', '6') + ->set('lineup', $lineup) ->call('submit') - ->assertHasNoErrors(); + ->assertHasErrors(['submit']) + ->assertHasNoErrors(['lineup.1', 'lineup.2', 'lineup.3', 'lineup.4', 'lineup.5', 'lineup.6']); + + expect($game->fresh()->events)->toHaveCount(1); }); +test('lineup submission rejects roster numbers not eligible for the selected team', function (string $invalidRosterNumber): void { + $game = prepareGameForLineupSubmission(); + $lineup = validLineupInput(); + $lineup[1] = $invalidRosterNumber; + + Livewire::test(LineupSubmission::class, ['team' => TeamAB::TeamA, 'gameId' => $game->getKey()]) + ->set('lineup', $lineup) + ->call('submit') + ->assertHasErrors(['submit']) + ->assertHasNoErrors(['lineup.1', 'lineup.2', 'lineup.3', 'lineup.4', 'lineup.5', 'lineup.6']); + + expect($game->fresh()->events)->toHaveCount(1); +})->with([ + 'away team roster number' => ['11'], + 'libero roster number' => ['99'], +]); + test('lineup submission is aware of the injected game context', function (): void { $game = Game::factory()->create(); @@ -69,3 +183,29 @@ ->assertSet('gameState.set_number', 2) ->assertSet('gameState.serving_team', TeamAB::TeamB->value); }); + +test('lineup submission button is hidden after the lineup is already submitted for the same team and upcoming set', function (): void { + $game = prepareGameForLineupSubmission(); + $game->recordLineup(1, TeamAB::TeamA, validLineupPositions()); + + Livewire::test(LineupSubmission::class, [ + 'team' => TeamAB::TeamA, + 'gameId' => $game->getKey(), + 'gameState' => $game->stateAt()->toAttributes(), + ]) + ->assertDontSee('Submit Lineup') + ->assertDontSee('Team A Lineup'); +}); + +test('lineup submission button remains visible for the other team when only one team has submitted', function (): void { + $game = prepareGameForLineupSubmission(); + $game->recordLineup(1, TeamAB::TeamA, validLineupPositions()); + + Livewire::test(LineupSubmission::class, [ + 'team' => TeamAB::TeamB, + 'gameId' => $game->getKey(), + 'gameState' => $game->stateAt()->toAttributes(), + ]) + ->assertSee('Submit Lineup') + ->assertSee('Team B Lineup'); +}); diff --git a/tests/Feature/TeamRosterTest.php b/tests/Feature/TeamRosterTest.php index fee60d8..d9d53af 100644 --- a/tests/Feature/TeamRosterTest.php +++ b/tests/Feature/TeamRosterTest.php @@ -70,6 +70,19 @@ ]); }); +test('team roster can render after a second Livewire request', function (): void { + $game = gameWithNumberedRostersForTeamRoster(); + + Livewire::test(TeamRoster::class, [ + 'gameId' => $game->getKey(), + 'team' => TeamAB::TeamA, + 'leftSide' => true, + ]) + ->assertSee('Team A') + ->call('$refresh') + ->assertSee('Team A'); +}); + function gameWithNumberedRostersForTeamRoster(): Game { $homeTeam = Team::factory()->create(); From 4b2d89f2812cce4992662e50b6953c7058e7af78 Mon Sep 17 00:00:00 2001 From: Giulio Troccoli-Allard Date: Mon, 23 Mar 2026 18:13:48 +0000 Subject: [PATCH 08/63] feat: add scoreboard --- resources/views/livewire/game.blade.php | 35 +++++++++++++++++++++ tests/Feature/GameComponentStateTest.php | 40 ++++++++++++++++++++++++ 2 files changed, 75 insertions(+) diff --git a/resources/views/livewire/game.blade.php b/resources/views/livewire/game.blade.php index db86d20..9b21dfa 100644 --- a/resources/views/livewire/game.blade.php +++ b/resources/views/livewire/game.blade.php @@ -1,5 +1,40 @@
+ @php + $setsWonTeamA = is_numeric($gameState['sets_won_team_a'] ?? null) ? (int) $gameState['sets_won_team_a'] : 0; + $setsWonTeamB = is_numeric($gameState['sets_won_team_b'] ?? null) ? (int) $gameState['sets_won_team_b'] : 0; + $scoreTeamA = is_numeric($gameState['score_team_a'] ?? null) ? (int) $gameState['score_team_a'] : 0; + $scoreTeamB = is_numeric($gameState['score_team_b'] ?? null) ? (int) $gameState['score_team_b'] : 0; + @endphp +
+
+
+ Team A + Sets + Team B +
+
+ {{ $setsWonTeamA }} + : + {{ $setsWonTeamB }} +
+ +
+ Team A + Points + Team B +
+
+ {{ $scoreTeamA }} + : + {{ $scoreTeamB }} +
+
+
diff --git a/tests/Feature/GameComponentStateTest.php b/tests/Feature/GameComponentStateTest.php index 8c9f519..988d85b 100644 --- a/tests/Feature/GameComponentStateTest.php +++ b/tests/Feature/GameComponentStateTest.php @@ -34,3 +34,43 @@ ->assertSet('gameId', $targetGame->getKey()) ->assertSet('gameState.serving_team', TeamAB::TeamA->value); }); + +test('game component renders sets and current set points for both teams', function (): void { + $game = GameModel::factory()->create(); + $game->recordToss(TeamSide::Home, TeamAB::TeamA); + $game->recordSetStarted(); + + for ($index = 0; $index < 25; $index++) { + $game->recordRallyWinner(TeamAB::TeamA); + } + + $game->recordSetStarted(); + + for ($index = 0; $index < 7; $index++) { + $game->recordRallyWinner(TeamAB::TeamA); + } + + for ($index = 0; $index < 3; $index++) { + $game->recordRallyWinner(TeamAB::TeamB); + } + + Livewire::test(Game::class, ['game' => $game]) + ->assertSet('gameState.sets_won_team_a', 1) + ->assertSet('gameState.sets_won_team_b', 0) + ->assertSet('gameState.score_team_a', 7) + ->assertSet('gameState.score_team_b', 3) + ->assertSee('Sets') + ->assertSee('Points') + ->assertSeeHtml('data-scoreboard-sets-team-a') + ->assertSeeHtml('data-scoreboard-sets-team-b') + ->assertSeeHtml('data-scoreboard-points-team-a') + ->assertSeeHtml('data-scoreboard-points-team-b') + ->assertSeeInOrder([ + 'Sets', + '1', + '0', + 'Points', + '7', + '3', + ]); +}); From 44f5e45155ab1e20e344340213ed90b5d65398ab Mon Sep 17 00:00:00 2001 From: Giulio Troccoli-Allard Date: Mon, 23 Mar 2026 19:19:05 +0000 Subject: [PATCH 09/63] refactor: use the GameState object intead of array --- app/Data/GameState/GameState.php | 59 +++++++++++++++++++++++- app/Livewire/Court.php | 47 +++++-------------- app/Livewire/Game.php | 8 ++-- app/Livewire/LineupSubmission.php | 29 ++++-------- app/Livewire/TossResultSubmission.php | 10 ++-- resources/views/livewire/game.blade.php | 15 ++---- tests/Feature/CourtTest.php | 31 ++++++++----- tests/Feature/GameComponentStateTest.php | 19 ++++---- tests/Feature/LineupSubmissionTest.php | 13 +++--- 9 files changed, 126 insertions(+), 105 deletions(-) diff --git a/app/Data/GameState/GameState.php b/app/Data/GameState/GameState.php index eddfecd..6b62cb1 100644 --- a/app/Data/GameState/GameState.php +++ b/app/Data/GameState/GameState.php @@ -6,8 +6,9 @@ use App\Enums\TeamAB; use App\Models\GameStateSnapshot; +use Livewire\Wireable; -class GameState +class GameState implements Wireable { /** @var array */ public array $rotationTeamA = []; @@ -58,6 +59,41 @@ public static function fromSnapshot(GameStateSnapshot $snapshot): self return $state; } + /** + * @param array $attributes + */ + public static function fromAttributes(array $attributes): self + { + $state = new self( + setNumber: self::toInteger($attributes['set_number'] ?? 0), + scoreTeamA: self::toInteger($attributes['score_team_a'] ?? 0), + scoreTeamB: self::toInteger($attributes['score_team_b'] ?? 0), + setsWonTeamA: self::toInteger($attributes['sets_won_team_a'] ?? 0), + setsWonTeamB: self::toInteger($attributes['sets_won_team_b'] ?? 0), + timeoutsTeamA: self::toInteger($attributes['timeouts_team_a'] ?? 0), + timeoutsTeamB: self::toInteger($attributes['timeouts_team_b'] ?? 0), + substitutionsTeamA: self::toInteger($attributes['substitutions_team_a'] ?? 0), + substitutionsTeamB: self::toInteger($attributes['substitutions_team_b'] ?? 0), + servingTeam: is_string($attributes['serving_team'] ?? null) + ? TeamAB::tryFrom($attributes['serving_team']) + : null, + setInProgress: (bool) ($attributes['set_in_progress'] ?? false), + gameEnded: (bool) ($attributes['game_ended'] ?? false), + ); + + $rotationTeamA = $attributes['rotation_team_a'] ?? []; + $rotationTeamB = $attributes['rotation_team_b'] ?? []; + + $state->rotationTeamA = is_array($rotationTeamA) + ? self::normalizeRotation($rotationTeamA) + : []; + $state->rotationTeamB = is_array($rotationTeamB) + ? self::normalizeRotation($rotationTeamB) + : []; + + return $state; + } + /** * @return array */ @@ -81,6 +117,22 @@ public function toAttributes(): array ]; } + /** + * @return array + */ + public function toLivewire(): array + { + return $this->toAttributes(); + } + + /** + * @param mixed $value + */ + public static function fromLivewire($value): self + { + return is_array($value) ? self::fromAttributes($value) : self::initial(); + } + public function resetCurrentSetCounters(): void { $this->scoreTeamA = 0; @@ -107,4 +159,9 @@ private static function normalizeRotation(array $positions): array return $normalized; } + + private static function toInteger(mixed $value): int + { + return is_numeric($value) ? (int) $value : 0; + } } diff --git a/app/Livewire/Court.php b/app/Livewire/Court.php index 640d749..51343b8 100644 --- a/app/Livewire/Court.php +++ b/app/Livewire/Court.php @@ -4,6 +4,7 @@ namespace App\Livewire; +use App\Data\GameState\GameState; use App\Enums\GameEventType; use App\Enums\TeamAB; use App\Enums\TeamSide; @@ -19,17 +20,12 @@ class Court extends Component #[Reactive] public ?int $gameId = null; - /** @var array */ #[Reactive] - public array $gameState = []; + public ?GameState $gameState = null; - /** - * @param array $gameState - */ - public function mount(?int $gameId = null, array $gameState = []): void + public function mount(?int $gameId = null): void { $this->gameId = $gameId; - $this->gameState = $gameState; } public function render(): View @@ -103,9 +99,7 @@ private function teamASideForTossSets(Game $game): TeamSide private function setNumber(): int { - $setNumber = $this->gameState['set_number'] ?? 0; - - return is_int($setNumber) ? $setNumber : 0; + return $this->resolvedGameState()->setNumber; } private function latestTossPayload(Game $game): ?TossCompletedPayload @@ -130,32 +124,13 @@ private function latestTossPayload(Game $game): ?TossCompletedPayload */ private function rotationForTeam(TeamAB $team): array { - $key = $team === TeamAB::TeamA ? 'rotation_team_a' : 'rotation_team_b'; - $rotation = $this->gameState[$key] ?? []; - - if (! is_array($rotation)) { - return []; - } - - $normalizedRotation = []; - - foreach ($rotation as $position => $number) { - if (! is_numeric((string) $position) || ! is_numeric((string) $number)) { - continue; - } - - $normalizedPosition = (int) $position; - $normalizedNumber = (int) $number; - - if ($normalizedPosition < 1 || $normalizedPosition > 6 || $normalizedNumber <= 0) { - continue; - } - - $normalizedRotation[$normalizedPosition] = $normalizedNumber; - } - - ksort($normalizedRotation); + return $team === TeamAB::TeamA + ? $this->resolvedGameState()->rotationTeamA + : $this->resolvedGameState()->rotationTeamB; + } - return $normalizedRotation; + private function resolvedGameState(): GameState + { + return $this->gameState ?? GameState::initial(); } } diff --git a/app/Livewire/Game.php b/app/Livewire/Game.php index 97616b7..a1e493d 100644 --- a/app/Livewire/Game.php +++ b/app/Livewire/Game.php @@ -4,6 +4,7 @@ namespace App\Livewire; +use App\Data\GameState\GameState; use App\Models\Game as GameModel; use Illuminate\Contracts\View\View; use Livewire\Attributes\On; @@ -13,8 +14,7 @@ class Game extends Component { public int $gameId; - /** @var array */ - public array $gameState = []; + public GameState $gameState; public function mount(GameModel $game): void { @@ -26,9 +26,7 @@ public function mount(GameModel $game): void public function synchronizeGameContext(): void { $game = GameModel::query()->findSole($this->gameId); - $state = $game->stateAt(); - - $this->gameState = $state->toAttributes(); + $this->gameState = $game->stateAt(); } public function render(): View diff --git a/app/Livewire/LineupSubmission.php b/app/Livewire/LineupSubmission.php index c440432..6457158 100644 --- a/app/Livewire/LineupSubmission.php +++ b/app/Livewire/LineupSubmission.php @@ -4,6 +4,7 @@ namespace App\Livewire; +use App\Data\GameState\GameState; use App\Enums\GameEventType; use App\Enums\TeamAB; use App\Enums\TeamSide; @@ -27,23 +28,18 @@ class LineupSubmission extends Component public TeamAB $team = TeamAB::TeamA; - /** @var array */ #[Reactive] - public array $gameState = []; + public ?GameState $gameState = null; /** @var array */ public array $lineup = []; - /** - * @param array $gameState - */ - public function mount(TeamAB $team, ?int $gameId = null, array $gameState = []): void + public function mount(TeamAB $team, ?int $gameId = null): void { abort_if(is_null($gameId), 404); $this->team = $team; $this->gameId = $gameId; - $this->gameState = $gameState; $this->lineup = $this->defaultLineup(); } @@ -242,27 +238,22 @@ private function upcomingSetNumber(): int private function currentSetNumber(): int { - $setNumber = $this->gameState['set_number'] ?? 0; - - if (is_int($setNumber)) { - return $setNumber; - } - - return is_numeric($setNumber) ? (int) $setNumber : 0; + return $this->resolvedGameState()->setNumber; } private function isSetInProgress(): bool { - $setInProgress = $this->gameState['set_in_progress'] ?? false; - - return (bool) $setInProgress; + return $this->resolvedGameState()->setInProgress; } private function isGameEnded(): bool { - $gameEnded = $this->gameState['game_ended'] ?? false; + return $this->resolvedGameState()->gameEnded; + } - return (bool) $gameEnded; + private function resolvedGameState(): GameState + { + return $this->gameState ?? GameState::initial(); } /** diff --git a/app/Livewire/TossResultSubmission.php b/app/Livewire/TossResultSubmission.php index 5c1bac0..9188989 100644 --- a/app/Livewire/TossResultSubmission.php +++ b/app/Livewire/TossResultSubmission.php @@ -4,6 +4,7 @@ namespace App\Livewire; +use App\Data\GameState\GameState; use App\Enums\GameEventType; use App\Enums\TeamAB; use App\Enums\TeamSide; @@ -23,21 +24,16 @@ class TossResultSubmission extends Component #[Locked] public ?int $gameId = null; - /** @var array */ #[Reactive] - public array $gameState = []; + public ?GameState $gameState = null; public string $teamA = TeamSide::Home->value; public string $serving = TeamAB::TeamA->value; - /** - * @param array $gameState - */ - public function mount(?int $gameId = null, array $gameState = []): void + public function mount(?int $gameId = null): void { $this->gameId = $gameId; - $this->gameState = $gameState; } /** diff --git a/resources/views/livewire/game.blade.php b/resources/views/livewire/game.blade.php index 9b21dfa..d2477e6 100644 --- a/resources/views/livewire/game.blade.php +++ b/resources/views/livewire/game.blade.php @@ -1,11 +1,4 @@
- @php - $setsWonTeamA = is_numeric($gameState['sets_won_team_a'] ?? null) ? (int) $gameState['sets_won_team_a'] : 0; - $setsWonTeamB = is_numeric($gameState['sets_won_team_b'] ?? null) ? (int) $gameState['sets_won_team_b'] : 0; - $scoreTeamA = is_numeric($gameState['score_team_a'] ?? null) ? (int) $gameState['score_team_a'] : 0; - $scoreTeamB = is_numeric($gameState['score_team_b'] ?? null) ? (int) $gameState['score_team_b'] : 0; - @endphp -
Team B
- {{ $setsWonTeamA }} + {{ $gameState->setsWonTeamA }} : - {{ $setsWonTeamB }} + {{ $gameState->setsWonTeamB }}
@@ -29,9 +22,9 @@ class="absolute top-6 left-1/2 z-20 w-[300px] -translate-x-1/2 rounded-xl border Team B
- {{ $scoreTeamA }} + {{ $gameState->scoreTeamA }} : - {{ $scoreTeamB }} + {{ $gameState->scoreTeamB }}
diff --git a/tests/Feature/CourtTest.php b/tests/Feature/CourtTest.php index c82a036..0ce740f 100644 --- a/tests/Feature/CourtTest.php +++ b/tests/Feature/CourtTest.php @@ -2,6 +2,7 @@ declare(strict_types=1); +use App\Data\GameState\GameState; use App\Enums\TeamAB; use App\Enums\TeamSide; use App\Livewire\Court; @@ -16,7 +17,7 @@ test('court shows team a on the left and team b on the right before toss', function (): void { $game = gameWithNumberedRosters(); - Livewire::test(Court::class, ['gameId' => $game->getKey(), 'gameState' => ['set_number' => 0]]) + Livewire::test(Court::class, ['gameId' => $game->getKey(), 'gameState' => gameState(['set_number' => 0])]) ->assertSeeInOrder([ 'Team A', 'Anderson 3', @@ -37,7 +38,7 @@ $game = gameWithNumberedRosters(); $game->recordToss(TeamSide::Home, TeamAB::TeamA); - Livewire::test(Court::class, ['gameId' => $game->getKey(), 'gameState' => ['set_number' => 2]]) + Livewire::test(Court::class, ['gameId' => $game->getKey(), 'gameState' => gameState(['set_number' => 2])]) ->assertSeeInOrder([ 'Team B', 'Baker 2', @@ -47,7 +48,7 @@ '12 Zephyr', ]); - Livewire::test(Court::class, ['gameId' => $game->getKey(), 'gameState' => ['set_number' => 3]]) + Livewire::test(Court::class, ['gameId' => $game->getKey(), 'gameState' => gameState(['set_number' => 3])]) ->assertSeeInOrder([ 'Team A', 'Anderson 3', @@ -57,7 +58,7 @@ '9 Young', ]); - Livewire::test(Court::class, ['gameId' => $game->getKey(), 'gameState' => ['set_number' => 4]]) + Livewire::test(Court::class, ['gameId' => $game->getKey(), 'gameState' => gameState(['set_number' => 4])]) ->assertSeeInOrder([ 'Team B', 'Baker 2', @@ -108,7 +109,7 @@ ]; foreach ($setExpectations as $setNumber => $expectedVisibleText) { - Livewire::test(Court::class, ['gameId' => $game->getKey(), 'gameState' => ['set_number' => $setNumber]]) + Livewire::test(Court::class, ['gameId' => $game->getKey(), 'gameState' => gameState(['set_number' => $setNumber])]) ->assertSeeInOrder($expectedVisibleText); } }); @@ -117,7 +118,7 @@ $game = gameWithNumberedRosters(); $game->recordToss(TeamSide::Away, TeamAB::TeamA); - Livewire::test(Court::class, ['gameId' => $game->getKey(), 'gameState' => ['set_number' => 1]]) + Livewire::test(Court::class, ['gameId' => $game->getKey(), 'gameState' => gameState(['set_number' => 1])]) ->assertSeeInOrder([ 'Team B', 'Anderson 3', @@ -127,7 +128,7 @@ '9 Young', ]); - Livewire::test(Court::class, ['gameId' => $game->getKey(), 'gameState' => ['set_number' => 5]]) + Livewire::test(Court::class, ['gameId' => $game->getKey(), 'gameState' => gameState(['set_number' => 5])]) ->assertSeeInOrder([ 'Team B', 'Anderson 3', @@ -144,11 +145,11 @@ Livewire::test(Court::class, [ 'gameId' => $game->getKey(), - 'gameState' => [ + 'gameState' => gameState([ 'set_number' => 1, 'rotation_team_a' => [1 => 12], 'rotation_team_b' => [1 => 9], - ], + ]), ]) ->assertSeeHtml('data-court-marker="left-team_a-1"') ->assertSeeHtml('data-court-marker="right-team_b-1"') @@ -162,11 +163,11 @@ Livewire::test(Court::class, [ 'gameId' => $game->getKey(), - 'gameState' => [ + 'gameState' => gameState([ 'set_number' => 2, 'rotation_team_a' => [1 => 12], 'rotation_team_b' => [1 => 9], - ], + ]), ]) ->assertSeeHtml('data-court-marker="left-team_b-1"') ->assertSeeHtml('data-court-marker="right-team_a-1"') @@ -197,3 +198,11 @@ function gameWithNumberedRosters(): Game return $game; } + +/** + * @param array $attributes + */ +function gameState(array $attributes): GameState +{ + return GameState::fromAttributes($attributes); +} diff --git a/tests/Feature/GameComponentStateTest.php b/tests/Feature/GameComponentStateTest.php index 988d85b..9153f26 100644 --- a/tests/Feature/GameComponentStateTest.php +++ b/tests/Feature/GameComponentStateTest.php @@ -2,6 +2,7 @@ declare(strict_types=1); +use App\Data\GameState\GameState; use App\Enums\TeamAB; use App\Enums\TeamSide; use App\Livewire\Game; @@ -17,10 +18,10 @@ Livewire::test(Game::class, ['game' => $game]) ->assertSet('gameId', $game->getKey()) - ->assertSet('gameState.serving_team', TeamAB::TeamB->value) - ->assertSet('gameState.set_number', 0) - ->assertSet('gameState.rotation_team_a', []) - ->assertSet('gameState.rotation_team_b', []); + ->assertSet('gameState', fn (GameState $gameState): bool => $gameState->servingTeam === TeamAB::TeamB + && $gameState->setNumber === 0 + && $gameState->rotationTeamA === [] + && $gameState->rotationTeamB === []); }); test('game component uses the passed game id instead of the latest game', function (): void { @@ -32,7 +33,7 @@ Livewire::test(Game::class, ['game' => $targetGame]) ->assertSet('gameId', $targetGame->getKey()) - ->assertSet('gameState.serving_team', TeamAB::TeamA->value); + ->assertSet('gameState', fn (GameState $gameState): bool => $gameState->servingTeam === TeamAB::TeamA); }); test('game component renders sets and current set points for both teams', function (): void { @@ -55,10 +56,10 @@ } Livewire::test(Game::class, ['game' => $game]) - ->assertSet('gameState.sets_won_team_a', 1) - ->assertSet('gameState.sets_won_team_b', 0) - ->assertSet('gameState.score_team_a', 7) - ->assertSet('gameState.score_team_b', 3) + ->assertSet('gameState', fn (GameState $gameState): bool => $gameState->setsWonTeamA === 1 + && $gameState->setsWonTeamB === 0 + && $gameState->scoreTeamA === 7 + && $gameState->scoreTeamB === 3) ->assertSee('Sets') ->assertSee('Points') ->assertSeeHtml('data-scoreboard-sets-team-a') diff --git a/tests/Feature/LineupSubmissionTest.php b/tests/Feature/LineupSubmissionTest.php index d359a10..73f1881 100644 --- a/tests/Feature/LineupSubmissionTest.php +++ b/tests/Feature/LineupSubmissionTest.php @@ -2,6 +2,7 @@ declare(strict_types=1); +use App\Data\GameState\GameState; use App\Enums\GameEventType; use App\Enums\TeamAB; use App\Enums\TeamSide; @@ -174,14 +175,14 @@ function prepareGameForLineupSubmission(): Game Livewire::test(LineupSubmission::class, [ 'team' => TeamAB::TeamA, 'gameId' => $game->getKey(), - 'gameState' => [ + 'gameState' => GameState::fromAttributes([ 'set_number' => 2, 'serving_team' => TeamAB::TeamB->value, - ], + ]), ]) ->assertSet('gameId', $game->getKey()) - ->assertSet('gameState.set_number', 2) - ->assertSet('gameState.serving_team', TeamAB::TeamB->value); + ->assertSet('gameState', fn (GameState $gameState): bool => $gameState->setNumber === 2 + && $gameState->servingTeam === TeamAB::TeamB); }); test('lineup submission button is hidden after the lineup is already submitted for the same team and upcoming set', function (): void { @@ -191,7 +192,7 @@ function prepareGameForLineupSubmission(): Game Livewire::test(LineupSubmission::class, [ 'team' => TeamAB::TeamA, 'gameId' => $game->getKey(), - 'gameState' => $game->stateAt()->toAttributes(), + 'gameState' => $game->stateAt(), ]) ->assertDontSee('Submit Lineup') ->assertDontSee('Team A Lineup'); @@ -204,7 +205,7 @@ function prepareGameForLineupSubmission(): Game Livewire::test(LineupSubmission::class, [ 'team' => TeamAB::TeamB, 'gameId' => $game->getKey(), - 'gameState' => $game->stateAt()->toAttributes(), + 'gameState' => $game->stateAt(), ]) ->assertSee('Submit Lineup') ->assertSee('Team B Lineup'); From 848601c21a1461a502316733ccbdae2ba794869c Mon Sep 17 00:00:00 2001 From: Giulio Troccoli-Allard Date: Mon, 23 Mar 2026 19:31:47 +0000 Subject: [PATCH 10/63] refactor: extract scoreboard to its own component --- app/Livewire/Scoreboard.php | 23 +++++++++++++++ resources/views/livewire/game.blade.php | 28 +------------------ resources/views/livewire/scoreboard.blade.php | 27 ++++++++++++++++++ tests/Feature/GameComponentStateTest.php | 4 --- 4 files changed, 51 insertions(+), 31 deletions(-) create mode 100644 app/Livewire/Scoreboard.php create mode 100644 resources/views/livewire/scoreboard.blade.php diff --git a/app/Livewire/Scoreboard.php b/app/Livewire/Scoreboard.php new file mode 100644 index 0000000..e731757 --- /dev/null +++ b/app/Livewire/Scoreboard.php @@ -0,0 +1,23 @@ + $this->gameState ?? GameState::initial(), + ]); + } +} diff --git a/resources/views/livewire/game.blade.php b/resources/views/livewire/game.blade.php index d2477e6..4e92a1b 100644 --- a/resources/views/livewire/game.blade.php +++ b/resources/views/livewire/game.blade.php @@ -1,32 +1,6 @@
-
-
- Team A - Sets - Team B -
-
- {{ $gameState->setsWonTeamA }} - : - {{ $gameState->setsWonTeamB }} -
- -
- Team A - Points - Team B -
-
- {{ $gameState->scoreTeamA }} - : - {{ $gameState->scoreTeamB }} -
-
+
diff --git a/resources/views/livewire/scoreboard.blade.php b/resources/views/livewire/scoreboard.blade.php new file mode 100644 index 0000000..292a2b3 --- /dev/null +++ b/resources/views/livewire/scoreboard.blade.php @@ -0,0 +1,27 @@ +
+
+ Team A + Sets + Team B +
+
+ {{ $scoreboardState->setsWonTeamA }} + : + {{ $scoreboardState->setsWonTeamB }} +
+ +
+ Team A + Points + Team B +
+
+ {{ $scoreboardState->scoreTeamA }} + : + {{ $scoreboardState->scoreTeamB }} +
+
diff --git a/tests/Feature/GameComponentStateTest.php b/tests/Feature/GameComponentStateTest.php index 9153f26..196518d 100644 --- a/tests/Feature/GameComponentStateTest.php +++ b/tests/Feature/GameComponentStateTest.php @@ -62,10 +62,6 @@ && $gameState->scoreTeamB === 3) ->assertSee('Sets') ->assertSee('Points') - ->assertSeeHtml('data-scoreboard-sets-team-a') - ->assertSeeHtml('data-scoreboard-sets-team-b') - ->assertSeeHtml('data-scoreboard-points-team-a') - ->assertSeeHtml('data-scoreboard-points-team-b') ->assertSeeInOrder([ 'Sets', '1', From d655b3b1cc6bf230d04f95547e33feb8d5e8923a Mon Sep 17 00:00:00 2001 From: Giulio Troccoli-Allard Date: Wed, 25 Mar 2026 19:20:23 +0000 Subject: [PATCH 11/63] feat: add serving player on court --- app/Data/GameState/GameState.php | 5 +-- app/Livewire/Court.php | 3 ++ resources/views/livewire/court.blade.php | 34 +++++++++++++++++--- tests/Feature/CourtTest.php | 40 ++++++++++++++++++++++++ 4 files changed, 73 insertions(+), 9 deletions(-) diff --git a/app/Data/GameState/GameState.php b/app/Data/GameState/GameState.php index 6b62cb1..832c1c2 100644 --- a/app/Data/GameState/GameState.php +++ b/app/Data/GameState/GameState.php @@ -125,10 +125,7 @@ public function toLivewire(): array return $this->toAttributes(); } - /** - * @param mixed $value - */ - public static function fromLivewire($value): self + public static function fromLivewire(mixed $value): self { return is_array($value) ? self::fromAttributes($value) : self::initial(); } diff --git a/app/Livewire/Court.php b/app/Livewire/Court.php index 51343b8..029dd71 100644 --- a/app/Livewire/Court.php +++ b/app/Livewire/Court.php @@ -37,6 +37,7 @@ public function render(): View * @return array{ * leftTeam: TeamAB, * rightTeam: TeamAB, + * servingTeam: TeamAB|null, * leftRotation: array, * rightRotation: array * } @@ -46,6 +47,7 @@ private function courtContext(): array $defaultContext = [ 'leftTeam' => TeamAB::TeamA, 'rightTeam' => TeamAB::TeamB, + 'servingTeam' => $this->resolvedGameState()->servingTeam, 'leftRotation' => $this->rotationForTeam(TeamAB::TeamA), 'rightRotation' => $this->rotationForTeam(TeamAB::TeamB), ]; @@ -67,6 +69,7 @@ private function courtContext(): array return [ 'leftTeam' => TeamAB::TeamB, 'rightTeam' => TeamAB::TeamA, + 'servingTeam' => $this->resolvedGameState()->servingTeam, 'leftRotation' => $this->rotationForTeam(TeamAB::TeamB), 'rightRotation' => $this->rotationForTeam(TeamAB::TeamA), ]; diff --git a/resources/views/livewire/court.blade.php b/resources/views/livewire/court.blade.php index fa0bb20..9cb0cfb 100644 --- a/resources/views/livewire/court.blade.php +++ b/resources/views/livewire/court.blade.php @@ -1,4 +1,4 @@ -
+
@php $leftCourtPositionClasses = [ 1 => 'left-[12%] bottom-[14%]', @@ -18,11 +18,19 @@ 6 => 'right-[12%] top-1/2 -translate-y-1/2', ]; + $leftOutsidePositionClasses = [ + 1 => '-left-10 bottom-[14%]', + ]; + + $rightOutsidePositionClasses = [ + 1 => '-right-10 top-[14%]', + ]; + $leftMarkerTone = $leftTeam === \App\Enums\TeamAB::TeamA ? 'bg-blue-600' : 'bg-red-600'; $rightMarkerTone = $rightTeam === \App\Enums\TeamAB::TeamA ? 'bg-blue-600' : 'bg-red-600'; @endphp -
+
@foreach ($leftRotation as $position => $number) + @php + $isServingPlayer = $position === 1 && $servingTeam === $leftTeam; + $markerPositionClass = $isServingPlayer + ? ($leftOutsidePositionClasses[$position] ?? $leftCourtPositionClasses[$position] ?? '') + : ($leftCourtPositionClasses[$position] ?? ''); + @endphp
{{ $number }}
@endforeach @foreach ($rightRotation as $position => $number) + @php + $isServingPlayer = $position === 1 && $servingTeam === $rightTeam; + $markerPositionClass = $isServingPlayer + ? ($rightOutsidePositionClasses[$position] ?? $rightCourtPositionClasses[$position] ?? '') + : ($rightCourtPositionClasses[$position] ?? ''); + @endphp
{{ $number }}
@@ -72,6 +94,7 @@ class="absolute {{ $rightCourtPositionClasses[$position] ?? '' }} flex h-8 w-8 i :key="'team-roster-right'" />
+
+ :key="'lineup-submission-team-b'" + />
diff --git a/tests/Feature/CourtTest.php b/tests/Feature/CourtTest.php index 0ce740f..3413c88 100644 --- a/tests/Feature/CourtTest.php +++ b/tests/Feature/CourtTest.php @@ -175,6 +175,46 @@ ->assertSeeHtml('right-[12%] top-[14%]'); }); +test('court shows serving team position one outside the court on the left side', function (): void { + $game = gameWithNumberedRosters(); + $game->recordToss(TeamSide::Home, TeamAB::TeamA); + + Livewire::test(Court::class, [ + 'gameId' => $game->getKey(), + 'gameState' => gameState([ + 'set_number' => 1, + 'serving_team' => TeamAB::TeamA->value, + 'rotation_team_a' => [1 => 12], + 'rotation_team_b' => [1 => 9], + ]), + ]) + ->assertSeeHtml('data-court-marker="left-team_a-1"') + ->assertSeeHtml('data-court-serving-player="1"') + ->assertSeeHtml('-left-10 bottom-[14%]') + ->assertSeeHtml('data-court-marker="right-team_b-1"') + ->assertSeeHtml('right-[12%] top-[14%]'); +}); + +test('court shows serving team position one outside the court after side swap', function (): void { + $game = gameWithNumberedRosters(); + $game->recordToss(TeamSide::Home, TeamAB::TeamA); + + Livewire::test(Court::class, [ + 'gameId' => $game->getKey(), + 'gameState' => gameState([ + 'set_number' => 2, + 'serving_team' => TeamAB::TeamA->value, + 'rotation_team_a' => [1 => 12], + 'rotation_team_b' => [1 => 9], + ]), + ]) + ->assertSeeHtml('data-court-marker="right-team_a-1"') + ->assertSeeHtml('data-court-serving-player="1"') + ->assertSeeHtml('-right-10 top-[14%]') + ->assertSeeHtml('data-court-marker="left-team_b-1"') + ->assertSeeHtml('left-[12%] bottom-[14%]'); +}); + function gameWithNumberedRosters(): Game { $homeTeam = Team::factory()->create(); From 82ea95be3ac27a8de713d939a5ed5b40f38b91ea Mon Sep 17 00:00:00 2001 From: Giulio Troccoli-Allard Date: Wed, 25 Mar 2026 19:50:09 +0000 Subject: [PATCH 12/63] feat: add button to start set and some refactoring --- app/Livewire/Court.php | 6 +- app/Livewire/StartSetSubmission.php | 140 ++++++++++++++++++ resources/views/livewire/court.blade.php | 2 +- resources/views/livewire/game.blade.php | 6 +- .../livewire/start-set-submission.blade.php | 19 +++ tests/Feature/ExampleTest.php | 29 ++++ tests/Feature/StartSetSubmissionTest.php | 103 +++++++++++++ 7 files changed, 295 insertions(+), 10 deletions(-) create mode 100644 app/Livewire/StartSetSubmission.php create mode 100644 resources/views/livewire/start-set-submission.blade.php create mode 100644 tests/Feature/StartSetSubmissionTest.php diff --git a/app/Livewire/Court.php b/app/Livewire/Court.php index 029dd71..313da07 100644 --- a/app/Livewire/Court.php +++ b/app/Livewire/Court.php @@ -52,11 +52,7 @@ private function courtContext(): array 'rightRotation' => $this->rotationForTeam(TeamAB::TeamB), ]; - if ($this->gameId === null) { - return $defaultContext; - } - - $game = Game::query()->find($this->gameId); + $game = $this->gameId === null ? null : Game::query()->find($this->gameId); if ($game === null) { return $defaultContext; diff --git a/app/Livewire/StartSetSubmission.php b/app/Livewire/StartSetSubmission.php new file mode 100644 index 0000000..92e7d41 --- /dev/null +++ b/app/Livewire/StartSetSubmission.php @@ -0,0 +1,140 @@ +gameId = $gameId; + } + + public function startSet(): void + { + $this->resetValidation('startSet'); + + $activeGame = $this->activeGame(); + + if ($activeGame === null) { + $this->addError('startSet', 'No active game is available to start the set.'); + + return; + } + + if (! $this->bothLineupsSubmittedForUpcomingSet($activeGame)) { + $this->addError('startSet', 'Both team lineups must be submitted before starting the set.'); + + return; + } + + try { + $activeGame->recordSetStarted(); + } catch (InvalidGameEventTransition $exception) { + $this->addError('startSet', $exception->getMessage()); + + return; + } + + $this->dispatch('game-event-recorded'); + } + + public function render(): View + { + return view('livewire.start-set-submission', [ + 'canStartSet' => $this->canStartSet(), + 'upcomingSetNumber' => $this->upcomingSetNumber(), + ]); + } + + private function canStartSet(): bool + { + $activeGame = $this->activeGame(); + + if ($activeGame === null) { + return false; + } + + $activeGameState = $activeGame->stateAt(); + + if ($activeGameState->setInProgress || $activeGameState->gameEnded) { + return false; + } + + return $this->bothLineupsSubmittedForSet( + game: $activeGame, + setNumber: $activeGameState->setNumber + 1, + ); + } + + private function activeGame(): ?Game + { + if ($this->gameId === null) { + return null; + } + + return Game::query()->whereKey($this->gameId)->first(); + } + + private function bothLineupsSubmittedForUpcomingSet(Game $game): bool + { + return $this->bothLineupsSubmittedForSet( + game: $game, + setNumber: $this->upcomingSetNumberForGame($game), + ); + } + + private function bothLineupsSubmittedForSet(Game $game, int $setNumber): bool + { + return $this->hasSubmittedLineupForSet($game, TeamAB::TeamA, $setNumber) + && $this->hasSubmittedLineupForSet($game, TeamAB::TeamB, $setNumber); + } + + private function hasSubmittedLineupForSet(Game $game, TeamAB $team, int $setNumber): bool + { + return $game->events() + ->where('type', GameEventType::LineupSubmitted) + ->where('payload->set', $setNumber) + ->where('payload->team', $team->value) + ->exists(); + } + + private function upcomingSetNumber(): int + { + $activeGame = $this->activeGame(); + + if ($activeGame === null) { + return $this->resolvedGameState()->setNumber + 1; + } + + return $this->upcomingSetNumberForGame($activeGame); + } + + private function upcomingSetNumberForGame(Game $game): int + { + return $game->stateAt()->setNumber + 1; + } + + private function resolvedGameState(): GameState + { + return $this->gameState ?? GameState::initial(); + } +} diff --git a/resources/views/livewire/court.blade.php b/resources/views/livewire/court.blade.php index 9cb0cfb..03828fd 100644 --- a/resources/views/livewire/court.blade.php +++ b/resources/views/livewire/court.blade.php @@ -1,4 +1,4 @@ -
+
@php $leftCourtPositionClasses = [ 1 => 'left-[12%] bottom-[14%]', diff --git a/resources/views/livewire/game.blade.php b/resources/views/livewire/game.blade.php index 4e92a1b..55d60d3 100644 --- a/resources/views/livewire/game.blade.php +++ b/resources/views/livewire/game.blade.php @@ -1,10 +1,8 @@
- -
- -
+ +
diff --git a/resources/views/livewire/start-set-submission.blade.php b/resources/views/livewire/start-set-submission.blade.php new file mode 100644 index 0000000..b1b7cb8 --- /dev/null +++ b/resources/views/livewire/start-set-submission.blade.php @@ -0,0 +1,19 @@ +
+ @if ($canStartSet) +
+ + Start Set {{ $upcomingSetNumber }} + + + @error('startSet') + {{ $message }} + @enderror +
+ @endif +
diff --git a/tests/Feature/ExampleTest.php b/tests/Feature/ExampleTest.php index db5428c..bef5096 100644 --- a/tests/Feature/ExampleTest.php +++ b/tests/Feature/ExampleTest.php @@ -2,8 +2,12 @@ declare(strict_types=1); +use App\Enums\TeamAB; +use App\Enums\TeamSide; use App\Livewire\Game; use App\Models\Game as GameModel; +use App\Models\Player; +use App\Models\Team; use Illuminate\Foundation\Testing\RefreshDatabase; use Livewire\Livewire; @@ -29,3 +33,28 @@ ->assertSeeHtml('id="game-canvas"') ->assertSeeHtml('id="volleyball-court"'); }); + +test('the game page renders the start set button when both lineups are submitted for the upcoming set', function (): void { + $homeTeam = Team::factory()->create(); + $awayTeam = Team::factory()->create(); + $game = GameModel::factory()->betweenTeams($homeTeam, $awayTeam)->create(); + + $homePlayers = Player::factory()->for($homeTeam)->count(6)->create(); + foreach ($homePlayers as $index => $player) { + $game->addPlayer($player, number: $index + 1); + } + + $awayPlayers = Player::factory()->for($awayTeam)->count(6)->create(); + foreach ($awayPlayers as $index => $player) { + $game->addPlayer($player, number: $index + 11); + } + + $game->recordToss(TeamSide::Home, TeamAB::TeamA); + $game->recordLineup(1, TeamAB::TeamA, [1 => 1, 2 => 2, 3 => 3, 4 => 4, 5 => 5, 6 => 6]); + $game->recordLineup(1, TeamAB::TeamB, [1 => 11, 2 => 12, 3 => 13, 4 => 14, 5 => 15, 6 => 16]); + + $response = $this->get(route('game', ['game' => $game])); + + $response->assertSuccessful() + ->assertSee('Start Set 1'); +}); diff --git a/tests/Feature/StartSetSubmissionTest.php b/tests/Feature/StartSetSubmissionTest.php new file mode 100644 index 0000000..134d3cf --- /dev/null +++ b/tests/Feature/StartSetSubmissionTest.php @@ -0,0 +1,103 @@ +recordLineup(1, TeamAB::TeamA, lineupPositionsForNumbers(1)); + + Livewire::test(StartSetSubmission::class, ['gameId' => $game->getKey(), 'gameState' => $game->stateAt()]) + ->assertDontSee('Start Set 1'); + + $game->recordLineup(1, TeamAB::TeamB, lineupPositionsForNumbers(11)); + + Livewire::test(StartSetSubmission::class, ['gameId' => $game->getKey(), 'gameState' => $game->stateAt()]) + ->assertSee('Start Set 1'); +}); + +test('start set button records set started event and dispatches refresh event', function (): void { + $game = gameReadyToStartSet(); + $game->recordLineup(1, TeamAB::TeamA, lineupPositionsForNumbers(1)); + $game->recordLineup(1, TeamAB::TeamB, lineupPositionsForNumbers(11)); + + Livewire::test(StartSetSubmission::class, ['gameId' => $game->getKey(), 'gameState' => $game->stateAt()]) + ->assertSee('Start Set 1') + ->call('startSet') + ->assertHasNoErrors() + ->assertDispatched('game-event-recorded') + ->assertDontSee('Start Set 1'); + + $freshGame = $game->fresh(); + $latestEvent = $freshGame->events->last(); + + expect($latestEvent)->not->toBeNull() + ->and($latestEvent->type)->toBe(GameEventType::SetStarted) + ->and($freshGame->stateAt()->setNumber)->toBe(1) + ->and($freshGame->stateAt()->setInProgress)->toBeTrue(); +}); + +test('start set button label uses the next dynamic set number', function (): void { + $game = gameReadyToStartSet(); + $game->recordLineup(1, TeamAB::TeamA, lineupPositionsForNumbers(1)); + $game->recordLineup(1, TeamAB::TeamB, lineupPositionsForNumbers(11)); + $game->recordSetStarted(); + + for ($i = 0; $i < 25; $i++) { + $game->recordRallyWinner(TeamAB::TeamA); + } + + $game->recordLineup(2, TeamAB::TeamA, lineupPositionsForNumbers(1)); + $game->recordLineup(2, TeamAB::TeamB, lineupPositionsForNumbers(11)); + + Livewire::test(StartSetSubmission::class, ['gameId' => $game->getKey(), 'gameState' => $game->stateAt()]) + ->assertSee('Start Set 2'); +}); + +/** + * @return array + */ +function lineupPositionsForNumbers(int $start): array +{ + return [ + 1 => $start, + 2 => $start + 1, + 3 => $start + 2, + 4 => $start + 3, + 5 => $start + 4, + 6 => $start + 5, + ]; +} + +function gameReadyToStartSet(): Game +{ + $homeTeam = Team::factory()->create(); + $awayTeam = Team::factory()->create(); + $game = Game::factory()->betweenTeams($homeTeam, $awayTeam)->create(); + + $homePlayers = Player::factory()->for($homeTeam)->count(6)->create(); + foreach ($homePlayers as $index => $player) { + $game->addPlayer($player, number: $index + 1); + } + + $awayPlayers = Player::factory()->for($awayTeam)->count(6)->create(); + foreach ($awayPlayers as $index => $player) { + $game->addPlayer($player, number: $index + 11); + } + + $game->recordToss(TeamSide::Home, TeamAB::TeamA); + + return $game; +} From ba3f7ea9d62521bcdf977e073cf50bd0b580fe30 Mon Sep 17 00:00:00 2001 From: Giulio Troccoli-Allard Date: Fri, 27 Mar 2026 18:56:09 +0000 Subject: [PATCH 13/63] feat: add buttons to select the team who won the rally --- app/Livewire/Court.php | 68 ++++++++++++- resources/views/livewire/court.blade.php | 122 ++++++++++++++--------- tests/Feature/CourtTest.php | 53 ++++++++++ 3 files changed, 196 insertions(+), 47 deletions(-) diff --git a/app/Livewire/Court.php b/app/Livewire/Court.php index 313da07..4fa0ea1 100644 --- a/app/Livewire/Court.php +++ b/app/Livewire/Court.php @@ -9,6 +9,7 @@ use App\Enums\TeamAB; use App\Enums\TeamSide; use App\Events\Payloads\TossCompletedPayload; +use App\Exceptions\InvalidGameEventTransition; use App\Models\Game; use App\Models\GameEvent; use Illuminate\Contracts\View\View; @@ -33,27 +34,67 @@ public function render(): View return view('livewire.court', $this->courtContext()); } + public function recordRallyWinner(string $team): void + { + $this->resetValidation('rallyWinner'); + + if (! $this->canRecordRallyWinner()) { + $this->addError('rallyWinner', 'A rally result can only be recorded while a set is in progress.'); + + return; + } + + $winningTeam = TeamAB::tryFrom($team); + + if ($winningTeam === null) { + $this->addError('rallyWinner', 'The selected team is invalid.'); + + return; + } + + $activeGame = $this->activeGame(); + + if ($activeGame === null) { + $this->addError('rallyWinner', 'No active game is available to record the rally winner.'); + + return; + } + + try { + $activeGame->recordRallyWinner($winningTeam); + } catch (InvalidGameEventTransition $exception) { + $this->addError('rallyWinner', $exception->getMessage()); + + return; + } + + $this->dispatch('game-event-recorded'); + } + /** * @return array{ * leftTeam: TeamAB, * rightTeam: TeamAB, * servingTeam: TeamAB|null, + * canRecordRallyWinner: bool, * leftRotation: array, * rightRotation: array * } */ private function courtContext(): array { + $game = $this->activeGame(); + $canRecordRallyWinner = $this->canRecordRallyWinner($game); + $defaultContext = [ 'leftTeam' => TeamAB::TeamA, 'rightTeam' => TeamAB::TeamB, 'servingTeam' => $this->resolvedGameState()->servingTeam, + 'canRecordRallyWinner' => $canRecordRallyWinner, 'leftRotation' => $this->rotationForTeam(TeamAB::TeamA), 'rightRotation' => $this->rotationForTeam(TeamAB::TeamB), ]; - $game = $this->gameId === null ? null : Game::query()->find($this->gameId); - if ($game === null) { return $defaultContext; } @@ -66,6 +107,7 @@ private function courtContext(): array 'leftTeam' => TeamAB::TeamB, 'rightTeam' => TeamAB::TeamA, 'servingTeam' => $this->resolvedGameState()->servingTeam, + 'canRecordRallyWinner' => $canRecordRallyWinner, 'leftRotation' => $this->rotationForTeam(TeamAB::TeamB), 'rightRotation' => $this->rotationForTeam(TeamAB::TeamA), ]; @@ -132,4 +174,26 @@ private function resolvedGameState(): GameState { return $this->gameState ?? GameState::initial(); } + + private function canRecordRallyWinner(?Game $activeGame = null): bool + { + $activeGame ??= $this->activeGame(); + + if ($activeGame === null) { + return false; + } + + $state = $this->resolvedGameState(); + + return $state->setInProgress && ! $state->gameEnded; + } + + private function activeGame(): ?Game + { + if ($this->gameId === null) { + return null; + } + + return Game::query()->whereKey($this->gameId)->first(); + } } diff --git a/resources/views/livewire/court.blade.php b/resources/views/livewire/court.blade.php index 03828fd..c88fde6 100644 --- a/resources/views/livewire/court.blade.php +++ b/resources/views/livewire/court.blade.php @@ -38,54 +38,86 @@ :key="'team-roster-left'" /> -
-
-
-
-
+
+
+
+
+
+
- @foreach ($leftRotation as $position => $number) - @php - $isServingPlayer = $position === 1 && $servingTeam === $leftTeam; - $markerPositionClass = $isServingPlayer - ? ($leftOutsidePositionClasses[$position] ?? $leftCourtPositionClasses[$position] ?? '') - : ($leftCourtPositionClasses[$position] ?? ''); - @endphp -
- {{ $number }} -
- @endforeach + @foreach ($leftRotation as $position => $number) + @php + $isServingPlayer = $position === 1 && $servingTeam === $leftTeam; + $markerPositionClass = $isServingPlayer + ? ($leftOutsidePositionClasses[$position] ?? $leftCourtPositionClasses[$position] ?? '') + : ($leftCourtPositionClasses[$position] ?? ''); + @endphp +
+ {{ $number }} +
+ @endforeach + + @foreach ($rightRotation as $position => $number) + @php + $isServingPlayer = $position === 1 && $servingTeam === $rightTeam; + $markerPositionClass = $isServingPlayer + ? ($rightOutsidePositionClasses[$position] ?? $rightCourtPositionClasses[$position] ?? '') + : ($rightCourtPositionClasses[$position] ?? ''); + @endphp +
+ {{ $number }} +
+ @endforeach +
- @foreach ($rightRotation as $position => $number) - @php - $isServingPlayer = $position === 1 && $servingTeam === $rightTeam; - $markerPositionClass = $isServingPlayer - ? ($rightOutsidePositionClasses[$position] ?? $rightCourtPositionClasses[$position] ?? '') - : ($rightCourtPositionClasses[$position] ?? ''); - @endphp -
- {{ $number }} + @if ($canRecordRallyWinner) +
+ + +
- @endforeach -
+ @endif + + @error('rallyWinner') + {{ $message }} + @enderror +
assertSeeHtml('left-[12%] bottom-[14%]'); }); +test('court shows rally winner buttons only while a set is in progress', function (): void { + $game = Game::factory()->create(); + + Livewire::test(Court::class, [ + 'gameId' => $game->getKey(), + 'gameState' => gameState(['set_number' => 1, 'set_in_progress' => false]), + ]) + ->assertDontSee('Winner') + ->assertDontSeeHtml('data-rally-winner-button="team_a"') + ->assertDontSeeHtml('data-rally-winner-button="team_b"'); + + Livewire::test(Court::class, [ + 'gameId' => $game->getKey(), + 'gameState' => gameState(['set_number' => 1, 'set_in_progress' => true]), + ]) + ->assertSee('Winner') + ->assertSeeHtml('data-rally-winner-button="team_a"') + ->assertSeeHtml('data-rally-winner-button="team_b"'); +}); + +test('court records rally winner for the selected team and dispatches a refresh event', function (): void { + $game = gameWithStartedSet(); + + Livewire::test(Court::class, [ + 'gameId' => $game->getKey(), + 'gameState' => $game->stateAt(), + ]) + ->assertSee('Winner') + ->assertSeeHtml('data-rally-winner-button="team_a"') + ->assertSeeHtml('data-rally-winner-button="team_b"') + ->call('recordRallyWinner', TeamAB::TeamA->value) + ->assertHasNoErrors() + ->assertDispatched('game-event-recorded'); + + $latestEvent = $game->fresh()->events->last(); + + expect($latestEvent)->not->toBeNull() + ->and($latestEvent->type)->toBe(GameEventType::RallyEnded) + ->and($latestEvent->payload)->toBeInstanceOf(RallyEndedPayload::class) + ->and($latestEvent->payload->team)->toBe(TeamAB::TeamA); +}); + function gameWithNumberedRosters(): Game { $homeTeam = Team::factory()->create(); @@ -239,6 +283,15 @@ function gameWithNumberedRosters(): Game return $game; } +function gameWithStartedSet(): Game +{ + $game = Game::factory()->create(); + $game->recordToss(TeamSide::Home, TeamAB::TeamA); + $game->recordSetStarted(); + + return $game; +} + /** * @param array $attributes */ From e0884ebbbdd753fc03e1b59171372722b60be2b5 Mon Sep 17 00:00:00 2001 From: Giulio Troccoli-Allard Date: Fri, 27 Mar 2026 19:32:58 +0000 Subject: [PATCH 14/63] fix: variouos fixes --- app/Livewire/Court.php | 44 ++++------ app/Livewire/LineupSubmission.php | 14 +++ app/Services/CacheRepository.php | 72 ++++++---------- resources/views/livewire/court.blade.php | 32 ++++--- tests/Feature/CacheRepositoryTest.php | 42 ++++----- tests/Feature/CourtTest.php | 103 +++++++++++++++++------ tests/Feature/LineupSubmissionTest.php | 14 ++- tests/Feature/TeamRosterTest.php | 15 ++++ 8 files changed, 195 insertions(+), 141 deletions(-) diff --git a/app/Livewire/Court.php b/app/Livewire/Court.php index 4fa0ea1..66e9e24 100644 --- a/app/Livewire/Court.php +++ b/app/Livewire/Court.php @@ -7,7 +7,6 @@ use App\Data\GameState\GameState; use App\Enums\GameEventType; use App\Enums\TeamAB; -use App\Enums\TeamSide; use App\Events\Payloads\TossCompletedPayload; use App\Exceptions\InvalidGameEventTransition; use App\Models\Game; @@ -76,6 +75,7 @@ public function recordRallyWinner(string $team): void * leftTeam: TeamAB, * rightTeam: TeamAB, * servingTeam: TeamAB|null, + * showRosters: bool, * canRecordRallyWinner: bool, * leftRotation: array, * rightRotation: array @@ -85,11 +85,13 @@ private function courtContext(): array { $game = $this->activeGame(); $canRecordRallyWinner = $this->canRecordRallyWinner($game); + $showRosters = $game !== null && $this->latestTossPayload($game) !== null; $defaultContext = [ 'leftTeam' => TeamAB::TeamA, 'rightTeam' => TeamAB::TeamB, 'servingTeam' => $this->resolvedGameState()->servingTeam, + 'showRosters' => $showRosters, 'canRecordRallyWinner' => $canRecordRallyWinner, 'leftRotation' => $this->rotationForTeam(TeamAB::TeamA), 'rightRotation' => $this->rotationForTeam(TeamAB::TeamB), @@ -99,7 +101,7 @@ private function courtContext(): array return $defaultContext; } - if ($this->isTeamAOnLeft($game)) { + if ($this->isTeamAOnLeft()) { return $defaultContext; } @@ -107,46 +109,25 @@ private function courtContext(): array 'leftTeam' => TeamAB::TeamB, 'rightTeam' => TeamAB::TeamA, 'servingTeam' => $this->resolvedGameState()->servingTeam, + 'showRosters' => $showRosters, 'canRecordRallyWinner' => $canRecordRallyWinner, 'leftRotation' => $this->rotationForTeam(TeamAB::TeamB), 'rightRotation' => $this->rotationForTeam(TeamAB::TeamA), ]; } - private function isTeamAOnLeft(Game $game): bool + private function isTeamAOnLeft(): bool { - $baseTeamAOnLeft = $this->teamASideForTossSets($game) === TeamSide::Home; - $setNumber = $this->setNumber(); + $completedSets = $this->completedSetCount(); - if ($setNumber >= 2 && $setNumber <= 4) { - return $setNumber % 2 === 1 - ? $baseTeamAOnLeft - : ! $baseTeamAOnLeft; - } - - return $baseTeamAOnLeft; - } - - private function teamASideForTossSets(Game $game): TeamSide - { - $tossPayload = $this->latestTossPayload($game); - - if ($tossPayload === null) { - return TeamSide::Home; - } - - return $tossPayload->teamA; - } - - private function setNumber(): int - { - return $this->resolvedGameState()->setNumber; + return $completedSets % 2 === 0; } private function latestTossPayload(Game $game): ?TossCompletedPayload { /** @var GameEvent|null $tossEvent */ $tossEvent = $game->events() + ->reorder() ->where('type', GameEventType::TossCompleted) ->orderByDesc('created_at') ->orderByDesc('id') @@ -175,6 +156,13 @@ private function resolvedGameState(): GameState return $this->gameState ?? GameState::initial(); } + private function completedSetCount(): int + { + $state = $this->resolvedGameState(); + + return $state->setsWonTeamA + $state->setsWonTeamB; + } + private function canRecordRallyWinner(?Game $activeGame = null): bool { $activeGame ??= $this->activeGame(); diff --git a/app/Livewire/LineupSubmission.php b/app/Livewire/LineupSubmission.php index 6457158..1df690a 100644 --- a/app/Livewire/LineupSubmission.php +++ b/app/Livewire/LineupSubmission.php @@ -14,6 +14,7 @@ use App\Models\GameEvent; use Flux\Flux; use Illuminate\Contracts\View\View; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\Facades\Validator; use InvalidArgumentException; use Livewire\Attributes\Locked; @@ -148,6 +149,7 @@ private function eligibleRosterNumbers(Game $game): array { /** @var GameEvent|null $tossEvent */ $tossEvent = $game->events() + ->reorder() ->where('type', GameEventType::TossCompleted) ->orderByDesc('created_at') ->orderByDesc('id') @@ -209,6 +211,10 @@ private function validateLineup(): bool private function canSubmitLineup(): bool { + if (! $this->hasSubmittedToss()) { + return false; + } + if ($this->isSetInProgress() || $this->isGameEnded()) { return false; } @@ -231,6 +237,14 @@ private function hasSubmittedLineupForUpcomingSet(): bool ->exists(); } + private function hasSubmittedToss(): bool + { + return Game::query() + ->whereKey($this->gameId) + ->whereHas('events', fn (Builder $query) => $query->where('type', GameEventType::TossCompleted)) + ->exists(); + } + private function upcomingSetNumber(): int { return $this->currentSetNumber() + 1; diff --git a/app/Services/CacheRepository.php b/app/Services/CacheRepository.php index 19411bc..e28b6a2 100644 --- a/app/Services/CacheRepository.php +++ b/app/Services/CacheRepository.php @@ -10,14 +10,9 @@ use App\Models\Game; use App\Models\GameEvent; use App\Models\Player; -use Illuminate\Support\Facades\Cache; class CacheRepository { - private const int PLAYERS_FOR_SIDE_CACHE_SECONDS = 60; - - private const int LATEST_TOSS_PAYLOAD_CACHE_SECONDS = 60; - /** * @return arraygetKey(), $side->value), - now()->addSeconds(self::PLAYERS_FOR_SIDE_CACHE_SECONDS), - function () use ($game, $side): array { - $players = $side === TeamSide::Home - ? $game->homePlayers() - : $game->awayPlayers(); - - return $players - ->wherePivot('is_libero', false) - ->orderByPivot('number') - ->get() - ->map(fn (Player $player): array => [ - 'player_key' => $player->getKey(), - 'number' => $player->roster->number, - 'last_name' => $player->last_name, - ]) - ->all(); - } - ); + $players = $side === TeamSide::Home + ? $game->homePlayers() + : $game->awayPlayers(); + + return $players + ->wherePivot('is_libero', false) + ->orderByPivot('number') + ->get() + ->map(fn (Player $player): array => [ + 'player_key' => $player->getKey(), + 'number' => $player->roster->number, + 'last_name' => $player->last_name, + ]) + ->all(); } public function latestTossPayload(Game $game): ?TossCompletedPayload { - return Cache::remember( - sprintf('game:%d:events:latest-toss-payload', $game->getKey()), - now()->addSeconds(self::LATEST_TOSS_PAYLOAD_CACHE_SECONDS), - function () use ($game): ?TossCompletedPayload { - /** @var GameEvent|null $tossEvent */ - $tossEvent = $game->events() - ->where('type', GameEventType::TossCompleted) - ->orderByDesc('created_at') - ->orderByDesc('id') - ->first(); - - if ($tossEvent === null) { - return null; - } - - /** @var TossCompletedPayload */ - return $tossEvent->payload; - } - ); + /** @var GameEvent|null $tossEvent */ + $tossEvent = $game->events() + ->reorder() + ->where('type', GameEventType::TossCompleted) + ->orderByDesc('created_at') + ->orderByDesc('id') + ->first(); + + if ($tossEvent === null) { + return null; + } + + /** @var TossCompletedPayload */ + return $tossEvent->payload; } } diff --git a/resources/views/livewire/court.blade.php b/resources/views/livewire/court.blade.php index c88fde6..01419d3 100644 --- a/resources/views/livewire/court.blade.php +++ b/resources/views/livewire/court.blade.php @@ -31,12 +31,16 @@ @endphp
- + @if ($showRosters) + + @else +
+ @endif
+ @if ($showRosters) + + @else +
+ @endif
diff --git a/tests/Feature/CacheRepositoryTest.php b/tests/Feature/CacheRepositoryTest.php index b008040..e3effe2 100644 --- a/tests/Feature/CacheRepositoryTest.php +++ b/tests/Feature/CacheRepositoryTest.php @@ -11,13 +11,10 @@ use App\Models\Team; use App\Services\CacheRepository; use Illuminate\Foundation\Testing\RefreshDatabase; -use Illuminate\Support\Facades\Cache; uses(RefreshDatabase::class); -test('players for side are cached per game and side', function (): void { - Cache::flush(); - +test('players for side returns non-libero players for each side', function (): void { $game = gameWithNumberedRostersForCacheRepository(); $repository = app(CacheRepository::class); @@ -27,31 +24,25 @@ expect(collect($homePlayers)->pluck('number')->all())->toBe([3, 12]) ->and(collect($homePlayers)->pluck('last_name')->all())->toBe(['Anderson', 'Zephyr']) ->and(collect($awayPlayers)->pluck('number')->all())->toBe([2, 9]) - ->and(collect($awayPlayers)->pluck('last_name')->all())->toBe(['Baker', 'Young']) - ->and(Cache::has(sprintf('game:%d:roster:players-for-side:%s', $game->getKey(), TeamSide::Home->value)))->toBeTrue() - ->and(Cache::has(sprintf('game:%d:roster:players-for-side:%s', $game->getKey(), TeamSide::Away->value)))->toBeTrue(); + ->and(collect($awayPlayers)->pluck('last_name')->all())->toBe(['Baker', 'Young']); }); -test('players for side reuses cached value for repeated reads', function (): void { - Cache::flush(); - +test('players for side reflects roster changes on repeated reads', function (): void { $game = gameWithNumberedRostersForCacheRepository(); $repository = app(CacheRepository::class); - $cachedPlayers = $repository->playersForSide($game, TeamSide::Home); + $playersBeforeRosterChange = $repository->playersForSide($game, TeamSide::Home); $newHomePlayer = Player::factory()->for($game->homeTeam)->named('Nina', 'Newest')->create(); $game->addPlayer($newHomePlayer, number: 8); $playersAfterRosterChange = $repository->playersForSide($game, TeamSide::Home); - expect($playersAfterRosterChange)->toBe($cachedPlayers) - ->and(collect($playersAfterRosterChange)->pluck('player_key')->all())->not->toContain($newHomePlayer->getKey()); + expect(collect($playersBeforeRosterChange)->pluck('player_key')->all())->not->toContain($newHomePlayer->getKey()) + ->and(collect($playersAfterRosterChange)->pluck('player_key')->all())->toContain($newHomePlayer->getKey()); }); -test('latest toss payload is cached with a game specific key', function (): void { - Cache::flush(); - +test('latest toss payload returns the recorded toss payload', function (): void { $game = gameWithNumberedRostersForCacheRepository(); $game->recordToss(TeamSide::Away, TeamAB::TeamB); @@ -60,19 +51,16 @@ expect($payload)->not->toBeNull() ->and($payload?->teamA)->toBe(TeamSide::Away) - ->and($payload?->serving)->toBe(TeamAB::TeamB) - ->and(Cache::has(sprintf('game:%d:events:latest-toss-payload', $game->getKey())))->toBeTrue(); + ->and($payload?->serving)->toBe(TeamAB::TeamB); }); -test('latest toss payload reuses cached value for repeated reads', function (): void { - Cache::flush(); - +test('latest toss payload reflects new toss events on repeated reads', function (): void { $game = gameWithNumberedRostersForCacheRepository(); $game->recordToss(TeamSide::Home, TeamAB::TeamA); $repository = app(CacheRepository::class); - $cachedPayload = $repository->latestTossPayload($game); + $payloadBeforeSecondToss = $repository->latestTossPayload($game); $game->events()->create([ 'type' => GameEventType::TossCompleted, 'payload' => new TossCompletedPayload( @@ -83,11 +71,11 @@ $payloadAfterRecordingToss = $repository->latestTossPayload($game); - expect($cachedPayload)->not->toBeNull() - ->and($cachedPayload?->teamA)->toBe(TeamSide::Home) - ->and($cachedPayload?->serving)->toBe(TeamAB::TeamA) - ->and($payloadAfterRecordingToss?->teamA)->toBe(TeamSide::Home) - ->and($payloadAfterRecordingToss?->serving)->toBe(TeamAB::TeamA); + expect($payloadBeforeSecondToss)->not->toBeNull() + ->and($payloadBeforeSecondToss?->teamA)->toBe(TeamSide::Home) + ->and($payloadBeforeSecondToss?->serving)->toBe(TeamAB::TeamA) + ->and($payloadAfterRecordingToss?->teamA)->toBe(TeamSide::Away) + ->and($payloadAfterRecordingToss?->serving)->toBe(TeamAB::TeamB); }); function gameWithNumberedRostersForCacheRepository(): Game diff --git a/tests/Feature/CourtTest.php b/tests/Feature/CourtTest.php index 8753b20..7727692 100644 --- a/tests/Feature/CourtTest.php +++ b/tests/Feature/CourtTest.php @@ -16,10 +16,28 @@ uses(RefreshDatabase::class); -test('court shows team a on the left and team b on the right before toss', function (): void { +test('court does not show player lists before toss is submitted', function (): void { $game = gameWithNumberedRosters(); Livewire::test(Court::class, ['gameId' => $game->getKey(), 'gameState' => gameState(['set_number' => 0])]) + ->assertDontSee('Submit Lineup') + ->assertDontSee('Anderson 3') + ->assertDontSee('Zephyr 12') + ->assertDontSee('2 Baker') + ->assertDontSee('9 Young') + ->assertDontSee('1 Libero') + ->assertDontSee('20 Keeper') + ->assertDontSee('Anna') + ->assertDontSee('Beth') + ->assertDontSee('Dora') + ->assertDontSee('Etta'); +}); + +test('court shows player lists after toss is submitted', function (): void { + $game = gameWithNumberedRosters(); + $game->recordToss(TeamSide::Home, TeamAB::TeamA); + + Livewire::test(Court::class, ['gameId' => $game->getKey(), 'gameState' => gameState(['set_number' => 1])]) ->assertSeeInOrder([ 'Team A', 'Anderson 3', @@ -28,6 +46,7 @@ '2 Baker', '9 Young', ]) + ->assertSee('Submit Lineup') ->assertDontSee('1 Libero') ->assertDontSee('20 Keeper') ->assertDontSee('Anna') @@ -40,7 +59,7 @@ $game = gameWithNumberedRosters(); $game->recordToss(TeamSide::Home, TeamAB::TeamA); - Livewire::test(Court::class, ['gameId' => $game->getKey(), 'gameState' => gameState(['set_number' => 2])]) + Livewire::test(Court::class, ['gameId' => $game->getKey(), 'gameState' => gameState(['set_number' => 2, 'sets_won_team_a' => 1])]) ->assertSeeInOrder([ 'Team B', 'Baker 2', @@ -50,7 +69,7 @@ '12 Zephyr', ]); - Livewire::test(Court::class, ['gameId' => $game->getKey(), 'gameState' => gameState(['set_number' => 3])]) + Livewire::test(Court::class, ['gameId' => $game->getKey(), 'gameState' => gameState(['set_number' => 3, 'sets_won_team_a' => 1, 'sets_won_team_b' => 1])]) ->assertSeeInOrder([ 'Team A', 'Anderson 3', @@ -60,7 +79,7 @@ '9 Young', ]); - Livewire::test(Court::class, ['gameId' => $game->getKey(), 'gameState' => gameState(['set_number' => 4])]) + Livewire::test(Court::class, ['gameId' => $game->getKey(), 'gameState' => gameState(['set_number' => 4, 'sets_won_team_a' => 2, 'sets_won_team_b' => 1])]) ->assertSeeInOrder([ 'Team B', 'Baker 2', @@ -76,68 +95,74 @@ $game->recordToss(TeamSide::Home, TeamAB::TeamA); $setExpectations = [ - 1 => [ + 1 => ['set_number' => 1, 'sets_won_team_a' => 0, 'sets_won_team_b' => 0, 'expected' => [ 'Team A', 'Anderson 3', 'Zephyr 12', 'Team B', '2 Baker', '9 Young', - ], - 2 => [ + ]], + 2 => ['set_number' => 2, 'sets_won_team_a' => 1, 'sets_won_team_b' => 0, 'expected' => [ 'Team B', 'Baker 2', 'Young 9', 'Team A', '3 Anderson', '12 Zephyr', - ], - 3 => [ + ]], + 3 => ['set_number' => 3, 'sets_won_team_a' => 1, 'sets_won_team_b' => 1, 'expected' => [ 'Team A', 'Anderson 3', 'Zephyr 12', 'Team B', '2 Baker', '9 Young', - ], - 4 => [ + ]], + 4 => ['set_number' => 4, 'sets_won_team_a' => 2, 'sets_won_team_b' => 1, 'expected' => [ 'Team B', 'Baker 2', 'Young 9', 'Team A', '3 Anderson', '12 Zephyr', - ], + ]], ]; - foreach ($setExpectations as $setNumber => $expectedVisibleText) { - Livewire::test(Court::class, ['gameId' => $game->getKey(), 'gameState' => gameState(['set_number' => $setNumber])]) - ->assertSeeInOrder($expectedVisibleText); + foreach ($setExpectations as $state) { + Livewire::test(Court::class, [ + 'gameId' => $game->getKey(), + 'gameState' => gameState([ + 'set_number' => $state['set_number'], + 'sets_won_team_a' => $state['sets_won_team_a'], + 'sets_won_team_b' => $state['sets_won_team_b'], + ]), + ])->assertSeeInOrder($state['expected']); } }); -test('court uses toss assignment for first and fifth set side labels', function (): void { +test('court keeps team a on the left in first and fifth sets regardless of toss side assignment', function (): void { $game = gameWithNumberedRosters(); $game->recordToss(TeamSide::Away, TeamAB::TeamA); Livewire::test(Court::class, ['gameId' => $game->getKey(), 'gameState' => gameState(['set_number' => 1])]) ->assertSeeInOrder([ - 'Team B', - 'Anderson 3', - 'Zephyr 12', 'Team A', - '2 Baker', - '9 Young', + 'Baker 2', + 'Young 9', + 'Team B', + '3 Anderson', + '12 Zephyr', ]); - Livewire::test(Court::class, ['gameId' => $game->getKey(), 'gameState' => gameState(['set_number' => 5])]) + Livewire::test(Court::class, ['gameId' => $game->getKey(), 'gameState' => gameState(['set_number' => 5, 'sets_won_team_a' => 2, 'sets_won_team_b' => 2])]) ->assertSeeInOrder([ - 'Team B', - 'Anderson 3', - 'Zephyr 12', 'Team A', - '2 Baker', - '9 Young', + 'Baker 2', + 'Young 9', + 'Team B', + '3 Anderson', + '12 Zephyr', ]); }); @@ -167,6 +192,7 @@ 'gameId' => $game->getKey(), 'gameState' => gameState([ 'set_number' => 2, + 'sets_won_team_a' => 1, 'rotation_team_a' => [1 => 12], 'rotation_team_b' => [1 => 9], ]), @@ -205,6 +231,7 @@ 'gameId' => $game->getKey(), 'gameState' => gameState([ 'set_number' => 2, + 'sets_won_team_a' => 1, 'serving_team' => TeamAB::TeamA->value, 'rotation_team_a' => [1 => 12], 'rotation_team_b' => [1 => 9], @@ -217,6 +244,28 @@ ->assertSeeHtml('left-[12%] bottom-[14%]'); }); +test('court swaps sides as soon as a set ends before the next set starts', function (): void { + $game = gameWithNumberedRosters(); + $game->recordToss(TeamSide::Home, TeamAB::TeamA); + + Livewire::test(Court::class, [ + 'gameId' => $game->getKey(), + 'gameState' => gameState([ + 'set_number' => 1, + 'sets_won_team_a' => 1, + 'set_in_progress' => false, + ]), + ]) + ->assertSeeInOrder([ + 'Team B', + 'Baker 2', + 'Young 9', + 'Team A', + '3 Anderson', + '12 Zephyr', + ]); +}); + test('court shows rally winner buttons only while a set is in progress', function (): void { $game = Game::factory()->create(); diff --git a/tests/Feature/LineupSubmissionTest.php b/tests/Feature/LineupSubmissionTest.php index 73f1881..6208b80 100644 --- a/tests/Feature/LineupSubmissionTest.php +++ b/tests/Feature/LineupSubmissionTest.php @@ -71,9 +71,17 @@ function prepareGameForLineupSubmission(): Game return $game; } -test('lineup submission renders team a button and modal', function (): void { +test('lineup submission is hidden before toss is submitted', function (): void { $game = Game::factory()->create(); + Livewire::test(LineupSubmission::class, ['team' => TeamAB::TeamA, 'gameId' => $game->getKey()]) + ->assertDontSee('Submit Lineup') + ->assertDontSee('Team A Lineup'); +}); + +test('lineup submission renders team a button and modal after toss is submitted', function (): void { + $game = prepareGameForLineupSubmission(); + Livewire::test(LineupSubmission::class, ['team' => TeamAB::TeamA, 'gameId' => $game->getKey()]) ->assertSee('Submit Lineup') ->assertSee('Team A Lineup') @@ -83,8 +91,8 @@ function prepareGameForLineupSubmission(): Game ->assertSee('Submit'); }); -test('lineup submission renders team b button and modal', function (): void { - $game = Game::factory()->create(); +test('lineup submission renders team b button and modal after toss is submitted', function (): void { + $game = prepareGameForLineupSubmission(); Livewire::test(LineupSubmission::class, ['team' => TeamAB::TeamB, 'gameId' => $game->getKey()]) ->assertSee('Submit Lineup') diff --git a/tests/Feature/TeamRosterTest.php b/tests/Feature/TeamRosterTest.php index d9d53af..c7cc5b0 100644 --- a/tests/Feature/TeamRosterTest.php +++ b/tests/Feature/TeamRosterTest.php @@ -70,6 +70,21 @@ ]); }); +test('team roster resolves toss assignment immediately after toss submission', function (): void { + $game = gameWithNumberedRostersForTeamRoster(); + $game->recordToss(TeamSide::Away, TeamAB::TeamA); + + Livewire::test(TeamRoster::class, [ + 'gameId' => $game->getKey(), + 'team' => TeamAB::TeamA, + 'leftSide' => true, + ])->assertSeeInOrder([ + 'Team A', + 'Baker 2', + 'Young 9', + ])->assertDontSee('Anderson 3'); +}); + test('team roster can render after a second Livewire request', function (): void { $game = gameWithNumberedRostersForTeamRoster(); From 5bf3f10700590d074081c1b4863019e0d8dca5ab Mon Sep 17 00:00:00 2001 From: Giulio Troccoli-Allard Date: Sat, 28 Mar 2026 09:29:53 +0000 Subject: [PATCH 15/63] fix: move roster players under the court to simulate the benches --- app/Livewire/TeamRoster.php | 29 ++- resources/views/livewire/court.blade.php | 190 +++++++++--------- .../views/livewire/team-roster.blade.php | 21 +- tests/Feature/CourtTest.php | 136 ++++++------- tests/Feature/TeamRosterTest.php | 51 +++-- 5 files changed, 230 insertions(+), 197 deletions(-) diff --git a/app/Livewire/TeamRoster.php b/app/Livewire/TeamRoster.php index a61921c..bf9fe197 100644 --- a/app/Livewire/TeamRoster.php +++ b/app/Livewire/TeamRoster.php @@ -4,6 +4,7 @@ namespace App\Livewire; +use App\Data\GameState\GameState; use App\Enums\TeamAB; use App\Enums\TeamSide; use App\Models\Game; @@ -25,6 +26,9 @@ class TeamRoster extends Component #[Reactive] public bool $leftSide = true; + #[Reactive] + public ?GameState $gameState = null; + public function mount( ?int $gameId = null, TeamAB $team = TeamAB::TeamA, @@ -40,11 +44,9 @@ public function mount( public function render(): View { return view('livewire.team-roster', [ - 'teamLabel' => $this->team->label(), 'players' => $this->players(), - 'numberFirst' => ! $this->leftSide, - 'alignRight' => $this->leftSide, 'keyPrefix' => $this->leftSide ? 'left-player' : 'right-player', + 'markerTone' => $this->team === TeamAB::TeamA ? 'bg-blue-600' : 'bg-red-600', ]); } @@ -63,7 +65,14 @@ private function players(): array return []; } - return $this->playersForTeam($game, $this->team); + $players = $this->playersForTeam($game, $this->team); + $onCourtNumbers = $this->onCourtRosterNumbers($this->team); + + if ($onCourtNumbers === []) { + return $players; + } + + return array_values(array_filter($players, fn (array $player): bool => ! in_array($player['number'], $onCourtNumbers, true))); } /** @@ -101,4 +110,16 @@ private function oppositeSide(TeamSide $side): TeamSide ? TeamSide::Away : TeamSide::Home; } + + /** + * @return array + */ + private function onCourtRosterNumbers(TeamAB $team): array + { + $state = $this->gameState ?? GameState::initial(); + + return $team === TeamAB::TeamA + ? array_values($state->rotationTeamA) + : array_values($state->rotationTeamB); + } } diff --git a/resources/views/livewire/court.blade.php b/resources/views/livewire/court.blade.php index 01419d3..91ca987 100644 --- a/resources/views/livewire/court.blade.php +++ b/resources/views/livewire/court.blade.php @@ -30,108 +30,108 @@ $rightMarkerTone = $rightTeam === \App\Enums\TeamAB::TeamA ? 'bg-blue-600' : 'bg-red-600'; @endphp -
- @if ($showRosters) - - @else -
- @endif - -
-
-
-
-
-
+
+
+
+
+
+
- @foreach ($leftRotation as $position => $number) - @php - $isServingPlayer = $position === 1 && $servingTeam === $leftTeam; - $markerPositionClass = $isServingPlayer - ? ($leftOutsidePositionClasses[$position] ?? $leftCourtPositionClasses[$position] ?? '') - : ($leftCourtPositionClasses[$position] ?? ''); - @endphp -
- {{ $number }} -
- @endforeach + @foreach ($leftRotation as $position => $number) + @php + $isServingPlayer = $position === 1 && $servingTeam === $leftTeam; + $markerPositionClass = $isServingPlayer + ? ($leftOutsidePositionClasses[$position] ?? $leftCourtPositionClasses[$position] ?? '') + : ($leftCourtPositionClasses[$position] ?? ''); + @endphp +
+ {{ $number }} +
+ @endforeach - @foreach ($rightRotation as $position => $number) - @php - $isServingPlayer = $position === 1 && $servingTeam === $rightTeam; - $markerPositionClass = $isServingPlayer - ? ($rightOutsidePositionClasses[$position] ?? $rightCourtPositionClasses[$position] ?? '') - : ($rightCourtPositionClasses[$position] ?? ''); - @endphp -
- {{ $number }} -
- @endforeach -
+ @foreach ($rightRotation as $position => $number) + @php + $isServingPlayer = $position === 1 && $servingTeam === $rightTeam; + $markerPositionClass = $isServingPlayer + ? ($rightOutsidePositionClasses[$position] ?? $rightCourtPositionClasses[$position] ?? '') + : ($rightCourtPositionClasses[$position] ?? ''); + @endphp +
+ {{ $number }} +
+ @endforeach +
- @if ($canRecordRallyWinner) -
- + @if ($canRecordRallyWinner) +
+ - -
- @endif + +
+ @endif - @error('rallyWinner') - {{ $message }} - @enderror -
+ @error('rallyWinner') + {{ $message }} + @enderror @if ($showRosters) - - @else -
+
+
+ +
+ +
+ +
+
@endif
diff --git a/resources/views/livewire/team-roster.blade.php b/resources/views/livewire/team-roster.blade.php index 3c383b9..83a3670 100644 --- a/resources/views/livewire/team-roster.blade.php +++ b/resources/views/livewire/team-roster.blade.php @@ -1,17 +1,18 @@ -
diff --git a/resources/views/livewire/lineup-submission.blade.php b/resources/views/livewire/lineup-submission.blade.php index 7937b8a..63598fc 100644 --- a/resources/views/livewire/lineup-submission.blade.php +++ b/resources/views/livewire/lineup-submission.blade.php @@ -17,6 +17,7 @@ wire:key="{{ $this->team->value }}-position-{{ $position }}" name="lineup[{{ $position }}]" wire:model="lineup.{{ $position }}" + :autofocus="$position === 1" class="h-12! w-12!" /> @endfor @@ -26,6 +27,19 @@ class="h-12! w-12!" {{ $message }} @enderror + @if ($rosterNumbers !== []) +
    + @foreach ($rosterNumbers as $rosterNumber) +
  • + {{ $rosterNumber }} +
  • + @endforeach +
+ @endif +
Submit diff --git a/resources/views/livewire/start-set-submission.blade.php b/resources/views/livewire/start-set-submission.blade.php index b1b7cb8..f01ce37 100644 --- a/resources/views/livewire/start-set-submission.blade.php +++ b/resources/views/livewire/start-set-submission.blade.php @@ -1,6 +1,6 @@
@if ($canStartSet) -
+
- @if ($players === []) + @if ($showPlayerPlaceholders) +
    + @foreach (range(1, $placeholderCount) as $placeholderIndex) +
  • + @endforeach +
+ @elseif ($players === [] && ! $hasRosterPlayers)

No players available.

- @else + @elseif ($players !== [])
    $reverseStaffOrder, + ]) + > + @foreach ($staffMarkers as $staffMarker) +
  • + + {{ $staffMarker['role_letter'] }} + @if (! is_null($staffMarker['subscript'])) + {{ $staffMarker['subscript'] }} + @endif + +
  • + @endforeach +
+ @endif diff --git a/tests/Feature/CourtTest.php b/tests/Feature/CourtTest.php index 5bc34d2..e0bf2c3 100644 --- a/tests/Feature/CourtTest.php +++ b/tests/Feature/CourtTest.php @@ -37,7 +37,7 @@ $game = gameWithNumberedRosters(); $game->recordToss(TeamSide::Home, TeamAB::TeamA); - Livewire::test(Court::class, ['gameId' => $game->getKey(), 'gameState' => gameState(['set_number' => 1])]) + Livewire::test(Court::class, ['gameId' => $game->getKey(), 'gameState' => gameStateWithSubmittedLineups(['set_number' => 1])]) ->assertSeeInOrder([ '3', '12', @@ -75,7 +75,7 @@ $game = gameWithNumberedRosters(); $game->recordToss(TeamSide::Home, TeamAB::TeamA); - Livewire::test(Court::class, ['gameId' => $game->getKey(), 'gameState' => gameState(['set_number' => 2, 'sets_won_team_a' => 1])]) + Livewire::test(Court::class, ['gameId' => $game->getKey(), 'gameState' => gameStateWithSubmittedLineups(['set_number' => 2, 'sets_won_team_a' => 1])]) ->assertSeeInOrder([ '2', '9', @@ -83,7 +83,7 @@ '12', ]); - Livewire::test(Court::class, ['gameId' => $game->getKey(), 'gameState' => gameState(['set_number' => 3, 'sets_won_team_a' => 1, 'sets_won_team_b' => 1])]) + Livewire::test(Court::class, ['gameId' => $game->getKey(), 'gameState' => gameStateWithSubmittedLineups(['set_number' => 3, 'sets_won_team_a' => 1, 'sets_won_team_b' => 1])]) ->assertSeeInOrder([ '3', '12', @@ -91,7 +91,7 @@ '9', ]); - Livewire::test(Court::class, ['gameId' => $game->getKey(), 'gameState' => gameState(['set_number' => 4, 'sets_won_team_a' => 2, 'sets_won_team_b' => 1])]) + Livewire::test(Court::class, ['gameId' => $game->getKey(), 'gameState' => gameStateWithSubmittedLineups(['set_number' => 4, 'sets_won_team_a' => 2, 'sets_won_team_b' => 1])]) ->assertSeeInOrder([ '2', '9', @@ -134,7 +134,7 @@ foreach ($setExpectations as $state) { Livewire::test(Court::class, [ 'gameId' => $game->getKey(), - 'gameState' => gameState([ + 'gameState' => gameStateWithSubmittedLineups([ 'set_number' => $state['set_number'], 'sets_won_team_a' => $state['sets_won_team_a'], 'sets_won_team_b' => $state['sets_won_team_b'], @@ -147,7 +147,7 @@ $game = gameWithNumberedRosters(); $game->recordToss(TeamSide::Away, TeamAB::TeamA); - Livewire::test(Court::class, ['gameId' => $game->getKey(), 'gameState' => gameState(['set_number' => 1])]) + Livewire::test(Court::class, ['gameId' => $game->getKey(), 'gameState' => gameStateWithSubmittedLineups(['set_number' => 1])]) ->assertSeeInOrder([ '2', '9', @@ -155,7 +155,7 @@ '12', ]); - Livewire::test(Court::class, ['gameId' => $game->getKey(), 'gameState' => gameState(['set_number' => 5, 'sets_won_team_a' => 2, 'sets_won_team_b' => 2])]) + Livewire::test(Court::class, ['gameId' => $game->getKey(), 'gameState' => gameStateWithSubmittedLineups(['set_number' => 5, 'sets_won_team_a' => 2, 'sets_won_team_b' => 2])]) ->assertSeeInOrder([ '2', '9', @@ -248,7 +248,7 @@ Livewire::test(Court::class, [ 'gameId' => $game->getKey(), - 'gameState' => gameState([ + 'gameState' => gameStateWithSubmittedLineups([ 'set_number' => 1, 'sets_won_team_a' => 1, 'set_in_progress' => false, @@ -344,3 +344,14 @@ function gameState(array $attributes): GameState { return GameState::fromAttributes($attributes); } + +/** + * @param array $attributes + */ +function gameStateWithSubmittedLineups(array $attributes): GameState +{ + return gameState(array_merge([ + 'rotation_team_a' => [1 => 999], + 'rotation_team_b' => [1 => 998], + ], $attributes)); +} diff --git a/tests/Feature/LineupSubmissionTest.php b/tests/Feature/LineupSubmissionTest.php index 6208b80..28c3320 100644 --- a/tests/Feature/LineupSubmissionTest.php +++ b/tests/Feature/LineupSubmissionTest.php @@ -87,7 +87,13 @@ function prepareGameForLineupSubmission(): Game ->assertSee('Team A Lineup') ->assertSeeHtml('submit-lineup-team_a') ->assertSeeHtml('name="lineup[1]"') + ->assertSeeHtml('autofocus') ->assertSeeHtml('name="lineup[6]"') + ->assertSeeHtml('data-lineup-roster-numbers') + ->assertSeeHtml('data-lineup-roster-number="1"') + ->assertSeeHtml('data-lineup-roster-number="6"') + ->assertDontSeeHtml('data-lineup-roster-number="99"') + ->assertDontSeeHtml('data-lineup-roster-number="11"') ->assertSee('Submit'); }); @@ -100,6 +106,11 @@ function prepareGameForLineupSubmission(): Game ->assertSeeHtml('submit-lineup-team_b') ->assertSeeHtml('name="lineup[1]"') ->assertSeeHtml('name="lineup[6]"') + ->assertSeeHtml('data-lineup-roster-numbers') + ->assertSeeHtml('data-lineup-roster-number="11"') + ->assertSeeHtml('data-lineup-roster-number="16"') + ->assertDontSeeHtml('data-lineup-roster-number="99"') + ->assertDontSeeHtml('data-lineup-roster-number="1"') ->assertSee('Submit'); }); diff --git a/tests/Feature/TeamRosterTest.php b/tests/Feature/TeamRosterTest.php index 8fcd072..12a5ba9 100644 --- a/tests/Feature/TeamRosterTest.php +++ b/tests/Feature/TeamRosterTest.php @@ -3,11 +3,13 @@ declare(strict_types=1); use App\Data\GameState\GameState; +use App\Enums\StaffRole; use App\Enums\TeamAB; use App\Enums\TeamSide; use App\Livewire\TeamRoster; use App\Models\Game; use App\Models\Player; +use App\Models\Staff; use App\Models\Team; use Illuminate\Foundation\Testing\RefreshDatabase; use Livewire\Livewire; @@ -36,6 +38,7 @@ 'gameId' => $game->getKey(), 'team' => TeamAB::TeamA, 'leftSide' => true, + 'gameState' => submittedLineupState(), ])->assertSeeInOrder([ '3', '12', @@ -49,6 +52,7 @@ 'gameId' => $game->getKey(), 'team' => TeamAB::TeamB, 'leftSide' => false, + 'gameState' => submittedLineupState(), ])->assertSeeInOrder([ '2', '9', @@ -63,6 +67,7 @@ 'gameId' => $game->getKey(), 'team' => TeamAB::TeamA, 'leftSide' => true, + 'gameState' => submittedLineupState(), ])->assertSeeInOrder([ '2', '9', @@ -77,10 +82,12 @@ 'gameId' => $game->getKey(), 'team' => TeamAB::TeamA, 'leftSide' => true, - ])->assertSeeInOrder([ - '2', - '9', - ])->assertDontSeeHtml('data-team-roster-number="3"'); + 'gameState' => submittedLineupState(), + ]) + ->assertSeeInOrder([ + '2', + '9', + ])->assertDontSeeHtml('data-team-roster-number="3"'); }); test('team roster hides on-court players when lineup rotation exists', function (): void { @@ -107,12 +114,96 @@ 'gameId' => $game->getKey(), 'team' => TeamAB::TeamA, 'leftSide' => true, + 'gameState' => submittedLineupState(), ]) ->assertSeeHtml('data-team-roster-number="3"') ->call('$refresh') ->assertSeeHtml('data-team-roster-number="3"'); }); +test('team roster shows placeholders when lineup is not submitted', function (): void { + $homeTeam = Team::factory()->create(); + $awayTeam = Team::factory()->create(); + $game = Game::factory()->betweenTeams($homeTeam, $awayTeam)->create(); + $homePlayers = Player::factory()->for($homeTeam)->count(9)->create(); + + foreach ($homePlayers as $index => $player) { + $game->addPlayer($player, number: $index + 1, isLibero: $index === 8); + } + + Livewire::test(TeamRoster::class, [ + 'gameId' => $game->getKey(), + 'team' => TeamAB::TeamA, + 'leftSide' => true, + ]) + ->assertSeeHtml('data-team-roster-placeholder="1"') + ->assertSeeHtml('data-team-roster-placeholder="2"') + ->assertDontSeeHtml('data-team-roster-placeholder="3"') + ->assertDontSeeHtml('data-team-roster-number="1"') + ->assertDontSeeHtml('data-team-roster-number="8"'); +}); + +test('team roster renders staff role circles in the requested order for team a', function (): void { + $game = gameWithNumberedRostersForTeamRoster(); + + Livewire::test(TeamRoster::class, [ + 'gameId' => $game->getKey(), + 'team' => TeamAB::TeamA, + 'leftSide' => true, + ]) + ->assertSeeHtml('data-team-roster-staff-list') + ->assertSeeHtml('flex-row-reverse') + ->assertSeeInOrder([ + 'data-team-roster-staff-role="C"', + 'data-team-roster-staff-role="A1"', + 'data-team-roster-staff-role="A2"', + 'data-team-roster-staff-role="D"', + 'data-team-roster-staff-role="T"', + ]); +}); + +test('team roster renders staff role circles left to right for team b', function (): void { + $game = gameWithNumberedRostersForTeamRoster(); + + Livewire::test(TeamRoster::class, [ + 'gameId' => $game->getKey(), + 'team' => TeamAB::TeamB, + 'leftSide' => false, + ]) + ->assertSeeHtml('data-team-roster-staff-list') + ->assertDontSeeHtml('flex-row-reverse') + ->assertSeeInOrder([ + 'data-team-roster-staff-role="C"', + 'data-team-roster-staff-role="A1"', + 'data-team-roster-staff-role="A2"', + 'data-team-roster-staff-role="D"', + 'data-team-roster-staff-role="T"', + ]); +}); + +test('team roster omits staff role circles when those roles are not on the roster', function (): void { + $homeTeam = Team::factory()->create(); + $awayTeam = Team::factory()->create(); + $game = Game::factory()->betweenTeams($homeTeam, $awayTeam)->create(); + + $homeCoach = Staff::factory()->for($homeTeam)->create(); + $homeDoctor = Staff::factory()->for($homeTeam)->create(); + + $game->addStaff($homeCoach, StaffRole::Coach); + $game->addStaff($homeDoctor, StaffRole::Doctor); + + Livewire::test(TeamRoster::class, [ + 'gameId' => $game->getKey(), + 'team' => TeamAB::TeamA, + 'leftSide' => true, + ]) + ->assertSeeHtml('data-team-roster-staff-role="C"') + ->assertSeeHtml('data-team-roster-staff-role="D"') + ->assertDontSeeHtml('data-team-roster-staff-role="A1"') + ->assertDontSeeHtml('data-team-roster-staff-role="A2"') + ->assertDontSeeHtml('data-team-roster-staff-role="T"'); +}); + function gameWithNumberedRostersForTeamRoster(): Game { $homeTeam = Team::factory()->create(); @@ -126,6 +217,16 @@ function gameWithNumberedRostersForTeamRoster(): Game $awayPlayerOne = Player::factory()->for($awayTeam)->named('Dora', 'Young')->create(); $awayPlayerTwo = Player::factory()->for($awayTeam)->named('Etta', 'Baker')->create(); $awayLibero = Player::factory()->for($awayTeam)->named('Faye', 'Keeper')->create(); + $homeCoach = Staff::factory()->for($homeTeam)->create(); + $homeAssistantCoachOne = Staff::factory()->for($homeTeam)->create(); + $homeAssistantCoachTwo = Staff::factory()->for($homeTeam)->create(); + $homeDoctor = Staff::factory()->for($homeTeam)->create(); + $homeTherapist = Staff::factory()->for($homeTeam)->create(); + $awayCoach = Staff::factory()->for($awayTeam)->create(); + $awayAssistantCoachOne = Staff::factory()->for($awayTeam)->create(); + $awayAssistantCoachTwo = Staff::factory()->for($awayTeam)->create(); + $awayDoctor = Staff::factory()->for($awayTeam)->create(); + $awayTherapist = Staff::factory()->for($awayTeam)->create(); $game->addPlayer($homePlayerOne, number: 12); $game->addPlayer($homePlayerTwo, number: 3); @@ -133,6 +234,24 @@ function gameWithNumberedRostersForTeamRoster(): Game $game->addPlayer($awayPlayerOne, number: 9); $game->addPlayer($awayPlayerTwo, number: 2); $game->addPlayer($awayLibero, number: 20, isLibero: true); + $game->addStaff($homeCoach, StaffRole::Coach); + $game->addStaff($homeAssistantCoachOne, StaffRole::AssistantCoach); + $game->addStaff($homeAssistantCoachTwo, StaffRole::AssistantCoach); + $game->addStaff($homeDoctor, StaffRole::Doctor); + $game->addStaff($homeTherapist, StaffRole::Therapist); + $game->addStaff($awayCoach, StaffRole::Coach); + $game->addStaff($awayAssistantCoachOne, StaffRole::AssistantCoach); + $game->addStaff($awayAssistantCoachTwo, StaffRole::AssistantCoach); + $game->addStaff($awayDoctor, StaffRole::Doctor); + $game->addStaff($awayTherapist, StaffRole::Therapist); return $game; } + +function submittedLineupState(array $attributes = []): GameState +{ + return GameState::fromAttributes(array_merge([ + 'rotation_team_a' => [1 => 999], + 'rotation_team_b' => [1 => 998], + ], $attributes)); +} From b512fa87fa40c324c7e03c224a3ed8ce968b9f6c Mon Sep 17 00:00:00 2001 From: Giulio Troccoli-Allard Date: Sat, 28 Mar 2026 16:02:51 +0000 Subject: [PATCH 17/63] fix: fix order of staff on bench --- app/Livewire/TeamRoster.php | 2 +- tests/Feature/TeamRosterTest.php | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/app/Livewire/TeamRoster.php b/app/Livewire/TeamRoster.php index 24e8745..b6d8af4 100644 --- a/app/Livewire/TeamRoster.php +++ b/app/Livewire/TeamRoster.php @@ -55,7 +55,7 @@ public function render(): View 'placeholderCount' => $placeholderCount, 'hasRosterPlayers' => $rosterPlayerCount > 0, 'staffMarkers' => $this->staffMarkers(), - 'reverseStaffOrder' => $this->team === TeamAB::TeamA, + 'reverseStaffOrder' => $this->team === TeamAB::TeamB, 'keyPrefix' => $this->leftSide ? 'left-player' : 'right-player', 'markerTone' => $this->team === TeamAB::TeamA ? 'bg-blue-600' : 'bg-red-600', ]); diff --git a/tests/Feature/TeamRosterTest.php b/tests/Feature/TeamRosterTest.php index 12a5ba9..dc489cb 100644 --- a/tests/Feature/TeamRosterTest.php +++ b/tests/Feature/TeamRosterTest.php @@ -143,7 +143,7 @@ ->assertDontSeeHtml('data-team-roster-number="8"'); }); -test('team roster renders staff role circles in the requested order for team a', function (): void { +test('team roster renders staff role circles left to right for team a', function (): void { $game = gameWithNumberedRostersForTeamRoster(); Livewire::test(TeamRoster::class, [ @@ -152,7 +152,7 @@ 'leftSide' => true, ]) ->assertSeeHtml('data-team-roster-staff-list') - ->assertSeeHtml('flex-row-reverse') + ->assertDontSeeHtml('flex-row-reverse') ->assertSeeInOrder([ 'data-team-roster-staff-role="C"', 'data-team-roster-staff-role="A1"', @@ -162,7 +162,7 @@ ]); }); -test('team roster renders staff role circles left to right for team b', function (): void { +test('team roster renders staff role circles in reverse row order for team b', function (): void { $game = gameWithNumberedRostersForTeamRoster(); Livewire::test(TeamRoster::class, [ @@ -171,7 +171,7 @@ 'leftSide' => false, ]) ->assertSeeHtml('data-team-roster-staff-list') - ->assertDontSeeHtml('flex-row-reverse') + ->assertSeeHtml('flex-row-reverse') ->assertSeeInOrder([ 'data-team-roster-staff-role="C"', 'data-team-roster-staff-role="A1"', From 19d12699ded553cd3d19a2fb2e29752ae9ab67e5 Mon Sep 17 00:00:00 2001 From: Giulio Troccoli-Allard Date: Sat, 28 Mar 2026 16:16:45 +0000 Subject: [PATCH 18/63] feat: add new seeder with controlled data --- database/seeders/ControlledGameSeeder.php | 129 +++++++++++++++++++++ tests/Feature/ControlledGameSeederTest.php | 76 ++++++++++++ 2 files changed, 205 insertions(+) create mode 100644 database/seeders/ControlledGameSeeder.php create mode 100644 tests/Feature/ControlledGameSeederTest.php diff --git a/database/seeders/ControlledGameSeeder.php b/database/seeders/ControlledGameSeeder.php new file mode 100644 index 0000000..b6c5ea2 --- /dev/null +++ b/database/seeders/ControlledGameSeeder.php @@ -0,0 +1,129 @@ +named('Dev Controlled Championship')->create(); + + $homeTeam = Team::factory()->create([ + 'name' => self::HOME_TEAM_NAME, + 'country_code' => 'ITA', + ]); + + $awayTeam = Team::factory()->create([ + 'name' => self::AWAY_TEAM_NAME, + 'country_code' => 'FRA', + ]); + + Player::factory() + ->for($homeTeam) + ->forCountry($homeTeam->country_code) + ->count(13) + ->create(); + + Player::factory() + ->for($awayTeam) + ->forCountry($awayTeam->country_code) + ->count(12) + ->create(); + + Staff::factory() + ->for($homeTeam) + ->forCountry($homeTeam->country_code) + ->count(5) + ->create(); + + Staff::factory() + ->for($awayTeam) + ->forCountry($awayTeam->country_code) + ->count(3) + ->create(); + + $game = Game::factory() + ->for($championship, 'championship') + ->betweenTeams($homeTeam, $awayTeam) + ->withMatchNumber(1) + ->withCountryCode($homeTeam->country_code) + ->at('Bologna', 'PalaDozza') + ->scheduledAt(CarbonImmutable::now()->addDay()->setTime(20, 30)) + ->create(); + + $officials = Official::factory()->count(count(OfficialRole::cases()))->create(); + + foreach (OfficialRole::cases() as $index => $role) { + $official = $officials[$index]; + $game->addOfficial($official, $role); + } + + $homeNumbers = range(1, 13); + $awayNumbers = range(20, 31); + + foreach ($homeTeam->players()->orderBy('id')->get() as $index => $player) { + $number = $homeNumbers[$index]; + + $game->addPlayer( + player: $player, + number: $number, + isCaptain: $index === 0, + isLibero: $number === 13, + ); + } + + foreach ($awayTeam->players()->orderBy('id')->get() as $index => $player) { + $game->addPlayer( + player: $player, + number: $awayNumbers[$index], + isCaptain: $index === 0, + isLibero: false, + ); + } + + $homeStaffRoles = [ + StaffRole::Coach, + StaffRole::AssistantCoach, + StaffRole::AssistantCoach, + StaffRole::Doctor, + StaffRole::Therapist, + ]; + + foreach ($homeTeam->staff()->orderBy('id')->get() as $index => $staff) { + $game->addStaff($staff, $homeStaffRoles[$index]); + } + + $awayStaffRoles = [ + StaffRole::Coach, + StaffRole::AssistantCoach, + StaffRole::Doctor, + ]; + + foreach ($awayTeam->staff()->orderBy('id')->get() as $index => $staff) { + $game->addStaff($staff, $awayStaffRoles[$index]); + } + } +} diff --git a/tests/Feature/ControlledGameSeederTest.php b/tests/Feature/ControlledGameSeederTest.php new file mode 100644 index 0000000..718ca9a --- /dev/null +++ b/tests/Feature/ControlledGameSeederTest.php @@ -0,0 +1,76 @@ +seed(ControlledGameSeeder::class); + + $game = Game::query()->with(['homeTeam', 'awayTeam', 'players', 'staff', 'officials'])->sole(); + $homePlayers = $game->players + ->filter(fn (Player $player): bool => $player->roster->team_id === $game->home_team_id) + ->values(); + $awayPlayers = $game->players + ->filter(fn (Player $player): bool => $player->roster->team_id === $game->away_team_id) + ->values(); + $homeStaff = $game->staff + ->filter(fn (Staff $staff): bool => $staff->roster->team_id === $game->home_team_id) + ->values(); + $awayStaff = $game->staff + ->filter(fn (Staff $staff): bool => $staff->roster->team_id === $game->away_team_id) + ->values(); + + expect($game->homeTeam->name)->toBe('Dev Home Team') + ->and($game->awayTeam->name)->toBe('Dev Away Team') + ->and($homePlayers)->toHaveCount(13) + ->and($awayPlayers)->toHaveCount(12); + + $homeRosterNumbers = $homePlayers + ->sortBy(fn (Player $player): int => $player->roster->number) + ->pluck('roster.number') + ->values() + ->all(); + + $awayRosterNumbers = $awayPlayers + ->sortBy(fn (Player $player): int => $player->roster->number) + ->pluck('roster.number') + ->values() + ->all(); + + expect($homeRosterNumbers)->toBe(range(1, 13)) + ->and($awayRosterNumbers)->toBe(range(20, 31)) + ->and($homePlayers->filter(fn (Player $player): bool => $player->roster->is_libero)->count())->toBe(1) + ->and($awayPlayers->filter(fn (Player $player): bool => $player->roster->is_libero)->count())->toBe(0); + + expect($homeStaff)->toHaveCount(5) + ->and($awayStaff)->toHaveCount(3); + + $homeRoleCounts = $homeStaff + ->countBy(fn (Staff $staff): string => $staff->roster->role->value) + ->all(); + + $awayRoleCounts = $awayStaff + ->countBy(fn (Staff $staff): string => $staff->roster->role->value) + ->all(); + + expect($homeRoleCounts[StaffRole::Coach->value] ?? 0)->toBe(1) + ->and($homeRoleCounts[StaffRole::AssistantCoach->value] ?? 0)->toBe(2) + ->and($homeRoleCounts[StaffRole::Doctor->value] ?? 0)->toBe(1) + ->and($homeRoleCounts[StaffRole::Therapist->value] ?? 0)->toBe(1); + + expect($awayRoleCounts[StaffRole::Coach->value] ?? 0)->toBe(1) + ->and($awayRoleCounts[StaffRole::AssistantCoach->value] ?? 0)->toBe(1) + ->and($awayRoleCounts[StaffRole::Doctor->value] ?? 0)->toBe(1) + ->and($awayRoleCounts[StaffRole::Therapist->value] ?? 0)->toBe(0); + + expect($game->officials)->toHaveCount(count(OfficialRole::cases())); +}); From d8897c4ed22795bc6d93426e34ce01504ccc24d6 Mon Sep 17 00:00:00 2001 From: Giulio Troccoli-Allard Date: Sat, 28 Mar 2026 16:59:30 +0000 Subject: [PATCH 19/63] fix: variouos fixes --- app/Livewire/Scoreboard.php | 84 ++++++++++++++++++- app/Livewire/TeamRoster.php | 2 +- app/Services/GameState/GameStateProjector.php | 2 + resources/views/livewire/court.blade.php | 32 ++++--- resources/views/livewire/game.blade.php | 2 +- resources/views/livewire/scoreboard.blade.php | 16 ++-- tests/Feature/CourtTest.php | 48 +++++++++++ tests/Feature/GameStateProjectionTest.php | 23 +++++ tests/Feature/TeamRosterTest.php | 44 +++++++++- 9 files changed, 229 insertions(+), 24 deletions(-) diff --git a/app/Livewire/Scoreboard.php b/app/Livewire/Scoreboard.php index e731757..5ee6835 100644 --- a/app/Livewire/Scoreboard.php +++ b/app/Livewire/Scoreboard.php @@ -5,19 +5,101 @@ namespace App\Livewire; use App\Data\GameState\GameState; +use App\Enums\TeamAB; +use App\Enums\TeamSide; +use App\Models\Game; +use App\Services\CacheRepository; use Illuminate\Contracts\View\View; +use Livewire\Attributes\Locked; use Livewire\Attributes\Reactive; use Livewire\Component; class Scoreboard extends Component { + #[Reactive] + #[Locked] + public ?int $gameId = null; + #[Reactive] public ?GameState $gameState = null; public function render(): View { + $scoreboardState = $this->gameState ?? GameState::initial(); + $teamAOnLeft = $this->isTeamAOnLeft($scoreboardState); + $teamCodes = $this->teamCountryCodes(); + return view('livewire.scoreboard', [ - 'scoreboardState' => $this->gameState ?? GameState::initial(), + 'leftTeam' => $teamAOnLeft ? TeamAB::TeamA : TeamAB::TeamB, + 'rightTeam' => $teamAOnLeft ? TeamAB::TeamB : TeamAB::TeamA, + 'leftTeamCode' => $teamAOnLeft ? $teamCodes['team_a'] : $teamCodes['team_b'], + 'rightTeamCode' => $teamAOnLeft ? $teamCodes['team_b'] : $teamCodes['team_a'], + 'leftSets' => $teamAOnLeft ? $scoreboardState->setsWonTeamA : $scoreboardState->setsWonTeamB, + 'rightSets' => $teamAOnLeft ? $scoreboardState->setsWonTeamB : $scoreboardState->setsWonTeamA, + 'leftPoints' => $teamAOnLeft ? $scoreboardState->scoreTeamA : $scoreboardState->scoreTeamB, + 'rightPoints' => $teamAOnLeft ? $scoreboardState->scoreTeamB : $scoreboardState->scoreTeamA, ]); } + + private function isTeamAOnLeft(GameState $scoreboardState): bool + { + return ($scoreboardState->setsWonTeamA + $scoreboardState->setsWonTeamB) % 2 === 0; + } + + /** + * @return array{team_a: string, team_b: string} + */ + private function teamCountryCodes(): array + { + $game = $this->activeGame(); + + if ($game === null) { + return [ + 'team_a' => TeamAB::TeamA->label(), + 'team_b' => TeamAB::TeamB->label(), + ]; + } + + $teamASide = $this->teamASideForTossSets($game); + + if ($teamASide === TeamSide::Home) { + return [ + 'team_a' => $game->homeTeam->country_code, + 'team_b' => $game->awayTeam->country_code, + ]; + } + + return [ + 'team_a' => $game->awayTeam->country_code, + 'team_b' => $game->homeTeam->country_code, + ]; + } + + private function teamASideForTossSets(Game $game): TeamSide + { + $tossPayload = $this->cacheRepository()->latestTossPayload($game); + + if ($tossPayload === null) { + return TeamSide::Home; + } + + return $tossPayload->teamA; + } + + private function activeGame(): ?Game + { + if ($this->gameId === null) { + return null; + } + + return Game::query() + ->with(['homeTeam', 'awayTeam']) + ->whereKey($this->gameId) + ->first(); + } + + private function cacheRepository(): CacheRepository + { + return app(CacheRepository::class); + } } diff --git a/app/Livewire/TeamRoster.php b/app/Livewire/TeamRoster.php index b6d8af4..c813f82 100644 --- a/app/Livewire/TeamRoster.php +++ b/app/Livewire/TeamRoster.php @@ -55,7 +55,7 @@ public function render(): View 'placeholderCount' => $placeholderCount, 'hasRosterPlayers' => $rosterPlayerCount > 0, 'staffMarkers' => $this->staffMarkers(), - 'reverseStaffOrder' => $this->team === TeamAB::TeamB, + 'reverseStaffOrder' => $this->leftSide, 'keyPrefix' => $this->leftSide ? 'left-player' : 'right-player', 'markerTone' => $this->team === TeamAB::TeamA ? 'bg-blue-600' : 'bg-red-600', ]); diff --git a/app/Services/GameState/GameStateProjector.php b/app/Services/GameState/GameStateProjector.php index dbc975d..e1ffc1a 100644 --- a/app/Services/GameState/GameStateProjector.php +++ b/app/Services/GameState/GameStateProjector.php @@ -160,6 +160,8 @@ private function applySetEnded(GameState $state): GameState : TeamAB::TeamA; } + $state->scoreTeamA = 0; + $state->scoreTeamB = 0; $state->rotationTeamA = []; $state->rotationTeamB = []; $state->setInProgress = false; diff --git a/resources/views/livewire/court.blade.php b/resources/views/livewire/court.blade.php index e4b90fc..2940b12 100644 --- a/resources/views/livewire/court.blade.php +++ b/resources/views/livewire/court.blade.php @@ -84,22 +84,34 @@ class="absolute {{ $markerPositionClass }} flex h-8 w-8 items-center justify-cen
@@ -137,15 +149,15 @@ class="rounded-md bg-red-600 px-3 py-1 text-xs font-semibold text-white transiti
+ :key="'lineup-submission-left-'.$leftTeam->value" />
diff --git a/resources/views/livewire/game.blade.php b/resources/views/livewire/game.blade.php index ac5ca67..61c5947 100644 --- a/resources/views/livewire/game.blade.php +++ b/resources/views/livewire/game.blade.php @@ -1,6 +1,6 @@
- + diff --git a/resources/views/livewire/scoreboard.blade.php b/resources/views/livewire/scoreboard.blade.php index 292a2b3..1c51900 100644 --- a/resources/views/livewire/scoreboard.blade.php +++ b/resources/views/livewire/scoreboard.blade.php @@ -4,24 +4,24 @@ class="absolute top-6 left-1/2 z-20 w-[300px] -translate-x-1/2 rounded-xl border border-slate-200 bg-white/95 px-6 py-4 shadow-sm backdrop-blur" >
- Team A + {{ $leftTeamCode }} Sets - Team B + {{ $rightTeamCode }}
- {{ $scoreboardState->setsWonTeamA }} + {{ $leftSets }} : - {{ $scoreboardState->setsWonTeamB }} + {{ $rightSets }}
- Team A + {{ $leftTeamCode }} Points - Team B + {{ $rightTeamCode }}
- {{ $scoreboardState->scoreTeamA }} + {{ $leftPoints }} : - {{ $scoreboardState->scoreTeamB }} + {{ $rightPoints }}
diff --git a/tests/Feature/CourtTest.php b/tests/Feature/CourtTest.php index e0bf2c3..2ad0fb5 100644 --- a/tests/Feature/CourtTest.php +++ b/tests/Feature/CourtTest.php @@ -262,6 +262,39 @@ ]); }); +test('court keeps lineup submission order aligned with left and right sides in set one', function (): void { + $game = gameWithNumberedRosters(); + $game->recordToss(TeamSide::Home, TeamAB::TeamA); + + Livewire::test(Court::class, [ + 'gameId' => $game->getKey(), + 'gameState' => gameState([ + 'set_number' => 1, + 'set_in_progress' => false, + ]), + ])->assertSeeInOrder([ + 'submit-lineup-team_a', + 'submit-lineup-team_b', + ]); +}); + +test('court swaps lineup submission order as soon as a set ends before the next set starts', function (): void { + $game = gameWithNumberedRosters(); + $game->recordToss(TeamSide::Home, TeamAB::TeamA); + + Livewire::test(Court::class, [ + 'gameId' => $game->getKey(), + 'gameState' => gameState([ + 'set_number' => 1, + 'sets_won_team_a' => 1, + 'set_in_progress' => false, + ]), + ])->assertSeeInOrder([ + 'submit-lineup-team_b', + 'submit-lineup-team_a', + ]); +}); + test('court shows rally winner buttons only while a set is in progress', function (): void { $game = Game::factory()->create(); @@ -282,6 +315,21 @@ ->assertSeeHtml('data-rally-winner-button="team_b"'); }); +test('court swaps rally winner buttons as soon as sides swap', function (): void { + $game = Game::factory()->create(); + + Livewire::test(Court::class, [ + 'gameId' => $game->getKey(), + 'gameState' => gameState([ + 'set_number' => 2, + 'sets_won_team_a' => 1, + 'set_in_progress' => true, + ]), + ]) + ->assertSeeHtml('data-rally-winner-side-team="left-team_b"') + ->assertSeeHtml('data-rally-winner-side-team="right-team_a"'); +}); + test('court records rally winner for the selected team and dispatches a refresh event', function (): void { $game = gameWithStartedSet(); diff --git a/tests/Feature/GameStateProjectionTest.php b/tests/Feature/GameStateProjectionTest.php index 988da5b..8046a42 100644 --- a/tests/Feature/GameStateProjectionTest.php +++ b/tests/Feature/GameStateProjectionTest.php @@ -113,6 +113,29 @@ ->and($latest->servingTeam)->toBe(TeamAB::TeamA); }); +test('state resets points as soon as a set ends before the next set starts', function (): void { + $homeTeam = Team::factory()->create(); + $awayTeam = Team::factory()->create(); + $game = Game::factory()->betweenTeams($homeTeam, $awayTeam)->create(); + + $game->recordToss(TeamSide::Home, TeamAB::TeamA); + $game->recordSetStarted(); + + for ($index = 0; $index < 25; $index++) { + $game->recordRallyWinner(TeamAB::TeamA); + } + + $state = $game->fresh()->stateAt(); + + expect($game->fresh()->events->last()->type)->toBe(GameEventType::SetEnded) + ->and($state->setNumber)->toBe(1) + ->and($state->setInProgress)->toBeFalse() + ->and($state->setsWonTeamA)->toBe(1) + ->and($state->setsWonTeamB)->toBe(0) + ->and($state->scoreTeamA)->toBe(0) + ->and($state->scoreTeamB)->toBe(0); +}); + test('game state snapshot accepts a serialized serving team and casts it back to enum', function (): void { $game = Game::factory()->create(); diff --git a/tests/Feature/TeamRosterTest.php b/tests/Feature/TeamRosterTest.php index dc489cb..5edc991 100644 --- a/tests/Feature/TeamRosterTest.php +++ b/tests/Feature/TeamRosterTest.php @@ -143,7 +143,7 @@ ->assertDontSeeHtml('data-team-roster-number="8"'); }); -test('team roster renders staff role circles left to right for team a', function (): void { +test('team roster renders left-side staff role circles in reverse row order for team a', function (): void { $game = gameWithNumberedRostersForTeamRoster(); Livewire::test(TeamRoster::class, [ @@ -152,7 +152,7 @@ 'leftSide' => true, ]) ->assertSeeHtml('data-team-roster-staff-list') - ->assertDontSeeHtml('flex-row-reverse') + ->assertSeeHtml('flex-row-reverse') ->assertSeeInOrder([ 'data-team-roster-staff-role="C"', 'data-team-roster-staff-role="A1"', @@ -162,13 +162,32 @@ ]); }); -test('team roster renders staff role circles in reverse row order for team b', function (): void { +test('team roster renders right-side staff role circles left to right for team b', function (): void { $game = gameWithNumberedRostersForTeamRoster(); Livewire::test(TeamRoster::class, [ 'gameId' => $game->getKey(), 'team' => TeamAB::TeamB, 'leftSide' => false, + ]) + ->assertSeeHtml('data-team-roster-staff-list') + ->assertDontSeeHtml('flex-row-reverse') + ->assertSeeInOrder([ + 'data-team-roster-staff-role="C"', + 'data-team-roster-staff-role="A1"', + 'data-team-roster-staff-role="A2"', + 'data-team-roster-staff-role="D"', + 'data-team-roster-staff-role="T"', + ]); +}); + +test('team roster applies left-side staff orientation even when the displayed team is team b', function (): void { + $game = gameWithNumberedRostersForTeamRoster(); + + Livewire::test(TeamRoster::class, [ + 'gameId' => $game->getKey(), + 'team' => TeamAB::TeamB, + 'leftSide' => true, ]) ->assertSeeHtml('data-team-roster-staff-list') ->assertSeeHtml('flex-row-reverse') @@ -181,6 +200,25 @@ ]); }); +test('team roster applies right-side staff orientation even when the displayed team is team a', function (): void { + $game = gameWithNumberedRostersForTeamRoster(); + + Livewire::test(TeamRoster::class, [ + 'gameId' => $game->getKey(), + 'team' => TeamAB::TeamA, + 'leftSide' => false, + ]) + ->assertSeeHtml('data-team-roster-staff-list') + ->assertDontSeeHtml('flex-row-reverse') + ->assertSeeInOrder([ + 'data-team-roster-staff-role="C"', + 'data-team-roster-staff-role="A1"', + 'data-team-roster-staff-role="A2"', + 'data-team-roster-staff-role="D"', + 'data-team-roster-staff-role="T"', + ]); +}); + test('team roster omits staff role circles when those roles are not on the roster', function (): void { $homeTeam = Team::factory()->create(); $awayTeam = Team::factory()->create(); From 7849f03620e3daceb6e8b188d3c850a488cf54bd Mon Sep 17 00:00:00 2001 From: Giulio Troccoli-Allard Date: Sat, 28 Mar 2026 17:14:52 +0000 Subject: [PATCH 20/63] feat: enforce lineup preconditions before starting sets --- .../GameState/GameEventRuleValidator.php | 18 +++++ tests/Feature/CourtTest.php | 12 ++++ tests/Feature/GameComponentStateTest.php | 36 ++++++++++ tests/Feature/GameEventReactorTest.php | 40 +++++++++++ tests/Feature/GameEventTest.php | 66 +++++++++++++++++-- tests/Feature/GameStateProjectionTest.php | 41 +++++++++++- 6 files changed, 207 insertions(+), 6 deletions(-) diff --git a/app/Services/GameState/GameEventRuleValidator.php b/app/Services/GameState/GameEventRuleValidator.php index af768cd..85ae8c5 100644 --- a/app/Services/GameState/GameEventRuleValidator.php +++ b/app/Services/GameState/GameEventRuleValidator.php @@ -5,6 +5,7 @@ namespace App\Services\GameState; use App\Enums\GameEventType; +use App\Enums\TeamAB; use App\Exceptions\InvalidGameEventTransition; use App\Models\Game; @@ -35,6 +36,14 @@ public function assertCanRecordSetStarted(Game $game): void if ($state->setInProgress) { $this->fail('A set is already in progress.'); } + + $upcomingSet = $state->setNumber + 1; + $hasTeamALineup = $this->hasSubmittedLineupForSet($game, TeamAB::TeamA, $upcomingSet); + $hasTeamBLineup = $this->hasSubmittedLineupForSet($game, TeamAB::TeamB, $upcomingSet); + + if (! $hasTeamALineup || ! $hasTeamBLineup) { + $this->fail('Both team lineups must be submitted before starting the set.'); + } } public function assertCanRecordLineup(Game $game, int $set): void @@ -126,6 +135,15 @@ private function hasRecordedToss(Game $game): bool ->exists(); } + private function hasSubmittedLineupForSet(Game $game, TeamAB $team, int $setNumber): bool + { + return $game->events() + ->where('type', GameEventType::LineupSubmitted) + ->where('payload->set', $setNumber) + ->where('payload->team', $team->value) + ->exists(); + } + private function fail(string $message): never { throw new InvalidGameEventTransition($message); diff --git a/tests/Feature/CourtTest.php b/tests/Feature/CourtTest.php index 2ad0fb5..21581d8 100644 --- a/tests/Feature/CourtTest.php +++ b/tests/Feature/CourtTest.php @@ -379,7 +379,19 @@ function gameWithNumberedRosters(): Game function gameWithStartedSet(): Game { $game = Game::factory()->create(); + $homePlayers = Player::factory()->for($game->homeTeam)->count(6)->create(); + foreach ($homePlayers as $index => $player) { + $game->addPlayer($player, number: $index + 1); + } + + $awayPlayers = Player::factory()->for($game->awayTeam)->count(6)->create(); + foreach ($awayPlayers as $index => $player) { + $game->addPlayer($player, number: $index + 11); + } + $game->recordToss(TeamSide::Home, TeamAB::TeamA); + $game->recordLineup(1, TeamAB::TeamA, [1 => 1, 2 => 2, 3 => 3, 4 => 4, 5 => 5, 6 => 6]); + $game->recordLineup(1, TeamAB::TeamB, [1 => 11, 2 => 12, 3 => 13, 4 => 14, 5 => 15, 6 => 16]); $game->recordSetStarted(); return $game; diff --git a/tests/Feature/GameComponentStateTest.php b/tests/Feature/GameComponentStateTest.php index 196518d..9b6e05d 100644 --- a/tests/Feature/GameComponentStateTest.php +++ b/tests/Feature/GameComponentStateTest.php @@ -7,6 +7,7 @@ use App\Enums\TeamSide; use App\Livewire\Game; use App\Models\Game as GameModel; +use App\Models\Player; use Illuminate\Foundation\Testing\RefreshDatabase; use Livewire\Livewire; @@ -39,12 +40,14 @@ test('game component renders sets and current set points for both teams', function (): void { $game = GameModel::factory()->create(); $game->recordToss(TeamSide::Home, TeamAB::TeamA); + ensureLineupsForSet($game, 1); $game->recordSetStarted(); for ($index = 0; $index < 25; $index++) { $game->recordRallyWinner(TeamAB::TeamA); } + ensureLineupsForSet($game, 2); $game->recordSetStarted(); for ($index = 0; $index < 7; $index++) { @@ -71,3 +74,36 @@ '3', ]); }); + +function ensureLineupsForSet(GameModel $game, int $set): void +{ + if ($game->players()->count() === 0) { + $homePlayers = Player::factory()->for($game->homeTeam)->count(6)->create(); + foreach ($homePlayers as $index => $player) { + $game->addPlayer($player, number: $index + 1); + } + + $awayPlayers = Player::factory()->for($game->awayTeam)->count(6)->create(); + foreach ($awayPlayers as $index => $player) { + $game->addPlayer($player, number: $index + 11); + } + } + + $game->recordLineup($set, TeamAB::TeamA, lineupPositions(1)); + $game->recordLineup($set, TeamAB::TeamB, lineupPositions(11)); +} + +/** + * @return array + */ +function lineupPositions(int $start): array +{ + return [ + 1 => $start, + 2 => $start + 1, + 3 => $start + 2, + 4 => $start + 3, + 5 => $start + 4, + 6 => $start + 5, + ]; +} diff --git a/tests/Feature/GameEventReactorTest.php b/tests/Feature/GameEventReactorTest.php index 014e078..a1006d4 100644 --- a/tests/Feature/GameEventReactorTest.php +++ b/tests/Feature/GameEventReactorTest.php @@ -4,6 +4,7 @@ use App\Enums\TeamAB; use App\Enums\TeamSide; use App\Models\Game; +use App\Models\Player; use App\Models\Team; use Illuminate\Foundation\Testing\RefreshDatabase; @@ -15,6 +16,8 @@ $game = Game::factory()->betweenTeams($homeTeam, $awayTeam)->create(); $game->recordToss(TeamSide::Home, TeamAB::TeamA); + ensureRostersForSetStart($game); + submitReactorLineupsForSet($game, 1); $game->recordSetStarted(); for ($i = 0; $i < 24; $i++) { @@ -42,8 +45,10 @@ $game = Game::factory()->betweenTeams($homeTeam, $awayTeam)->create(); $game->recordToss(TeamSide::Home, TeamAB::TeamA); + ensureRostersForSetStart($game); foreach ([TeamAB::TeamA, TeamAB::TeamB, TeamAB::TeamA, TeamAB::TeamB] as $winner) { + submitReactorLineupsForSet($game, $game->stateAt()->setNumber + 1); $game->recordSetStarted(); for ($i = 0; $i < 25; $i++) { @@ -51,6 +56,7 @@ } } + submitReactorLineupsForSet($game, 5); $game->recordSetStarted(); // 5th set for ($i = 0; $i < 14; $i++) { @@ -72,3 +78,37 @@ ->and($state->setsWonTeamA)->toBe(3) ->and($state->setsWonTeamB)->toBe(2); }); + +function ensureRostersForSetStart(Game $game): void +{ + $homePlayers = Player::factory()->for($game->homeTeam)->count(6)->create(); + foreach ($homePlayers as $index => $player) { + $game->addPlayer($player, number: $index + 1); + } + + $awayPlayers = Player::factory()->for($game->awayTeam)->count(6)->create(); + foreach ($awayPlayers as $index => $player) { + $game->addPlayer($player, number: $index + 11); + } +} + +function submitReactorLineupsForSet(Game $game, int $set): void +{ + $game->recordLineup($set, TeamAB::TeamA, reactorLineupForSet(1)); + $game->recordLineup($set, TeamAB::TeamB, reactorLineupForSet(11)); +} + +/** + * @return array + */ +function reactorLineupForSet(int $start): array +{ + return [ + 1 => $start, + 2 => $start + 1, + 3 => $start + 2, + 4 => $start + 3, + 5 => $start + 4, + 6 => $start + 5, + ]; +} diff --git a/tests/Feature/GameEventTest.php b/tests/Feature/GameEventTest.php index 694ec06..ada6e8e 100644 --- a/tests/Feature/GameEventTest.php +++ b/tests/Feature/GameEventTest.php @@ -23,6 +23,8 @@ function prepareActiveSet(Game $game): void { $game->recordToss(TeamSide::Home, TeamAB::TeamA); + ensureStartingLineupRoster($game); + submitLineupsForSet($game, 1); $game->recordSetStarted(); } @@ -48,6 +50,29 @@ function lineupWithRosterNumbers(int $start = 1): array ]; } +function ensureStartingLineupRoster(Game $game): void +{ + if ($game->players()->count() > 0) { + return; + } + + $homePlayers = Player::factory()->for($game->homeTeam)->count(6)->create(); + foreach ($homePlayers as $index => $player) { + $game->addPlayer($player, number: $index + 1); + } + + $awayPlayers = Player::factory()->for($game->awayTeam)->count(6)->create(); + foreach ($awayPlayers as $index => $player) { + $game->addPlayer($player, number: $index + 11); + } +} + +function submitLineupsForSet(Game $game, int $set): void +{ + $game->recordLineup($set, TeamAB::TeamA, lineupWithRosterNumbers(1)); + $game->recordLineup($set, TeamAB::TeamB, lineupWithRosterNumbers(11)); +} + test('a toss can be recorded with the correct type and payload', function (): void { $homeTeam = Team::factory()->create(); $awayTeam = Team::factory()->create(); @@ -239,12 +264,18 @@ function lineupWithRosterNumbers(int $start = 1): array $awayTeam = Team::factory()->create(); $game = Game::factory()->betweenTeams($homeTeam, $awayTeam)->create(); - $players = Player::factory()->for($homeTeam)->count(6)->create(); - foreach ($players as $i => $player) { + $homePlayers = Player::factory()->for($homeTeam)->count(6)->create(); + foreach ($homePlayers as $i => $player) { $game->addPlayer($player, number: $i + 1); } + $awayPlayers = Player::factory()->for($awayTeam)->count(6)->create(); + foreach ($awayPlayers as $i => $player) { + $game->addPlayer($player, number: $i + 11); + } + $game->recordToss(TeamSide::Home, TeamAB::TeamA); + submitLineupsForSet($game, 1); $game->recordSetStarted(); $positions = lineupWithRosterNumbers(); @@ -258,12 +289,18 @@ function lineupWithRosterNumbers(int $start = 1): array $awayTeam = Team::factory()->create(); $game = Game::factory()->betweenTeams($homeTeam, $awayTeam)->create(); - $players = Player::factory()->for($homeTeam)->count(6)->create(); - foreach ($players as $i => $player) { + $homePlayers = Player::factory()->for($homeTeam)->count(6)->create(); + foreach ($homePlayers as $i => $player) { $game->addPlayer($player, number: $i + 1); } + $awayPlayers = Player::factory()->for($awayTeam)->count(6)->create(); + foreach ($awayPlayers as $i => $player) { + $game->addPlayer($player, number: $i + 11); + } + $game->recordToss(TeamSide::Home, TeamAB::TeamA); + submitLineupsForSet($game, 1); $game->recordSetStarted(); winSet($game, TeamAB::TeamA); @@ -412,9 +449,11 @@ function lineupWithRosterNumbers(int $start = 1): array $game = Game::factory()->betweenTeams($homeTeam, $awayTeam)->create(); $game->recordToss(TeamSide::Home, TeamAB::TeamA); + ensureStartingLineupRoster($game); + submitLineupsForSet($game, 1); $game->recordSetStarted(); - expect($game->events)->toHaveCount(2); + expect($game->events)->toHaveCount(4); $event = $game->events->last(); expect($event->type)->toBe(GameEventType::SetStarted) @@ -442,7 +481,9 @@ function lineupWithRosterNumbers(int $start = 1): array $game = Game::factory()->betweenTeams($homeTeam, $awayTeam)->create(); $game->recordToss(TeamSide::Home, TeamAB::TeamA); + ensureStartingLineupRoster($game); for ($set = 0; $set < 3; $set++) { + submitLineupsForSet($game, $set + 1); $game->recordSetStarted(); winSet($game, TeamAB::TeamA); } @@ -462,6 +503,19 @@ function lineupWithRosterNumbers(int $start = 1): array ->toThrow(InvalidGameEventTransition::class, 'A set cannot start before the toss has been recorded.'); }); +test('a set cannot start until both lineups are submitted for the upcoming set', function (): void { + $homeTeam = Team::factory()->create(); + $awayTeam = Team::factory()->create(); + $game = Game::factory()->betweenTeams($homeTeam, $awayTeam)->create(); + + $game->recordToss(TeamSide::Home, TeamAB::TeamA); + ensureStartingLineupRoster($game); + $game->recordLineup(1, TeamAB::TeamA, lineupWithRosterNumbers(1)); + + expect(fn () => $game->recordSetStarted()) + ->toThrow(InvalidGameEventTransition::class, 'Both team lineups must be submitted before starting the set.'); +}); + test('a set cannot end before score reaches 25 with a two-point advantage', function (): void { $homeTeam = Team::factory()->create(); $awayTeam = Team::factory()->create(); @@ -482,6 +536,8 @@ function lineupWithRosterNumbers(int $start = 1): array $game = Game::factory()->betweenTeams($homeTeam, $awayTeam)->create(); $game->recordToss(TeamSide::Home, TeamAB::TeamA); + ensureStartingLineupRoster($game); + submitLineupsForSet($game, 1); $game->recordSetStarted(); winSet($game, TeamAB::TeamA); diff --git a/tests/Feature/GameStateProjectionTest.php b/tests/Feature/GameStateProjectionTest.php index 8046a42..a77344a 100644 --- a/tests/Feature/GameStateProjectionTest.php +++ b/tests/Feature/GameStateProjectionTest.php @@ -84,9 +84,11 @@ $homeTeam = Team::factory()->create(); $awayTeam = Team::factory()->create(); $game = Game::factory()->betweenTeams($homeTeam, $awayTeam)->create(); + ensureRostersForProjection($game, $homeTeam, $awayTeam); Carbon::setTestNow('2026-03-07 10:00:00'); $game->recordToss(TeamSide::Home, TeamAB::TeamA); + submitProjectionLineupsForSet($game, 1); Carbon::setTestNow('2026-03-07 10:01:00'); $game->recordSetStarted(); @@ -106,7 +108,7 @@ $latest = $game->fresh()->stateAt(); - expect($game->fresh()->stateSnapshots)->toHaveCount(3) + expect($game->fresh()->stateSnapshots)->toHaveCount(5) ->and($latest->setNumber)->toBe(1) ->and($latest->scoreTeamA)->toBe(1) ->and($latest->scoreTeamB)->toBe(0) @@ -117,8 +119,10 @@ $homeTeam = Team::factory()->create(); $awayTeam = Team::factory()->create(); $game = Game::factory()->betweenTeams($homeTeam, $awayTeam)->create(); + ensureRostersForProjection($game, $homeTeam, $awayTeam); $game->recordToss(TeamSide::Home, TeamAB::TeamA); + submitProjectionLineupsForSet($game, 1); $game->recordSetStarted(); for ($index = 0; $index < 25; $index++) { @@ -169,3 +173,38 @@ expect($snapshot->serving_team)->toBe(TeamAB::TeamB) ->and($snapshot->getRawOriginal('serving_team'))->toBe(TeamAB::TeamB->value); }); + +function ensureRostersForProjection(Game $game, Team $homeTeam, Team $awayTeam): void +{ + $homePlayers = Player::factory()->for($homeTeam)->count(7)->create(); + $awayPlayers = Player::factory()->for($awayTeam)->count(6)->create(); + + foreach ($homePlayers as $index => $player) { + $game->addPlayer($player, number: $index + 1); + } + + foreach ($awayPlayers as $index => $player) { + $game->addPlayer($player, number: $index + 1); + } +} + +function submitProjectionLineupsForSet(Game $game, int $set): void +{ + $game->recordLineup($set, TeamAB::TeamA, projectionLineup()); + $game->recordLineup($set, TeamAB::TeamB, projectionLineup()); +} + +/** + * @return array + */ +function projectionLineup(): array +{ + return [ + 1 => 1, + 2 => 2, + 3 => 3, + 4 => 4, + 5 => 5, + 6 => 6, + ]; +} From a138e15677cb31b4b387825d191eb11dd0fb3052 Mon Sep 17 00:00:00 2001 From: Giulio Troccoli-Allard Date: Sat, 28 Mar 2026 17:38:55 +0000 Subject: [PATCH 21/63] fix: merged point 1 branch --- app/Livewire/TeamRoster.php | 29 +++++------------------ database/seeders/ControlledGameSeeder.php | 7 ++---- tests/Feature/ExampleTest.php | 4 +--- 3 files changed, 9 insertions(+), 31 deletions(-) diff --git a/app/Livewire/TeamRoster.php b/app/Livewire/TeamRoster.php index c813f82..3aedc25 100644 --- a/app/Livewire/TeamRoster.php +++ b/app/Livewire/TeamRoster.php @@ -117,29 +117,12 @@ private function staffMarkers(): array $hasTherapist = false; foreach ($staff as $staffMember) { - $role = $staffMember['role']; - - if ($role === StaffRole::Coach) { - $hasCoach = true; - - continue; - } - - if ($role === StaffRole::AssistantCoach) { - $assistantCoaches++; - - continue; - } - - if ($role === StaffRole::Doctor) { - $hasDoctor = true; - - continue; - } - - if ($role === StaffRole::Therapist) { - $hasTherapist = true; - } + match ($staffMember['role']) { + StaffRole::Coach => $hasCoach = true, + StaffRole::AssistantCoach => $assistantCoaches++, + StaffRole::Doctor => $hasDoctor = true, + StaffRole::Therapist => $hasTherapist = true, + }; } $markers = []; diff --git a/database/seeders/ControlledGameSeeder.php b/database/seeders/ControlledGameSeeder.php index b6c5ea2..c4faae0 100644 --- a/database/seeders/ControlledGameSeeder.php +++ b/database/seeders/ControlledGameSeeder.php @@ -74,11 +74,8 @@ public function run(): void ->scheduledAt(CarbonImmutable::now()->addDay()->setTime(20, 30)) ->create(); - $officials = Official::factory()->count(count(OfficialRole::cases()))->create(); - - foreach (OfficialRole::cases() as $index => $role) { - $official = $officials[$index]; - $game->addOfficial($official, $role); + foreach (OfficialRole::cases() as $role) { + $game->addOfficial(Official::factory()->create(), $role); } $homeNumbers = range(1, 13); diff --git a/tests/Feature/ExampleTest.php b/tests/Feature/ExampleTest.php index bef5096..8fd9ded 100644 --- a/tests/Feature/ExampleTest.php +++ b/tests/Feature/ExampleTest.php @@ -21,9 +21,7 @@ $response->assertSuccessful() ->assertSee('id="game-canvas"', false) ->assertSee('bg-sky-100') - ->assertSee('id="volleyball-court"', false) - ->assertSee('Submit Toss Result') - ->assertSee('Submit Lineup'); + ->assertSee('id="volleyball-court"', false); }); test('the game livewire component renders the court component', function (): void { From c70bda575505ba71946c27088443baef3fcdb862 Mon Sep 17 00:00:00 2001 From: Giulio Troccoli-Allard Date: Sat, 28 Mar 2026 17:19:41 +0000 Subject: [PATCH 22/63] refactor: centralize set scoring thresholds --- app/Services/GameState/GameEventReactor.php | 10 ++-- .../GameState/GameEventRuleValidator.php | 12 +++-- app/Services/GameState/SetScoringRules.php | 32 ++++++++++++ tests/Feature/GameEventTest.php | 49 +++++++++++++++++++ 4 files changed, 94 insertions(+), 9 deletions(-) create mode 100644 app/Services/GameState/SetScoringRules.php diff --git a/app/Services/GameState/GameEventReactor.php b/app/Services/GameState/GameEventReactor.php index 051ad06..5e8163e 100644 --- a/app/Services/GameState/GameEventReactor.php +++ b/app/Services/GameState/GameEventReactor.php @@ -12,6 +12,10 @@ class GameEventReactor { + public function __construct( + protected SetScoringRules $setScoringRules + ) {} + public function reactTo(GameEvent $event): void { match ($event->type) { @@ -29,11 +33,7 @@ protected function onRallyEnded(GameEvent $event): void return; } - $targetPoints = $state->setNumber === 5 ? 15 : 25; - $highestScore = max($state->scoreTeamA, $state->scoreTeamB); - $scoreDifference = abs($state->scoreTeamA - $state->scoreTeamB); - - if ($highestScore < $targetPoints || $scoreDifference < 2) { + if (! $this->setScoringRules->canEndSet($state->setNumber, $state->scoreTeamA, $state->scoreTeamB)) { return; } diff --git a/app/Services/GameState/GameEventRuleValidator.php b/app/Services/GameState/GameEventRuleValidator.php index 85ae8c5..b875745 100644 --- a/app/Services/GameState/GameEventRuleValidator.php +++ b/app/Services/GameState/GameEventRuleValidator.php @@ -11,6 +11,10 @@ class GameEventRuleValidator { + public function __construct( + protected SetScoringRules $setScoringRules + ) {} + public function assertCanRecordToss(Game $game): void { $state = $game->stateAt(); @@ -104,10 +108,10 @@ public function assertCanRecordSetEnded(Game $game): void $this->fail('A set can only end while it is in progress.'); } - $scoreDiff = abs($state->scoreTeamA - $state->scoreTeamB); - $highestScore = max($state->scoreTeamA, $state->scoreTeamB); - if ($highestScore < 25 || $scoreDiff < 2) { - $this->fail('A set can only end when a team has at least 25 points with a 2-point advantage.'); + $targetPoints = $this->setScoringRules->targetPoints($state->setNumber); + + if (! $this->setScoringRules->canEndSet($state->setNumber, $state->scoreTeamA, $state->scoreTeamB)) { + $this->fail("A set can only end when a team has at least {$targetPoints} points with a 2-point advantage."); } } diff --git a/app/Services/GameState/SetScoringRules.php b/app/Services/GameState/SetScoringRules.php new file mode 100644 index 0000000..0c3f0b7 --- /dev/null +++ b/app/Services/GameState/SetScoringRules.php @@ -0,0 +1,32 @@ += $this->targetPoints($setNumber) + && $scoreDifference >= self::MINIMUM_WIN_MARGIN; + } +} diff --git a/tests/Feature/GameEventTest.php b/tests/Feature/GameEventTest.php index ada6e8e..a8fcabc 100644 --- a/tests/Feature/GameEventTest.php +++ b/tests/Feature/GameEventTest.php @@ -14,8 +14,10 @@ use App\Exceptions\InvalidGameEventTransition; use App\Models\Game; use App\Models\GameEvent; +use App\Models\GameStateSnapshot; use App\Models\Player; use App\Models\Team; +use App\Services\GameState\GameEventRuleValidator; use Illuminate\Foundation\Testing\RefreshDatabase; uses(RefreshDatabase::class); @@ -530,6 +532,23 @@ function submitLineupsForSet(Game $game, int $set): void ->toThrow(InvalidGameEventTransition::class, 'A set can only end when a team has at least 25 points with a 2-point advantage.'); }); +test('the deciding set cannot end before 15 points with a two-point advantage', function (): void { + $game = Game::factory()->create(); + createSnapshotForSetScore($game, setNumber: 5, scoreTeamA: 14, scoreTeamB: 12); + + expect(fn () => app(GameEventRuleValidator::class)->assertCanRecordSetEnded($game)) + ->toThrow(InvalidGameEventTransition::class, 'A set can only end when a team has at least 15 points with a 2-point advantage.'); +}); + +test('the deciding set can end at 15 points with a two-point advantage', function (): void { + $game = Game::factory()->create(); + createSnapshotForSetScore($game, setNumber: 5, scoreTeamA: 15, scoreTeamB: 13); + + app(GameEventRuleValidator::class)->assertCanRecordSetEnded($game); + + expect(true)->toBeTrue(); +}); + test('a game cannot end before one team has won three sets', function (): void { $homeTeam = Team::factory()->create(); $awayTeam = Team::factory()->create(); @@ -544,3 +563,33 @@ function submitLineupsForSet(Game $game, int $set): void expect(fn () => $game->recordGameEnded()) ->toThrow(InvalidGameEventTransition::class, 'A game can only end after one team has won three sets.'); }); + +function createSnapshotForSetScore(Game $game, int $setNumber, int $scoreTeamA, int $scoreTeamB): void +{ + $event = GameEvent::withoutEvents(fn (): GameEvent => GameEvent::query()->create([ + 'game_id' => $game->getKey(), + 'type' => GameEventType::SetStarted, + 'payload' => new SetStartedPayload, + 'created_at' => now(), + ])); + + GameStateSnapshot::query()->create([ + 'game_id' => $game->getKey(), + 'game_event_id' => $event->getKey(), + 'set_number' => $setNumber, + 'score_team_a' => $scoreTeamA, + 'score_team_b' => $scoreTeamB, + 'sets_won_team_a' => 0, + 'sets_won_team_b' => 0, + 'timeouts_team_a' => 0, + 'timeouts_team_b' => 0, + 'substitutions_team_a' => 0, + 'substitutions_team_b' => 0, + 'serving_team' => TeamAB::TeamA->value, + 'rotation_team_a' => [], + 'rotation_team_b' => [], + 'set_in_progress' => true, + 'game_ended' => false, + 'created_at' => now(), + ]); +} From 11558950dabc81fea49bfdc2864ff637a4d3029e Mon Sep 17 00:00:00 2001 From: Giulio Troccoli-Allard Date: Sat, 28 Mar 2026 17:24:53 +0000 Subject: [PATCH 23/63] refactor: reduce repeated Livewire data queries --- app/Livewire/Court.php | 33 +++++------ app/Livewire/LineupSubmission.php | 61 +++++++++++++------- app/Livewire/Scoreboard.php | 22 +++++-- app/Livewire/StartSetSubmission.php | 26 +++++---- app/Livewire/TeamRoster.php | 89 ++++++++++++++++------------- tests/Feature/ExampleTest.php | 4 +- 6 files changed, 138 insertions(+), 97 deletions(-) diff --git a/app/Livewire/Court.php b/app/Livewire/Court.php index 66e9e24..5318f6d 100644 --- a/app/Livewire/Court.php +++ b/app/Livewire/Court.php @@ -5,13 +5,13 @@ namespace App\Livewire; use App\Data\GameState\GameState; -use App\Enums\GameEventType; use App\Enums\TeamAB; use App\Events\Payloads\TossCompletedPayload; use App\Exceptions\InvalidGameEventTransition; use App\Models\Game; -use App\Models\GameEvent; +use App\Services\CacheRepository; use Illuminate\Contracts\View\View; +use Livewire\Attributes\Computed; use Livewire\Attributes\Reactive; use Livewire\Component; @@ -51,7 +51,7 @@ public function recordRallyWinner(string $team): void return; } - $activeGame = $this->activeGame(); + $activeGame = $this->activeGame; if ($activeGame === null) { $this->addError('rallyWinner', 'No active game is available to record the rally winner.'); @@ -83,9 +83,9 @@ public function recordRallyWinner(string $team): void */ private function courtContext(): array { - $game = $this->activeGame(); + $game = $this->activeGame; $canRecordRallyWinner = $this->canRecordRallyWinner($game); - $showRosters = $game !== null && $this->latestTossPayload($game) !== null; + $showRosters = $game !== null && $this->latestTossPayload !== null; $defaultContext = [ 'leftTeam' => TeamAB::TeamA, @@ -123,22 +123,16 @@ private function isTeamAOnLeft(): bool return $completedSets % 2 === 0; } - private function latestTossPayload(Game $game): ?TossCompletedPayload + #[Computed] + public function latestTossPayload(): ?TossCompletedPayload { - /** @var GameEvent|null $tossEvent */ - $tossEvent = $game->events() - ->reorder() - ->where('type', GameEventType::TossCompleted) - ->orderByDesc('created_at') - ->orderByDesc('id') - ->first(); - - if ($tossEvent === null) { + $game = $this->activeGame; + + if ($game === null) { return null; } - /** @var TossCompletedPayload */ - return $tossEvent->payload; + return app(CacheRepository::class)->latestTossPayload($game); } /** @@ -165,7 +159,7 @@ private function completedSetCount(): int private function canRecordRallyWinner(?Game $activeGame = null): bool { - $activeGame ??= $this->activeGame(); + $activeGame ??= $this->activeGame; if ($activeGame === null) { return false; @@ -176,7 +170,8 @@ private function canRecordRallyWinner(?Game $activeGame = null): bool return $state->setInProgress && ! $state->gameEnded; } - private function activeGame(): ?Game + #[Computed] + public function activeGame(): ?Game { if ($this->gameId === null) { return null; diff --git a/app/Livewire/LineupSubmission.php b/app/Livewire/LineupSubmission.php index 894a869..9df068c 100644 --- a/app/Livewire/LineupSubmission.php +++ b/app/Livewire/LineupSubmission.php @@ -14,9 +14,9 @@ use App\Models\GameEvent; use Flux\Flux; use Illuminate\Contracts\View\View; -use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\Facades\Validator; use InvalidArgumentException; +use Livewire\Attributes\Computed; use Livewire\Attributes\Locked; use Livewire\Attributes\Reactive; use Livewire\Component; @@ -64,7 +64,7 @@ public function submit(): void return; } - $activeGame = Game::query()->whereKey($this->gameId)->first(); + $activeGame = $this->activeGame; if ($activeGame === null) { $this->addError('submit', 'No active game is available to record the lineup.'); @@ -104,6 +104,37 @@ public function render(): View ]); } + #[Computed] + public function activeGame(): ?Game + { + return Game::query()->whereKey($this->gameId)->first(); + } + + #[Computed] + public function tossPayload(): ?TossCompletedPayload + { + $activeGame = $this->activeGame; + + if ($activeGame === null) { + return null; + } + + /** @var GameEvent|null $tossEvent */ + $tossEvent = $activeGame->events() + ->reorder() + ->where('type', GameEventType::TossCompleted) + ->orderByDesc('created_at') + ->orderByDesc('id') + ->first(); + + if ($tossEvent === null) { + return null; + } + + /** @var TossCompletedPayload */ + return $tossEvent->payload; + } + /** * @return array> */ @@ -148,7 +179,7 @@ private function lineupPositions(): array */ private function eligibleRosterNumbers(Game $game): array { - $teamSide = $this->teamSideForToss($game); + $teamSide = $this->teamSideForToss(); if ($teamSide === null) { return []; @@ -168,7 +199,7 @@ private function eligibleRosterNumbers(Game $game): array */ private function rosterNumbers(): array { - $activeGame = Game::query()->whereKey($this->gameId)->first(); + $activeGame = $this->activeGame; if ($activeGame === null) { return []; @@ -177,23 +208,14 @@ private function rosterNumbers(): array return $this->eligibleRosterNumbers($activeGame); } - private function teamSideForToss(Game $game): ?TeamSide + private function teamSideForToss(): ?TeamSide { - /** @var GameEvent|null $tossEvent */ - $tossEvent = $game->events() - ->reorder() - ->where('type', GameEventType::TossCompleted) - ->orderByDesc('created_at') - ->orderByDesc('id') - ->first(); + $tossPayload = $this->tossPayload; - if ($tossEvent === null) { + if ($tossPayload === null) { return null; } - /** @var TossCompletedPayload $tossPayload */ - $tossPayload = $tossEvent->payload; - return $this->team === TeamAB::TeamA ? $tossPayload->teamA : ($tossPayload->teamA === TeamSide::Home ? TeamSide::Away : TeamSide::Home); @@ -248,7 +270,7 @@ private function canSubmitLineup(): bool private function hasSubmittedLineupForUpcomingSet(): bool { - $activeGame = Game::query()->whereKey($this->gameId)->first(); + $activeGame = $this->activeGame; if ($activeGame === null) { return false; @@ -263,10 +285,7 @@ private function hasSubmittedLineupForUpcomingSet(): bool private function hasSubmittedToss(): bool { - return Game::query() - ->whereKey($this->gameId) - ->whereHas('events', fn (Builder $query) => $query->where('type', GameEventType::TossCompleted)) - ->exists(); + return $this->tossPayload !== null; } private function upcomingSetNumber(): int diff --git a/app/Livewire/Scoreboard.php b/app/Livewire/Scoreboard.php index 5ee6835..9bfca4e 100644 --- a/app/Livewire/Scoreboard.php +++ b/app/Livewire/Scoreboard.php @@ -10,6 +10,7 @@ use App\Models\Game; use App\Services\CacheRepository; use Illuminate\Contracts\View\View; +use Livewire\Attributes\Computed; use Livewire\Attributes\Locked; use Livewire\Attributes\Reactive; use Livewire\Component; @@ -27,7 +28,7 @@ public function render(): View { $scoreboardState = $this->gameState ?? GameState::initial(); $teamAOnLeft = $this->isTeamAOnLeft($scoreboardState); - $teamCodes = $this->teamCountryCodes(); + $teamCodes = $this->teamCountryCodes; return view('livewire.scoreboard', [ 'leftTeam' => $teamAOnLeft ? TeamAB::TeamA : TeamAB::TeamB, @@ -49,9 +50,10 @@ private function isTeamAOnLeft(GameState $scoreboardState): bool /** * @return array{team_a: string, team_b: string} */ - private function teamCountryCodes(): array + #[Computed] + public function teamCountryCodes(): array { - $game = $this->activeGame(); + $game = $this->activeGame; if ($game === null) { return [ @@ -60,7 +62,7 @@ private function teamCountryCodes(): array ]; } - $teamASide = $this->teamASideForTossSets($game); + $teamASide = $this->teamASideForToss; if ($teamASide === TeamSide::Home) { return [ @@ -75,8 +77,15 @@ private function teamCountryCodes(): array ]; } - private function teamASideForTossSets(Game $game): TeamSide + #[Computed] + public function teamASideForToss(): TeamSide { + $game = $this->activeGame; + + if ($game === null) { + return TeamSide::Home; + } + $tossPayload = $this->cacheRepository()->latestTossPayload($game); if ($tossPayload === null) { @@ -86,7 +95,8 @@ private function teamASideForTossSets(Game $game): TeamSide return $tossPayload->teamA; } - private function activeGame(): ?Game + #[Computed] + public function activeGame(): ?Game { if ($this->gameId === null) { return null; diff --git a/app/Livewire/StartSetSubmission.php b/app/Livewire/StartSetSubmission.php index 92e7d41..a275c1e 100644 --- a/app/Livewire/StartSetSubmission.php +++ b/app/Livewire/StartSetSubmission.php @@ -10,6 +10,7 @@ use App\Exceptions\InvalidGameEventTransition; use App\Models\Game; use Illuminate\Contracts\View\View; +use Livewire\Attributes\Computed; use Livewire\Attributes\Locked; use Livewire\Attributes\Reactive; use Livewire\Component; @@ -32,7 +33,7 @@ public function startSet(): void { $this->resetValidation('startSet'); - $activeGame = $this->activeGame(); + $activeGame = $this->activeGame; if ($activeGame === null) { $this->addError('startSet', 'No active game is available to start the set.'); @@ -67,13 +68,13 @@ public function render(): View private function canStartSet(): bool { - $activeGame = $this->activeGame(); + $activeGame = $this->activeGame; if ($activeGame === null) { return false; } - $activeGameState = $activeGame->stateAt(); + $activeGameState = $this->activeGameState; if ($activeGameState->setInProgress || $activeGameState->gameEnded) { return false; @@ -85,7 +86,8 @@ private function canStartSet(): bool ); } - private function activeGame(): ?Game + #[Computed] + public function activeGame(): ?Game { if ($this->gameId === null) { return null; @@ -94,6 +96,14 @@ private function activeGame(): ?Game return Game::query()->whereKey($this->gameId)->first(); } + #[Computed] + public function activeGameState(): GameState + { + $activeGame = $this->activeGame; + + return $activeGame?->stateAt() ?? $this->resolvedGameState(); + } + private function bothLineupsSubmittedForUpcomingSet(Game $game): bool { return $this->bothLineupsSubmittedForSet( @@ -119,13 +129,7 @@ private function hasSubmittedLineupForSet(Game $game, TeamAB $team, int $setNumb private function upcomingSetNumber(): int { - $activeGame = $this->activeGame(); - - if ($activeGame === null) { - return $this->resolvedGameState()->setNumber + 1; - } - - return $this->upcomingSetNumberForGame($activeGame); + return $this->activeGameState->setNumber + 1; } private function upcomingSetNumberForGame(Game $game): int diff --git a/app/Livewire/TeamRoster.php b/app/Livewire/TeamRoster.php index 3aedc25..12fd96e 100644 --- a/app/Livewire/TeamRoster.php +++ b/app/Livewire/TeamRoster.php @@ -11,6 +11,7 @@ use App\Models\Game; use App\Services\CacheRepository; use Illuminate\Contracts\View\View; +use Livewire\Attributes\Computed; use Livewire\Attributes\Locked; use Livewire\Attributes\Reactive; use Livewire\Component; @@ -44,8 +45,9 @@ public function mount( public function render(): View { - $players = $this->players(); - $rosterPlayerCount = $this->rosterPlayerCount(); + $teamPlayers = $this->teamPlayers; + $rosterPlayerCount = count($teamPlayers); + $players = $this->benchPlayers($teamPlayers); $lineupSubmitted = $this->hasLineupBeenSubmitted($this->team); $placeholderCount = $lineupSubmitted ? 0 : max(0, $rosterPlayerCount - 6); @@ -54,7 +56,7 @@ public function render(): View 'showPlayerPlaceholders' => $placeholderCount > 0, 'placeholderCount' => $placeholderCount, 'hasRosterPlayers' => $rosterPlayerCount > 0, - 'staffMarkers' => $this->staffMarkers(), + 'staffMarkers' => $this->buildStaffMarkers($this->teamStaff), 'reverseStaffOrder' => $this->leftSide, 'keyPrefix' => $this->leftSide ? 'left-player' : 'right-player', 'markerTone' => $this->team === TeamAB::TeamA ? 'bg-blue-600' : 'bg-red-600', @@ -68,15 +70,37 @@ public function render(): View * last_name: string * }> */ - private function players(): array + #[Computed] + public function activeGame(): ?Game { - $game = Game::query()->find($this->gameId); + return Game::query()->find($this->gameId); + } + + /** + * @return array + */ + #[Computed] + public function teamPlayers(): array + { + $game = $this->activeGame; if ($game === null) { return []; } - $players = $this->playersForTeam($game, $this->team); + return $this->cacheRepository()->playersForSide($game, $this->targetSideForTeam($this->team)); + } + + /** + * @param array $players + * @return array + */ + private function benchPlayers(array $players): array + { $onCourtNumbers = $this->onCourtRosterNumbers($this->team); if ($onCourtNumbers === []) { @@ -87,26 +111,25 @@ private function players(): array } /** - * @return array - */ - private function playersForTeam(Game $game, TeamAB $team): array - { - return $this->cacheRepository()->playersForSide($game, $this->targetSideForTeam($game, $team)); - } - - /** - * @return array + * @return array */ - private function staffMarkers(): array + #[Computed] + public function teamStaff(): array { - $game = Game::query()->find($this->gameId); + $game = $this->activeGame; if ($game === null) { return []; } - $staff = $this->staffForTeam($game, $this->team); + return $this->cacheRepository()->staffForSide($game, $this->targetSideForTeam($this->team)); + } + /** + * @return array + */ + private function buildStaffMarkers(array $staff): array + { if ($staff === []) { return []; } @@ -150,16 +173,15 @@ private function staffMarkers(): array return $markers; } - /** - * @return array - */ - private function staffForTeam(Game $game, TeamAB $team): array + #[Computed] + public function teamASideForToss(): TeamSide { - return $this->cacheRepository()->staffForSide($game, $this->targetSideForTeam($game, $team)); - } + $game = $this->activeGame; + + if ($game === null) { + return TeamSide::Home; + } - private function teamASideForTossSets(Game $game): TeamSide - { $tossPayload = $this->cacheRepository()->latestTossPayload($game); if ($tossPayload === null) { @@ -174,20 +196,9 @@ private function cacheRepository(): CacheRepository return app(CacheRepository::class); } - private function rosterPlayerCount(): int - { - $game = Game::query()->find($this->gameId); - - if ($game === null) { - return 0; - } - - return count($this->playersForTeam($game, $this->team)); - } - - private function targetSideForTeam(Game $game, TeamAB $team): TeamSide + private function targetSideForTeam(TeamAB $team): TeamSide { - $teamASide = $this->teamASideForTossSets($game); + $teamASide = $this->teamASideForToss; return $team === TeamAB::TeamA ? $teamASide diff --git a/tests/Feature/ExampleTest.php b/tests/Feature/ExampleTest.php index 8fd9ded..0c6a2ee 100644 --- a/tests/Feature/ExampleTest.php +++ b/tests/Feature/ExampleTest.php @@ -21,7 +21,9 @@ $response->assertSuccessful() ->assertSee('id="game-canvas"', false) ->assertSee('bg-sky-100') - ->assertSee('id="volleyball-court"', false); + ->assertSee('id="volleyball-court"', false) + ->assertSee('Submit Toss Result') + ->assertDontSee('Submit Lineup'); }); test('the game livewire component renders the court component', function (): void { From 65c9f20d22bbefdf3a32d42327a5ec346d0dbac6 Mon Sep 17 00:00:00 2001 From: Giulio Troccoli-Allard Date: Sat, 28 Mar 2026 17:43:15 +0000 Subject: [PATCH 24/63] Fix phpstan issues after point-3 merge --- app/Livewire/Court.php | 10 +++++----- app/Livewire/LineupSubmission.php | 12 ++++++------ app/Livewire/Scoreboard.php | 8 ++++---- app/Livewire/StartSetSubmission.php | 10 +++++----- app/Livewire/TeamRoster.php | 20 +++++++------------- 5 files changed, 27 insertions(+), 33 deletions(-) diff --git a/app/Livewire/Court.php b/app/Livewire/Court.php index 5318f6d..a802d59 100644 --- a/app/Livewire/Court.php +++ b/app/Livewire/Court.php @@ -51,7 +51,7 @@ public function recordRallyWinner(string $team): void return; } - $activeGame = $this->activeGame; + $activeGame = $this->activeGame(); if ($activeGame === null) { $this->addError('rallyWinner', 'No active game is available to record the rally winner.'); @@ -83,9 +83,9 @@ public function recordRallyWinner(string $team): void */ private function courtContext(): array { - $game = $this->activeGame; + $game = $this->activeGame(); $canRecordRallyWinner = $this->canRecordRallyWinner($game); - $showRosters = $game !== null && $this->latestTossPayload !== null; + $showRosters = $game !== null && $this->latestTossPayload() !== null; $defaultContext = [ 'leftTeam' => TeamAB::TeamA, @@ -126,7 +126,7 @@ private function isTeamAOnLeft(): bool #[Computed] public function latestTossPayload(): ?TossCompletedPayload { - $game = $this->activeGame; + $game = $this->activeGame(); if ($game === null) { return null; @@ -159,7 +159,7 @@ private function completedSetCount(): int private function canRecordRallyWinner(?Game $activeGame = null): bool { - $activeGame ??= $this->activeGame; + $activeGame ??= $this->activeGame(); if ($activeGame === null) { return false; diff --git a/app/Livewire/LineupSubmission.php b/app/Livewire/LineupSubmission.php index 9df068c..f06c2a1 100644 --- a/app/Livewire/LineupSubmission.php +++ b/app/Livewire/LineupSubmission.php @@ -64,7 +64,7 @@ public function submit(): void return; } - $activeGame = $this->activeGame; + $activeGame = $this->activeGame(); if ($activeGame === null) { $this->addError('submit', 'No active game is available to record the lineup.'); @@ -113,7 +113,7 @@ public function activeGame(): ?Game #[Computed] public function tossPayload(): ?TossCompletedPayload { - $activeGame = $this->activeGame; + $activeGame = $this->activeGame(); if ($activeGame === null) { return null; @@ -199,7 +199,7 @@ private function eligibleRosterNumbers(Game $game): array */ private function rosterNumbers(): array { - $activeGame = $this->activeGame; + $activeGame = $this->activeGame(); if ($activeGame === null) { return []; @@ -210,7 +210,7 @@ private function rosterNumbers(): array private function teamSideForToss(): ?TeamSide { - $tossPayload = $this->tossPayload; + $tossPayload = $this->tossPayload(); if ($tossPayload === null) { return null; @@ -270,7 +270,7 @@ private function canSubmitLineup(): bool private function hasSubmittedLineupForUpcomingSet(): bool { - $activeGame = $this->activeGame; + $activeGame = $this->activeGame(); if ($activeGame === null) { return false; @@ -285,7 +285,7 @@ private function hasSubmittedLineupForUpcomingSet(): bool private function hasSubmittedToss(): bool { - return $this->tossPayload !== null; + return $this->tossPayload() !== null; } private function upcomingSetNumber(): int diff --git a/app/Livewire/Scoreboard.php b/app/Livewire/Scoreboard.php index 9bfca4e..123bca8 100644 --- a/app/Livewire/Scoreboard.php +++ b/app/Livewire/Scoreboard.php @@ -28,7 +28,7 @@ public function render(): View { $scoreboardState = $this->gameState ?? GameState::initial(); $teamAOnLeft = $this->isTeamAOnLeft($scoreboardState); - $teamCodes = $this->teamCountryCodes; + $teamCodes = $this->teamCountryCodes(); return view('livewire.scoreboard', [ 'leftTeam' => $teamAOnLeft ? TeamAB::TeamA : TeamAB::TeamB, @@ -53,7 +53,7 @@ private function isTeamAOnLeft(GameState $scoreboardState): bool #[Computed] public function teamCountryCodes(): array { - $game = $this->activeGame; + $game = $this->activeGame(); if ($game === null) { return [ @@ -62,7 +62,7 @@ public function teamCountryCodes(): array ]; } - $teamASide = $this->teamASideForToss; + $teamASide = $this->teamASideForToss(); if ($teamASide === TeamSide::Home) { return [ @@ -80,7 +80,7 @@ public function teamCountryCodes(): array #[Computed] public function teamASideForToss(): TeamSide { - $game = $this->activeGame; + $game = $this->activeGame(); if ($game === null) { return TeamSide::Home; diff --git a/app/Livewire/StartSetSubmission.php b/app/Livewire/StartSetSubmission.php index a275c1e..bfebf52 100644 --- a/app/Livewire/StartSetSubmission.php +++ b/app/Livewire/StartSetSubmission.php @@ -33,7 +33,7 @@ public function startSet(): void { $this->resetValidation('startSet'); - $activeGame = $this->activeGame; + $activeGame = $this->activeGame(); if ($activeGame === null) { $this->addError('startSet', 'No active game is available to start the set.'); @@ -68,13 +68,13 @@ public function render(): View private function canStartSet(): bool { - $activeGame = $this->activeGame; + $activeGame = $this->activeGame(); if ($activeGame === null) { return false; } - $activeGameState = $this->activeGameState; + $activeGameState = $this->activeGameState(); if ($activeGameState->setInProgress || $activeGameState->gameEnded) { return false; @@ -99,7 +99,7 @@ public function activeGame(): ?Game #[Computed] public function activeGameState(): GameState { - $activeGame = $this->activeGame; + $activeGame = $this->activeGame(); return $activeGame?->stateAt() ?? $this->resolvedGameState(); } @@ -129,7 +129,7 @@ private function hasSubmittedLineupForSet(Game $game, TeamAB $team, int $setNumb private function upcomingSetNumber(): int { - return $this->activeGameState->setNumber + 1; + return $this->activeGameState()->setNumber + 1; } private function upcomingSetNumberForGame(Game $game): int diff --git a/app/Livewire/TeamRoster.php b/app/Livewire/TeamRoster.php index 12fd96e..c0e3d6a 100644 --- a/app/Livewire/TeamRoster.php +++ b/app/Livewire/TeamRoster.php @@ -45,7 +45,7 @@ public function mount( public function render(): View { - $teamPlayers = $this->teamPlayers; + $teamPlayers = $this->teamPlayers(); $rosterPlayerCount = count($teamPlayers); $players = $this->benchPlayers($teamPlayers); $lineupSubmitted = $this->hasLineupBeenSubmitted($this->team); @@ -56,20 +56,13 @@ public function render(): View 'showPlayerPlaceholders' => $placeholderCount > 0, 'placeholderCount' => $placeholderCount, 'hasRosterPlayers' => $rosterPlayerCount > 0, - 'staffMarkers' => $this->buildStaffMarkers($this->teamStaff), + 'staffMarkers' => $this->buildStaffMarkers($this->teamStaff()), 'reverseStaffOrder' => $this->leftSide, 'keyPrefix' => $this->leftSide ? 'left-player' : 'right-player', 'markerTone' => $this->team === TeamAB::TeamA ? 'bg-blue-600' : 'bg-red-600', ]); } - /** - * @return array - */ #[Computed] public function activeGame(): ?Game { @@ -86,7 +79,7 @@ public function activeGame(): ?Game #[Computed] public function teamPlayers(): array { - $game = $this->activeGame; + $game = $this->activeGame(); if ($game === null) { return []; @@ -116,7 +109,7 @@ private function benchPlayers(array $players): array #[Computed] public function teamStaff(): array { - $game = $this->activeGame; + $game = $this->activeGame(); if ($game === null) { return []; @@ -126,6 +119,7 @@ public function teamStaff(): array } /** + * @param array $staff * @return array */ private function buildStaffMarkers(array $staff): array @@ -176,7 +170,7 @@ private function buildStaffMarkers(array $staff): array #[Computed] public function teamASideForToss(): TeamSide { - $game = $this->activeGame; + $game = $this->activeGame(); if ($game === null) { return TeamSide::Home; @@ -198,7 +192,7 @@ private function cacheRepository(): CacheRepository private function targetSideForTeam(TeamAB $team): TeamSide { - $teamASide = $this->teamASideForToss; + $teamASide = $this->teamASideForToss(); return $team === TeamAB::TeamA ? $teamASide From f5347bfd4b601abad4b5f49a9ffbcc4c177afeea Mon Sep 17 00:00:00 2001 From: Giulio Troccoli-Allard Date: Sat, 28 Mar 2026 17:29:11 +0000 Subject: [PATCH 25/63] refactor: centralize team side mapping rules --- app/Livewire/Court.php | 57 ++++++---------------- app/Livewire/LineupSubmission.php | 51 +++++-------------- app/Livewire/Scoreboard.php | 56 ++++++--------------- app/Livewire/TeamRoster.php | 43 ++++------------ app/Services/GameSideResolver.php | 81 +++++++++++++++++++++++++++++++ 5 files changed, 133 insertions(+), 155 deletions(-) create mode 100644 app/Services/GameSideResolver.php diff --git a/app/Livewire/Court.php b/app/Livewire/Court.php index a802d59..8337fff 100644 --- a/app/Livewire/Court.php +++ b/app/Livewire/Court.php @@ -6,10 +6,9 @@ use App\Data\GameState\GameState; use App\Enums\TeamAB; -use App\Events\Payloads\TossCompletedPayload; use App\Exceptions\InvalidGameEventTransition; use App\Models\Game; -use App\Services\CacheRepository; +use App\Services\GameSideResolver; use Illuminate\Contracts\View\View; use Livewire\Attributes\Computed; use Livewire\Attributes\Reactive; @@ -84,56 +83,23 @@ public function recordRallyWinner(string $team): void private function courtContext(): array { $game = $this->activeGame(); + $completedSetCount = $this->completedSetCount(); + $leftTeam = $this->gameSideResolver()->teamOnLeft($completedSetCount); + $rightTeam = $this->gameSideResolver()->teamOnRight($completedSetCount); $canRecordRallyWinner = $this->canRecordRallyWinner($game); - $showRosters = $game !== null && $this->latestTossPayload() !== null; - - $defaultContext = [ - 'leftTeam' => TeamAB::TeamA, - 'rightTeam' => TeamAB::TeamB, - 'servingTeam' => $this->resolvedGameState()->servingTeam, - 'showRosters' => $showRosters, - 'canRecordRallyWinner' => $canRecordRallyWinner, - 'leftRotation' => $this->rotationForTeam(TeamAB::TeamA), - 'rightRotation' => $this->rotationForTeam(TeamAB::TeamB), - ]; - - if ($game === null) { - return $defaultContext; - } - - if ($this->isTeamAOnLeft()) { - return $defaultContext; - } + $showRosters = $game !== null && $this->gameSideResolver()->hasRecordedToss($game); return [ - 'leftTeam' => TeamAB::TeamB, - 'rightTeam' => TeamAB::TeamA, + 'leftTeam' => $leftTeam, + 'rightTeam' => $rightTeam, 'servingTeam' => $this->resolvedGameState()->servingTeam, 'showRosters' => $showRosters, 'canRecordRallyWinner' => $canRecordRallyWinner, - 'leftRotation' => $this->rotationForTeam(TeamAB::TeamB), - 'rightRotation' => $this->rotationForTeam(TeamAB::TeamA), + 'leftRotation' => $this->rotationForTeam($leftTeam), + 'rightRotation' => $this->rotationForTeam($rightTeam), ]; } - private function isTeamAOnLeft(): bool - { - $completedSets = $this->completedSetCount(); - - return $completedSets % 2 === 0; - } - - #[Computed] - public function latestTossPayload(): ?TossCompletedPayload - { - $game = $this->activeGame(); - - if ($game === null) { - return null; - } - - return app(CacheRepository::class)->latestTossPayload($game); - } /** * @return array @@ -179,4 +145,9 @@ public function activeGame(): ?Game return Game::query()->whereKey($this->gameId)->first(); } + + private function gameSideResolver(): GameSideResolver + { + return app(GameSideResolver::class); + } } diff --git a/app/Livewire/LineupSubmission.php b/app/Livewire/LineupSubmission.php index f06c2a1..72ce9d4 100644 --- a/app/Livewire/LineupSubmission.php +++ b/app/Livewire/LineupSubmission.php @@ -8,10 +8,9 @@ use App\Enums\GameEventType; use App\Enums\TeamAB; use App\Enums\TeamSide; -use App\Events\Payloads\TossCompletedPayload; use App\Exceptions\InvalidGameEventTransition; use App\Models\Game; -use App\Models\GameEvent; +use App\Services\GameSideResolver; use Flux\Flux; use Illuminate\Contracts\View\View; use Illuminate\Support\Facades\Validator; @@ -110,31 +109,6 @@ public function activeGame(): ?Game return Game::query()->whereKey($this->gameId)->first(); } - #[Computed] - public function tossPayload(): ?TossCompletedPayload - { - $activeGame = $this->activeGame(); - - if ($activeGame === null) { - return null; - } - - /** @var GameEvent|null $tossEvent */ - $tossEvent = $activeGame->events() - ->reorder() - ->where('type', GameEventType::TossCompleted) - ->orderByDesc('created_at') - ->orderByDesc('id') - ->first(); - - if ($tossEvent === null) { - return null; - } - - /** @var TossCompletedPayload */ - return $tossEvent->payload; - } - /** * @return array> */ @@ -179,7 +153,7 @@ private function lineupPositions(): array */ private function eligibleRosterNumbers(Game $game): array { - $teamSide = $this->teamSideForToss(); + $teamSide = $this->teamSideForToss($game); if ($teamSide === null) { return []; @@ -208,17 +182,9 @@ private function rosterNumbers(): array return $this->eligibleRosterNumbers($activeGame); } - private function teamSideForToss(): ?TeamSide + private function teamSideForToss(Game $game): ?TeamSide { - $tossPayload = $this->tossPayload(); - - if ($tossPayload === null) { - return null; - } - - return $this->team === TeamAB::TeamA - ? $tossPayload->teamA - : ($tossPayload->teamA === TeamSide::Home ? TeamSide::Away : TeamSide::Home); + return $this->gameSideResolver()->sideForTeamFromToss($game, $this->team); } /** @@ -285,7 +251,9 @@ private function hasSubmittedLineupForUpcomingSet(): bool private function hasSubmittedToss(): bool { - return $this->tossPayload() !== null; + $activeGame = $this->activeGame(); + + return $activeGame !== null && $this->gameSideResolver()->hasRecordedToss($activeGame); } private function upcomingSetNumber(): int @@ -327,4 +295,9 @@ private function defaultLineup(): array 6 => '', ]; } + + private function gameSideResolver(): GameSideResolver + { + return app(GameSideResolver::class); + } } diff --git a/app/Livewire/Scoreboard.php b/app/Livewire/Scoreboard.php index 123bca8..1db8b43 100644 --- a/app/Livewire/Scoreboard.php +++ b/app/Livewire/Scoreboard.php @@ -8,7 +8,7 @@ use App\Enums\TeamAB; use App\Enums\TeamSide; use App\Models\Game; -use App\Services\CacheRepository; +use App\Services\GameSideResolver; use Illuminate\Contracts\View\View; use Livewire\Attributes\Computed; use Livewire\Attributes\Locked; @@ -27,26 +27,23 @@ class Scoreboard extends Component public function render(): View { $scoreboardState = $this->gameState ?? GameState::initial(); - $teamAOnLeft = $this->isTeamAOnLeft($scoreboardState); + $completedSets = $scoreboardState->setsWonTeamA + $scoreboardState->setsWonTeamB; + $leftTeam = $this->gameSideResolver()->teamOnLeft($completedSets); + $rightTeam = $this->gameSideResolver()->teamOnRight($completedSets); $teamCodes = $this->teamCountryCodes(); return view('livewire.scoreboard', [ - 'leftTeam' => $teamAOnLeft ? TeamAB::TeamA : TeamAB::TeamB, - 'rightTeam' => $teamAOnLeft ? TeamAB::TeamB : TeamAB::TeamA, - 'leftTeamCode' => $teamAOnLeft ? $teamCodes['team_a'] : $teamCodes['team_b'], - 'rightTeamCode' => $teamAOnLeft ? $teamCodes['team_b'] : $teamCodes['team_a'], - 'leftSets' => $teamAOnLeft ? $scoreboardState->setsWonTeamA : $scoreboardState->setsWonTeamB, - 'rightSets' => $teamAOnLeft ? $scoreboardState->setsWonTeamB : $scoreboardState->setsWonTeamA, - 'leftPoints' => $teamAOnLeft ? $scoreboardState->scoreTeamA : $scoreboardState->scoreTeamB, - 'rightPoints' => $teamAOnLeft ? $scoreboardState->scoreTeamB : $scoreboardState->scoreTeamA, + 'leftTeam' => $leftTeam, + 'rightTeam' => $rightTeam, + 'leftTeamCode' => $leftTeam === TeamAB::TeamA ? $teamCodes['team_a'] : $teamCodes['team_b'], + 'rightTeamCode' => $rightTeam === TeamAB::TeamA ? $teamCodes['team_a'] : $teamCodes['team_b'], + 'leftSets' => $leftTeam === TeamAB::TeamA ? $scoreboardState->setsWonTeamA : $scoreboardState->setsWonTeamB, + 'rightSets' => $rightTeam === TeamAB::TeamA ? $scoreboardState->setsWonTeamA : $scoreboardState->setsWonTeamB, + 'leftPoints' => $leftTeam === TeamAB::TeamA ? $scoreboardState->scoreTeamA : $scoreboardState->scoreTeamB, + 'rightPoints' => $rightTeam === TeamAB::TeamA ? $scoreboardState->scoreTeamA : $scoreboardState->scoreTeamB, ]); } - private function isTeamAOnLeft(GameState $scoreboardState): bool - { - return ($scoreboardState->setsWonTeamA + $scoreboardState->setsWonTeamB) % 2 === 0; - } - /** * @return array{team_a: string, team_b: string} */ @@ -62,9 +59,7 @@ public function teamCountryCodes(): array ]; } - $teamASide = $this->teamASideForToss(); - - if ($teamASide === TeamSide::Home) { + if ($this->gameSideResolver()->sideForTeam($game, TeamAB::TeamA) === TeamSide::Home) { return [ 'team_a' => $game->homeTeam->country_code, 'team_b' => $game->awayTeam->country_code, @@ -77,26 +72,7 @@ public function teamCountryCodes(): array ]; } - #[Computed] - public function teamASideForToss(): TeamSide - { - $game = $this->activeGame(); - - if ($game === null) { - return TeamSide::Home; - } - - $tossPayload = $this->cacheRepository()->latestTossPayload($game); - - if ($tossPayload === null) { - return TeamSide::Home; - } - - return $tossPayload->teamA; - } - - #[Computed] - public function activeGame(): ?Game + private function activeGame(): ?Game { if ($this->gameId === null) { return null; @@ -108,8 +84,8 @@ public function activeGame(): ?Game ->first(); } - private function cacheRepository(): CacheRepository + private function gameSideResolver(): GameSideResolver { - return app(CacheRepository::class); + return app(GameSideResolver::class); } } diff --git a/app/Livewire/TeamRoster.php b/app/Livewire/TeamRoster.php index c0e3d6a..f178f30 100644 --- a/app/Livewire/TeamRoster.php +++ b/app/Livewire/TeamRoster.php @@ -10,6 +10,7 @@ use App\Enums\TeamSide; use App\Models\Game; use App\Services\CacheRepository; +use App\Services\GameSideResolver; use Illuminate\Contracts\View\View; use Livewire\Attributes\Computed; use Livewire\Attributes\Locked; @@ -85,7 +86,7 @@ public function teamPlayers(): array return []; } - return $this->cacheRepository()->playersForSide($game, $this->targetSideForTeam($this->team)); + return $this->cacheRepository()->playersForSide($game, $this->targetSideForTeam($game, $this->team)); } /** @@ -115,7 +116,7 @@ public function teamStaff(): array return []; } - return $this->cacheRepository()->staffForSide($game, $this->targetSideForTeam($this->team)); + return $this->cacheRepository()->staffForSide($game, $this->targetSideForTeam($game, $this->team)); } /** @@ -167,43 +168,14 @@ private function buildStaffMarkers(array $staff): array return $markers; } - #[Computed] - public function teamASideForToss(): TeamSide - { - $game = $this->activeGame(); - - if ($game === null) { - return TeamSide::Home; - } - - $tossPayload = $this->cacheRepository()->latestTossPayload($game); - - if ($tossPayload === null) { - return TeamSide::Home; - } - - return $tossPayload->teamA; - } - private function cacheRepository(): CacheRepository { return app(CacheRepository::class); } - private function targetSideForTeam(TeamAB $team): TeamSide - { - $teamASide = $this->teamASideForToss(); - - return $team === TeamAB::TeamA - ? $teamASide - : $this->oppositeSide($teamASide); - } - - private function oppositeSide(TeamSide $side): TeamSide + private function targetSideForTeam(Game $game, TeamAB $team): TeamSide { - return $side === TeamSide::Home - ? TeamSide::Away - : TeamSide::Home; + return $this->gameSideResolver()->sideForTeam($game, $team); } /** @@ -222,4 +194,9 @@ private function hasLineupBeenSubmitted(TeamAB $team): bool { return $this->onCourtRosterNumbers($team) !== []; } + + private function gameSideResolver(): GameSideResolver + { + return app(GameSideResolver::class); + } } diff --git a/app/Services/GameSideResolver.php b/app/Services/GameSideResolver.php new file mode 100644 index 0000000..a15110c --- /dev/null +++ b/app/Services/GameSideResolver.php @@ -0,0 +1,81 @@ +tossPayload($game) !== null; + } + + public function teamASideForToss(Game $game): TeamSide + { + return $this->tossPayload($game)?->teamA ?? TeamSide::Home; + } + + public function sideForTeam(Game $game, TeamAB $team): TeamSide + { + return $this->sideForTeamFromTeamASide($this->teamASideForToss($game), $team); + } + + public function sideForTeamFromToss(Game $game, TeamAB $team): ?TeamSide + { + $tossPayload = $this->tossPayload($game); + + if ($tossPayload === null) { + return null; + } + + return $this->sideForTeamFromTeamASide($tossPayload->teamA, $team); + } + + public function teamOnLeft(int $completedSets): TeamAB + { + return $completedSets % 2 === 0 + ? TeamAB::TeamA + : TeamAB::TeamB; + } + + public function teamOnRight(int $completedSets): TeamAB + { + return $this->oppositeTeam($this->teamOnLeft($completedSets)); + } + + public function oppositeTeam(TeamAB $team): TeamAB + { + return $team === TeamAB::TeamA + ? TeamAB::TeamB + : TeamAB::TeamA; + } + + public function oppositeSide(TeamSide $side): TeamSide + { + return $side === TeamSide::Home + ? TeamSide::Away + : TeamSide::Home; + } + + private function tossPayload(Game $game): ?TossCompletedPayload + { + return $this->cacheRepository->latestTossPayload($game); + } + + private function sideForTeamFromTeamASide(TeamSide $teamASide, TeamAB $team): TeamSide + { + return $team === TeamAB::TeamA + ? $teamASide + : $this->oppositeSide($teamASide); + } +} From d575d20ca7aed02bac5a69b28cce4f2b6389b7f5 Mon Sep 17 00:00:00 2001 From: Giulio Troccoli-Allard Date: Sat, 28 Mar 2026 17:31:51 +0000 Subject: [PATCH 26/63] feat: improve responsive and accessible game controls --- resources/views/livewire/court.blade.php | 16 +++++++++------- resources/views/livewire/game.blade.php | 7 +++++-- resources/views/livewire/scoreboard.blade.php | 2 +- .../livewire/start-set-submission.blade.php | 3 ++- .../livewire/toss-result-submission.blade.php | 2 +- 5 files changed, 18 insertions(+), 12 deletions(-) diff --git a/resources/views/livewire/court.blade.php b/resources/views/livewire/court.blade.php index 2940b12..391fcce 100644 --- a/resources/views/livewire/court.blade.php +++ b/resources/views/livewire/court.blade.php @@ -34,7 +34,7 @@
@@ -84,6 +84,7 @@ class="absolute {{ $markerPositionClass }} flex h-8 w-8 items-center justify-cen
@endif @@ -123,7 +125,7 @@ class="absolute {{ $markerPositionClass }} flex h-8 w-8 items-center justify-cen @enderror @if ($showRosters) -
+
+
-
+
+
diff --git a/resources/views/livewire/scoreboard.blade.php b/resources/views/livewire/scoreboard.blade.php index 1c51900..19939ee 100644 --- a/resources/views/livewire/scoreboard.blade.php +++ b/resources/views/livewire/scoreboard.blade.php @@ -1,7 +1,7 @@
{{ $leftTeamCode }} diff --git a/resources/views/livewire/start-set-submission.blade.php b/resources/views/livewire/start-set-submission.blade.php index f01ce37..0f8da34 100644 --- a/resources/views/livewire/start-set-submission.blade.php +++ b/resources/views/livewire/start-set-submission.blade.php @@ -1,9 +1,10 @@
@if ($canStartSet) -
+
@unless ($hasSubmittedToss) -
+
Submit Toss Result From 2d23c45306d9b3dfe95cb605501c2815b298c49a Mon Sep 17 00:00:00 2001 From: Giulio Troccoli-Allard Date: Sat, 28 Mar 2026 17:33:42 +0000 Subject: [PATCH 27/63] fix: variouos fixes --- tests/Feature/ScoreboardTest.php | 91 ++++++++++++++++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 tests/Feature/ScoreboardTest.php diff --git a/tests/Feature/ScoreboardTest.php b/tests/Feature/ScoreboardTest.php new file mode 100644 index 0000000..70a8669 --- /dev/null +++ b/tests/Feature/ScoreboardTest.php @@ -0,0 +1,91 @@ +recordToss(TeamSide::Away, TeamAB::TeamA); + + $teamACode = $game->awayTeam->country_code; + $teamBCode = $game->homeTeam->country_code; + + Livewire::test(Scoreboard::class, [ + 'gameId' => $game->getKey(), + 'gameState' => GameState::fromAttributes([ + 'sets_won_team_a' => 0, + 'sets_won_team_b' => 0, + 'score_team_a' => 14, + 'score_team_b' => 19, + ]), + ]) + ->assertSeeHtml('data-scoreboard-left-team="team_a"') + ->assertSeeHtml('data-scoreboard-right-team="team_b"') + ->assertSeeInOrder([ + $teamACode, + 'Sets', + $teamBCode, + '0', + ':', + '0', + $teamACode, + 'Points', + $teamBCode, + '14', + ':', + '19', + ]); +}); + +test('scoreboard swaps left and right teams when completed set count is odd', function (): void { + $game = gameWithDistinctTeamCountryCodes(); + $game->recordToss(TeamSide::Home, TeamAB::TeamA); + + $teamACode = $game->homeTeam->country_code; + $teamBCode = $game->awayTeam->country_code; + + Livewire::test(Scoreboard::class, [ + 'gameId' => $game->getKey(), + 'gameState' => GameState::fromAttributes([ + 'sets_won_team_a' => 2, + 'sets_won_team_b' => 1, + 'score_team_a' => 17, + 'score_team_b' => 21, + ]), + ]) + ->assertSeeHtml('data-scoreboard-left-team="team_b"') + ->assertSeeHtml('data-scoreboard-right-team="team_a"') + ->assertSeeInOrder([ + $teamBCode, + 'Sets', + $teamACode, + '1', + ':', + '2', + $teamBCode, + 'Points', + $teamACode, + '21', + ':', + '17', + ]); +}); + +function gameWithDistinctTeamCountryCodes(): Game +{ + $homeTeam = Team::factory()->create(); + $awayTeam = Team::factory()->create(); + + while ($awayTeam->country_code === $homeTeam->country_code) { + $awayTeam = Team::factory()->create(); + } + + return Game::factory()->betweenTeams($homeTeam, $awayTeam)->create(); +} From 2f6fa97d6f017b690eedfbd0b0b71d79be71a516 Mon Sep 17 00:00:00 2001 From: Giulio Troccoli-Allard Date: Sun, 29 Mar 2026 17:14:33 +0100 Subject: [PATCH 28/63] fix: ensure all components use the game state snapshots --- app/Data/GameState/GameState.php | 7 ++ app/Livewire/LineupSubmission.php | 56 ++++++++++------ app/Livewire/StartSetSubmission.php | 46 +++++-------- app/Livewire/TossResultSubmission.php | 53 +++++++++++---- app/Models/GameStateSnapshot.php | 3 + app/Services/GameSideResolver.php | 26 ++++---- app/Services/GameState/GameStateProjector.php | 1 + ...m_a_side_to_game_state_snapshots_table.php | 62 +++++++++++++++++ .../livewire/toss-result-submission.blade.php | 8 +-- .../{ExampleTest.php => GamePageTest.php} | 0 tests/Feature/GameStateProjectionTest.php | 6 +- tests/Feature/LineupSubmissionTest.php | 49 ++++++++++++++ tests/Feature/ScoreboardTest.php | 66 +++++++++++++++++++ tests/Feature/StartSetSubmissionTest.php | 39 +++++++++++ tests/Feature/TossResultSubmissionTest.php | 57 +++++++++++++++- tests/Unit/ExampleTest.php | 5 -- 16 files changed, 394 insertions(+), 90 deletions(-) create mode 100644 database/migrations/2026_03_29_153813_add_team_a_side_to_game_state_snapshots_table.php rename tests/Feature/{ExampleTest.php => GamePageTest.php} (100%) delete mode 100644 tests/Unit/ExampleTest.php diff --git a/app/Data/GameState/GameState.php b/app/Data/GameState/GameState.php index 832c1c2..36d7227 100644 --- a/app/Data/GameState/GameState.php +++ b/app/Data/GameState/GameState.php @@ -5,6 +5,7 @@ namespace App\Data\GameState; use App\Enums\TeamAB; +use App\Enums\TeamSide; use App\Models\GameStateSnapshot; use Livewire\Wireable; @@ -26,6 +27,7 @@ public function __construct( public int $timeoutsTeamB = 0, public int $substitutionsTeamA = 0, public int $substitutionsTeamB = 0, + public ?TeamSide $teamASide = null, public ?TeamAB $servingTeam = null, public bool $setInProgress = false, public bool $gameEnded = false, @@ -48,6 +50,7 @@ public static function fromSnapshot(GameStateSnapshot $snapshot): self timeoutsTeamB: $snapshot->timeouts_team_b, substitutionsTeamA: $snapshot->substitutions_team_a, substitutionsTeamB: $snapshot->substitutions_team_b, + teamASide: $snapshot->team_a_side, servingTeam: $snapshot->serving_team, setInProgress: $snapshot->set_in_progress, gameEnded: $snapshot->game_ended, @@ -74,6 +77,9 @@ public static function fromAttributes(array $attributes): self timeoutsTeamB: self::toInteger($attributes['timeouts_team_b'] ?? 0), substitutionsTeamA: self::toInteger($attributes['substitutions_team_a'] ?? 0), substitutionsTeamB: self::toInteger($attributes['substitutions_team_b'] ?? 0), + teamASide: is_string($attributes['team_a_side'] ?? null) + ? TeamSide::tryFrom($attributes['team_a_side']) + : null, servingTeam: is_string($attributes['serving_team'] ?? null) ? TeamAB::tryFrom($attributes['serving_team']) : null, @@ -109,6 +115,7 @@ public function toAttributes(): array 'timeouts_team_b' => $this->timeoutsTeamB, 'substitutions_team_a' => $this->substitutionsTeamA, 'substitutions_team_b' => $this->substitutionsTeamB, + 'team_a_side' => $this->teamASide?->value, 'serving_team' => $this->servingTeam?->value, 'rotation_team_a' => $this->rotationTeamA, 'rotation_team_b' => $this->rotationTeamB, diff --git a/app/Livewire/LineupSubmission.php b/app/Livewire/LineupSubmission.php index 72ce9d4..f03bf02 100644 --- a/app/Livewire/LineupSubmission.php +++ b/app/Livewire/LineupSubmission.php @@ -5,12 +5,10 @@ namespace App\Livewire; use App\Data\GameState\GameState; -use App\Enums\GameEventType; use App\Enums\TeamAB; use App\Enums\TeamSide; use App\Exceptions\InvalidGameEventTransition; use App\Models\Game; -use App\Services\GameSideResolver; use Flux\Flux; use Illuminate\Contracts\View\View; use Illuminate\Support\Facades\Validator; @@ -71,6 +69,12 @@ public function submit(): void return; } + if (! $this->canSubmitLineup()) { + $this->addError('submit', 'A lineup can only be submitted before a set starts when the toss is recorded.'); + + return; + } + $positions = $this->lineupPositions(); $eligibleNumbers = $this->eligibleRosterNumbers($activeGame); @@ -78,7 +82,7 @@ public function submit(): void return; } - $set = $activeGame->stateAt()->setNumber + 1; + $set = $this->upcomingSetNumber(); try { $activeGame->recordLineup($set, $this->team, $positions); @@ -153,7 +157,7 @@ private function lineupPositions(): array */ private function eligibleRosterNumbers(Game $game): array { - $teamSide = $this->teamSideForToss($game); + $teamSide = $this->teamSideForToss(); if ($teamSide === null) { return []; @@ -182,9 +186,21 @@ private function rosterNumbers(): array return $this->eligibleRosterNumbers($activeGame); } - private function teamSideForToss(Game $game): ?TeamSide + private function teamSideForToss(): ?TeamSide { - return $this->gameSideResolver()->sideForTeamFromToss($game, $this->team); + $teamASide = $this->resolvedGameState()->teamASide; + + if ($teamASide === null) { + return null; + } + + if ($this->team === TeamAB::TeamA) { + return $teamASide; + } + + return $teamASide === TeamSide::Home + ? TeamSide::Away + : TeamSide::Home; } /** @@ -236,24 +252,20 @@ private function canSubmitLineup(): bool private function hasSubmittedLineupForUpcomingSet(): bool { - $activeGame = $this->activeGame(); + $state = $this->resolvedGameState(); - if ($activeGame === null) { - return false; - } + $lineup = $this->team === TeamAB::TeamA + ? $state->rotationTeamA + : $state->rotationTeamB; - return $activeGame->events() - ->where('type', GameEventType::LineupSubmitted) - ->where('payload->set', $this->upcomingSetNumber()) - ->where('payload->team', $this->team->value) - ->exists(); + return $lineup !== []; } private function hasSubmittedToss(): bool { - $activeGame = $this->activeGame(); + $state = $this->resolvedGameState(); - return $activeGame !== null && $this->gameSideResolver()->hasRecordedToss($activeGame); + return $state->teamASide !== null && $state->servingTeam !== null; } private function upcomingSetNumber(): int @@ -278,6 +290,12 @@ private function isGameEnded(): bool private function resolvedGameState(): GameState { + $activeGame = $this->activeGame(); + + if ($activeGame !== null) { + return $activeGame->stateAt(); + } + return $this->gameState ?? GameState::initial(); } @@ -296,8 +314,4 @@ private function defaultLineup(): array ]; } - private function gameSideResolver(): GameSideResolver - { - return app(GameSideResolver::class); - } } diff --git a/app/Livewire/StartSetSubmission.php b/app/Livewire/StartSetSubmission.php index bfebf52..77282c7 100644 --- a/app/Livewire/StartSetSubmission.php +++ b/app/Livewire/StartSetSubmission.php @@ -5,7 +5,6 @@ namespace App\Livewire; use App\Data\GameState\GameState; -use App\Enums\GameEventType; use App\Enums\TeamAB; use App\Exceptions\InvalidGameEventTransition; use App\Models\Game; @@ -41,7 +40,7 @@ public function startSet(): void return; } - if (! $this->bothLineupsSubmittedForUpcomingSet($activeGame)) { + if (! $this->canStartSet()) { $this->addError('startSet', 'Both team lineups must be submitted before starting the set.'); return; @@ -68,22 +67,21 @@ public function render(): View private function canStartSet(): bool { - $activeGame = $this->activeGame(); - - if ($activeGame === null) { + if ($this->activeGame() === null) { return false; } $activeGameState = $this->activeGameState(); + if (! $this->hasSubmittedToss($activeGameState)) { + return false; + } + if ($activeGameState->setInProgress || $activeGameState->gameEnded) { return false; } - return $this->bothLineupsSubmittedForSet( - game: $activeGame, - setNumber: $activeGameState->setNumber + 1, - ); + return $this->bothLineupsSubmittedForUpcomingSet($activeGameState); } #[Computed] @@ -104,27 +102,24 @@ public function activeGameState(): GameState return $activeGame?->stateAt() ?? $this->resolvedGameState(); } - private function bothLineupsSubmittedForUpcomingSet(Game $game): bool + private function hasSubmittedToss(GameState $state): bool { - return $this->bothLineupsSubmittedForSet( - game: $game, - setNumber: $this->upcomingSetNumberForGame($game), - ); + return $state->teamASide !== null && $state->servingTeam !== null; } - private function bothLineupsSubmittedForSet(Game $game, int $setNumber): bool + private function bothLineupsSubmittedForUpcomingSet(GameState $state): bool { - return $this->hasSubmittedLineupForSet($game, TeamAB::TeamA, $setNumber) - && $this->hasSubmittedLineupForSet($game, TeamAB::TeamB, $setNumber); + return $this->hasSubmittedLineupForTeam($state, TeamAB::TeamA) + && $this->hasSubmittedLineupForTeam($state, TeamAB::TeamB); } - private function hasSubmittedLineupForSet(Game $game, TeamAB $team, int $setNumber): bool + private function hasSubmittedLineupForTeam(GameState $state, TeamAB $team): bool { - return $game->events() - ->where('type', GameEventType::LineupSubmitted) - ->where('payload->set', $setNumber) - ->where('payload->team', $team->value) - ->exists(); + $lineup = $team === TeamAB::TeamA + ? $state->rotationTeamA + : $state->rotationTeamB; + + return $lineup !== []; } private function upcomingSetNumber(): int @@ -132,11 +127,6 @@ private function upcomingSetNumber(): int return $this->activeGameState()->setNumber + 1; } - private function upcomingSetNumberForGame(Game $game): int - { - return $game->stateAt()->setNumber + 1; - } - private function resolvedGameState(): GameState { return $this->gameState ?? GameState::initial(); diff --git a/app/Livewire/TossResultSubmission.php b/app/Livewire/TossResultSubmission.php index 9188989..b444ada 100644 --- a/app/Livewire/TossResultSubmission.php +++ b/app/Livewire/TossResultSubmission.php @@ -5,14 +5,12 @@ namespace App\Livewire; use App\Data\GameState\GameState; -use App\Enums\GameEventType; use App\Enums\TeamAB; use App\Enums\TeamSide; use App\Exceptions\InvalidGameEventTransition; use App\Models\Game; use Flux\Flux; use Illuminate\Contracts\View\View; -use Illuminate\Database\Eloquent\Builder; use Illuminate\Validation\Rule; use Livewire\Attributes\Locked; use Livewire\Attributes\Reactive; @@ -29,7 +27,7 @@ class TossResultSubmission extends Component public string $teamA = TeamSide::Home->value; - public string $serving = TeamAB::TeamA->value; + public string $serving = TeamSide::Home->value; public function mount(?int $gameId = null): void { @@ -43,7 +41,7 @@ protected function rules(): array { return [ 'teamA' => ['required', Rule::enum(TeamSide::class)], - 'serving' => ['required', Rule::enum(TeamAB::class)], + 'serving' => ['required', Rule::enum(TeamSide::class)], ]; } @@ -54,7 +52,7 @@ protected function messages(): array { return [ 'teamA.required' => 'Select whether Team A is home or away.', - 'serving.required' => 'Select whether Team A or Team B serves first.', + 'serving.required' => 'Select whether the home or away team serves first.', ]; } @@ -77,9 +75,12 @@ public function submit(): void } try { + $teamASide = TeamSide::from($this->teamA); + $servingSide = TeamSide::from($this->serving); + $activeGame->recordToss( - teamA: TeamSide::from($this->teamA), - serving: TeamAB::from($this->serving), + teamA: $teamASide, + serving: $servingSide === $teamASide ? TeamAB::TeamA : TeamAB::TeamB, ); } catch (InvalidGameEventTransition $exception) { $this->addError('submit', $exception->getMessage()); @@ -92,25 +93,51 @@ public function submit(): void $this->resetValidation(); $this->teamA = TeamSide::Home->value; - $this->serving = TeamAB::TeamA->value; + $this->serving = TeamSide::Home->value; } public function render(): View { + $activeGame = $this->activeGame(); + return view('livewire.toss-result-submission', [ - 'hasSubmittedToss' => $this->hasSubmittedToss(), + 'hasSubmittedToss' => $this->hasSubmittedToss($activeGame), + 'homeTeamCode' => $activeGame?->homeTeam->country_code ?? 'Home Team', + 'awayTeamCode' => $activeGame?->awayTeam->country_code ?? 'Away Team', ]); } - private function hasSubmittedToss(): bool + private function hasSubmittedToss(?Game $activeGame = null): bool + { + $state = $this->resolvedGameState($activeGame); + + return $state->teamASide !== null && $state->servingTeam !== null; + } + + private function resolvedGameState(?Game $activeGame = null): GameState + { + if ($activeGame !== null) { + return $activeGame->stateAt(); + } + + if ($this->gameId === null) { + return $this->gameState ?? GameState::initial(); + } + + $activeGame = Game::query()->whereKey($this->gameId)->first(); + + return $activeGame?->stateAt() ?? ($this->gameState ?? GameState::initial()); + } + + private function activeGame(): ?Game { if ($this->gameId === null) { - return false; + return null; } return Game::query() + ->with(['homeTeam', 'awayTeam']) ->whereKey($this->gameId) - ->whereHas('events', fn (Builder $query) => $query->where('type', GameEventType::TossCompleted)) - ->exists(); + ->first(); } } diff --git a/app/Models/GameStateSnapshot.php b/app/Models/GameStateSnapshot.php index 87a3ed3..5f4a7c2 100644 --- a/app/Models/GameStateSnapshot.php +++ b/app/Models/GameStateSnapshot.php @@ -5,6 +5,7 @@ namespace App\Models; use App\Enums\TeamAB; +use App\Enums\TeamSide; use Carbon\CarbonImmutable; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -21,6 +22,7 @@ * @property int $timeouts_team_b * @property int $substitutions_team_a * @property int $substitutions_team_b + * @property TeamSide|null $team_a_side * @property TeamAB|null $serving_team * @property array $rotation_team_a * @property array $rotation_team_b @@ -50,6 +52,7 @@ protected function casts(): array 'timeouts_team_b' => 'integer', 'substitutions_team_a' => 'integer', 'substitutions_team_b' => 'integer', + 'team_a_side' => TeamSide::class, 'serving_team' => TeamAB::class, 'rotation_team_a' => 'array', 'rotation_team_b' => 'array', diff --git a/app/Services/GameSideResolver.php b/app/Services/GameSideResolver.php index a15110c..22e4902 100644 --- a/app/Services/GameSideResolver.php +++ b/app/Services/GameSideResolver.php @@ -6,23 +6,24 @@ use App\Enums\TeamAB; use App\Enums\TeamSide; -use App\Events\Payloads\TossCompletedPayload; use App\Models\Game; class GameSideResolver { - public function __construct( - protected CacheRepository $cacheRepository - ) {} - public function hasRecordedToss(Game $game): bool { - return $this->tossPayload($game) !== null; + return $game->stateAt()->teamASide !== null; } public function teamASideForToss(Game $game): TeamSide { - return $this->tossPayload($game)?->teamA ?? TeamSide::Home; + $teamASide = $game->stateAt()->teamASide; + + if ($teamASide === null) { + return TeamSide::Home; + } + + return $teamASide; } public function sideForTeam(Game $game, TeamAB $team): TeamSide @@ -32,13 +33,13 @@ public function sideForTeam(Game $game, TeamAB $team): TeamSide public function sideForTeamFromToss(Game $game, TeamAB $team): ?TeamSide { - $tossPayload = $this->tossPayload($game); + $teamASide = $game->stateAt()->teamASide; - if ($tossPayload === null) { + if ($teamASide === null) { return null; } - return $this->sideForTeamFromTeamASide($tossPayload->teamA, $team); + return $this->sideForTeamFromTeamASide($teamASide, $team); } public function teamOnLeft(int $completedSets): TeamAB @@ -67,11 +68,6 @@ public function oppositeSide(TeamSide $side): TeamSide : TeamSide::Home; } - private function tossPayload(Game $game): ?TossCompletedPayload - { - return $this->cacheRepository->latestTossPayload($game); - } - private function sideForTeamFromTeamASide(TeamSide $teamASide, TeamAB $team): TeamSide { return $team === TeamAB::TeamA diff --git a/app/Services/GameState/GameStateProjector.php b/app/Services/GameState/GameStateProjector.php index e1ffc1a..d25e966 100644 --- a/app/Services/GameState/GameStateProjector.php +++ b/app/Services/GameState/GameStateProjector.php @@ -67,6 +67,7 @@ private function applyTossCompleted(GameState $state, GameEvent $event): GameSta { /** @var TossCompletedPayload $payload */ $payload = $event->payload; + $state->teamASide = $payload->teamA; $state->servingTeam = $payload->serving; return $state; diff --git a/database/migrations/2026_03_29_153813_add_team_a_side_to_game_state_snapshots_table.php b/database/migrations/2026_03_29_153813_add_team_a_side_to_game_state_snapshots_table.php new file mode 100644 index 0000000..330daf1 --- /dev/null +++ b/database/migrations/2026_03_29_153813_add_team_a_side_to_game_state_snapshots_table.php @@ -0,0 +1,62 @@ +string('team_a_side')->nullable()->after('substitutions_team_b'); + }); + + $tossEvents = DB::table('game_events') + ->select(['id', 'game_id', 'created_at', 'payload']) + ->where('type', 'toss_completed') + ->orderBy('game_id') + ->orderBy('created_at') + ->orderBy('id') + ->get(); + + foreach ($tossEvents as $tossEvent) { + $payload = json_decode((string) $tossEvent->payload, true); + + if (! is_array($payload)) { + continue; + } + + $teamASide = $payload['teamA'] ?? null; + + if (! is_string($teamASide)) { + continue; + } + + DB::table('game_state_snapshots') + ->where('game_id', $tossEvent->game_id) + ->where(function ($query) use ($tossEvent): void { + $query->where('created_at', '>', $tossEvent->created_at) + ->orWhere(function ($nestedQuery) use ($tossEvent): void { + $nestedQuery->where('created_at', $tossEvent->created_at) + ->where('game_event_id', '>=', $tossEvent->id); + }); + }) + ->update(['team_a_side' => $teamASide]); + } + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('game_state_snapshots', function (Blueprint $table) { + $table->dropColumn('team_a_side'); + }); + } +}; diff --git a/resources/views/livewire/toss-result-submission.blade.php b/resources/views/livewire/toss-result-submission.blade.php index b034a8e..6d1909b 100644 --- a/resources/views/livewire/toss-result-submission.blade.php +++ b/resources/views/livewire/toss-result-submission.blade.php @@ -22,8 +22,8 @@ variant="segmented" :invalid="$errors->has('teamA')" > - - + + @error('teamA') {{ $message }} @@ -35,8 +35,8 @@ variant="segmented" :invalid="$errors->has('serving')" > - - + + @error('serving') {{ $message }} diff --git a/tests/Feature/ExampleTest.php b/tests/Feature/GamePageTest.php similarity index 100% rename from tests/Feature/ExampleTest.php rename to tests/Feature/GamePageTest.php diff --git a/tests/Feature/GameStateProjectionTest.php b/tests/Feature/GameStateProjectionTest.php index a77344a..0bea3bc 100644 --- a/tests/Feature/GameStateProjectionTest.php +++ b/tests/Feature/GameStateProjectionTest.php @@ -75,6 +75,7 @@ ->and($state->timeoutsTeamB)->toBe(1) ->and($state->substitutionsTeamA)->toBe(1) ->and($state->substitutionsTeamB)->toBe(0) + ->and($state->teamASide)->toBe(TeamSide::Home) ->and($state->servingTeam)->toBe(TeamAB::TeamB) ->and($state->rotationTeamA[1])->toBe(7) ->and($state->rotationTeamB[1])->toBe(2); @@ -162,6 +163,7 @@ 'timeouts_team_b' => 0, 'substitutions_team_a' => 0, 'substitutions_team_b' => 0, + 'team_a_side' => TeamSide::Away->value, 'serving_team' => TeamAB::TeamB->value, 'rotation_team_a' => [], 'rotation_team_b' => [], @@ -170,7 +172,9 @@ 'created_at' => now(), ]); - expect($snapshot->serving_team)->toBe(TeamAB::TeamB) + expect($snapshot->team_a_side)->toBe(TeamSide::Away) + ->and($snapshot->serving_team)->toBe(TeamAB::TeamB) + ->and($snapshot->getRawOriginal('team_a_side'))->toBe(TeamSide::Away->value) ->and($snapshot->getRawOriginal('serving_team'))->toBe(TeamAB::TeamB->value); }); diff --git a/tests/Feature/LineupSubmissionTest.php b/tests/Feature/LineupSubmissionTest.php index 28c3320..0af9946 100644 --- a/tests/Feature/LineupSubmissionTest.php +++ b/tests/Feature/LineupSubmissionTest.php @@ -6,11 +6,15 @@ use App\Enums\GameEventType; use App\Enums\TeamAB; use App\Enums\TeamSide; +use App\Events\Payloads\SetStartedPayload; use App\Livewire\LineupSubmission; use App\Models\Game; +use App\Models\GameEvent; +use App\Models\GameStateSnapshot; use App\Models\Player; use App\Models\Team; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Carbon; use Illuminate\View\ViewException; use Livewire\Features\SupportTesting\Testable; use Livewire\Livewire; @@ -229,3 +233,48 @@ function prepareGameForLineupSubmission(): Game ->assertSee('Submit Lineup') ->assertSee('Team B Lineup'); }); + +test('lineup submission visibility follows snapshot state without querying lineup events', function (): void { + $game = Game::factory()->create(); + + $stateEvent = GameEvent::withoutEvents(fn (): GameEvent => GameEvent::query()->create([ + 'game_id' => $game->getKey(), + 'type' => GameEventType::SetStarted, + 'payload' => new SetStartedPayload, + 'created_at' => Carbon::now(), + ])); + + GameStateSnapshot::query()->create([ + 'game_id' => $game->getKey(), + 'game_event_id' => $stateEvent->getKey(), + 'set_number' => 0, + 'score_team_a' => 0, + 'score_team_b' => 0, + 'sets_won_team_a' => 0, + 'sets_won_team_b' => 0, + 'timeouts_team_a' => 0, + 'timeouts_team_b' => 0, + 'substitutions_team_a' => 0, + 'substitutions_team_b' => 0, + 'team_a_side' => TeamSide::Home->value, + 'serving_team' => TeamAB::TeamA->value, + 'rotation_team_a' => [1 => 1], + 'rotation_team_b' => [], + 'set_in_progress' => false, + 'game_ended' => false, + 'created_at' => Carbon::now(), + ]); + + Livewire::test(LineupSubmission::class, [ + 'team' => TeamAB::TeamA, + 'gameId' => $game->getKey(), + ]) + ->assertDontSee('Submit Lineup'); + + Livewire::test(LineupSubmission::class, [ + 'team' => TeamAB::TeamB, + 'gameId' => $game->getKey(), + ]) + ->assertSee('Submit Lineup') + ->assertSee('Team B Lineup'); +}); diff --git a/tests/Feature/ScoreboardTest.php b/tests/Feature/ScoreboardTest.php index 70a8669..0bedd67 100644 --- a/tests/Feature/ScoreboardTest.php +++ b/tests/Feature/ScoreboardTest.php @@ -3,11 +3,16 @@ declare(strict_types=1); use App\Data\GameState\GameState; +use App\Enums\GameEventType; use App\Enums\TeamAB; use App\Enums\TeamSide; +use App\Events\Payloads\SetStartedPayload; use App\Livewire\Scoreboard; use App\Models\Game; +use App\Models\GameEvent; +use App\Models\GameStateSnapshot; use App\Models\Team; +use Illuminate\Support\Carbon; use Livewire\Livewire; test('scoreboard shows team country codes based on team a and team b toss assignment', function (): void { @@ -78,6 +83,67 @@ ]); }); +test('scoreboard resolves team a side from latest snapshot without requiring toss event lookup', function (): void { + $game = gameWithDistinctTeamCountryCodes(); + + $stateEvent = GameEvent::withoutEvents(fn (): GameEvent => GameEvent::query()->create([ + 'game_id' => $game->getKey(), + 'type' => GameEventType::SetStarted, + 'payload' => new SetStartedPayload, + 'created_at' => Carbon::now(), + ])); + + GameStateSnapshot::query()->create([ + 'game_id' => $game->getKey(), + 'game_event_id' => $stateEvent->getKey(), + 'set_number' => 1, + 'score_team_a' => 0, + 'score_team_b' => 0, + 'sets_won_team_a' => 0, + 'sets_won_team_b' => 0, + 'timeouts_team_a' => 0, + 'timeouts_team_b' => 0, + 'substitutions_team_a' => 0, + 'substitutions_team_b' => 0, + 'team_a_side' => TeamSide::Away->value, + 'serving_team' => TeamAB::TeamA->value, + 'rotation_team_a' => [], + 'rotation_team_b' => [], + 'set_in_progress' => false, + 'game_ended' => false, + 'created_at' => Carbon::now(), + ]); + + $teamACode = $game->awayTeam->country_code; + $teamBCode = $game->homeTeam->country_code; + + Livewire::test(Scoreboard::class, [ + 'gameId' => $game->getKey(), + 'gameState' => GameState::fromAttributes([ + 'sets_won_team_a' => 0, + 'sets_won_team_b' => 0, + 'score_team_a' => 8, + 'score_team_b' => 4, + ]), + ]) + ->assertSeeHtml('data-scoreboard-left-team="team_a"') + ->assertSeeHtml('data-scoreboard-right-team="team_b"') + ->assertSeeInOrder([ + $teamACode, + 'Sets', + $teamBCode, + '0', + ':', + '0', + $teamACode, + 'Points', + $teamBCode, + '8', + ':', + '4', + ]); +}); + function gameWithDistinctTeamCountryCodes(): Game { $homeTeam = Team::factory()->create(); diff --git a/tests/Feature/StartSetSubmissionTest.php b/tests/Feature/StartSetSubmissionTest.php index 134d3cf..be39a60 100644 --- a/tests/Feature/StartSetSubmissionTest.php +++ b/tests/Feature/StartSetSubmissionTest.php @@ -5,11 +5,15 @@ use App\Enums\GameEventType; use App\Enums\TeamAB; use App\Enums\TeamSide; +use App\Events\Payloads\SetStartedPayload; use App\Livewire\StartSetSubmission; use App\Models\Game; +use App\Models\GameEvent; +use App\Models\GameStateSnapshot; use App\Models\Player; use App\Models\Team; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Carbon; use Livewire\Livewire; uses(RefreshDatabase::class); @@ -66,6 +70,41 @@ ->assertSee('Start Set 2'); }); +test('start set button visibility follows snapshot state without querying lineup events', function (): void { + $game = Game::factory()->create(); + + $stateEvent = GameEvent::withoutEvents(fn (): GameEvent => GameEvent::query()->create([ + 'game_id' => $game->getKey(), + 'type' => GameEventType::SetStarted, + 'payload' => new SetStartedPayload, + 'created_at' => Carbon::now(), + ])); + + GameStateSnapshot::query()->create([ + 'game_id' => $game->getKey(), + 'game_event_id' => $stateEvent->getKey(), + 'set_number' => 0, + 'score_team_a' => 0, + 'score_team_b' => 0, + 'sets_won_team_a' => 0, + 'sets_won_team_b' => 0, + 'timeouts_team_a' => 0, + 'timeouts_team_b' => 0, + 'substitutions_team_a' => 0, + 'substitutions_team_b' => 0, + 'team_a_side' => TeamSide::Home->value, + 'serving_team' => TeamAB::TeamA->value, + 'rotation_team_a' => [1 => 1], + 'rotation_team_b' => [1 => 11], + 'set_in_progress' => false, + 'game_ended' => false, + 'created_at' => Carbon::now(), + ]); + + Livewire::test(StartSetSubmission::class, ['gameId' => $game->getKey()]) + ->assertSee('Start Set 1'); +}); + /** * @return array */ diff --git a/tests/Feature/TossResultSubmissionTest.php b/tests/Feature/TossResultSubmissionTest.php index 2b798bf..bf032f5 100644 --- a/tests/Feature/TossResultSubmissionTest.php +++ b/tests/Feature/TossResultSubmissionTest.php @@ -5,9 +5,13 @@ use App\Enums\GameEventType; use App\Enums\TeamAB; use App\Enums\TeamSide; +use App\Events\Payloads\SetStartedPayload; use App\Livewire\TossResultSubmission; use App\Models\Game; +use App\Models\GameEvent; +use App\Models\GameStateSnapshot; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Carbon; use Livewire\Livewire; uses(RefreshDatabase::class); @@ -17,7 +21,7 @@ Livewire::test(TossResultSubmission::class, ['gameId' => $game->getKey()]) ->set('teamA', TeamSide::Away->value) - ->set('serving', TeamAB::TeamB->value) + ->set('serving', TeamSide::Home->value) ->call('submit') ->assertHasNoErrors(); @@ -41,7 +45,7 @@ Livewire::test(TossResultSubmission::class, ['gameId' => $game->getKey()]) ->set('teamA', TeamSide::Away->value) - ->set('serving', TeamAB::TeamB->value) + ->set('serving', TeamSide::Home->value) ->call('submit') ->assertHasErrors(['submit']); @@ -54,14 +58,25 @@ Livewire::test(TossResultSubmission::class, ['gameId' => $targetGame->getKey()]) ->set('teamA', TeamSide::Away->value) - ->set('serving', TeamAB::TeamA->value) + ->set('serving', TeamSide::Away->value) ->call('submit') ->assertHasNoErrors(); expect($targetGame->fresh()->events)->toHaveCount(1) + ->and($targetGame->fresh()->events->first()?->payload->serving)->toBe(TeamAB::TeamA) ->and($otherGame->fresh()->events)->toHaveCount(0); }); +test('toss modal shows home and away team country codes', function (): void { + $game = Game::factory()->create(); + + Livewire::test(TossResultSubmission::class, ['gameId' => $game->getKey()]) + ->assertSee($game->homeTeam->country_code) + ->assertSee($game->awayTeam->country_code) + ->assertDontSee('Home Team') + ->assertDontSee('Away Team'); +}); + test('toss submit button is hidden when toss has already been recorded', function (): void { $game = Game::factory()->create(); $game->recordToss(TeamSide::Home, TeamAB::TeamA); @@ -70,3 +85,39 @@ ->assertDontSee('Submit Toss Result') ->assertDontSee('Save Toss Result'); }); + +test('toss submit button is hidden when snapshot state already includes toss data', function (): void { + $game = Game::factory()->create(); + + $stateEvent = GameEvent::withoutEvents(fn (): GameEvent => GameEvent::query()->create([ + 'game_id' => $game->getKey(), + 'type' => GameEventType::SetStarted, + 'payload' => new SetStartedPayload, + 'created_at' => Carbon::now(), + ])); + + GameStateSnapshot::query()->create([ + 'game_id' => $game->getKey(), + 'game_event_id' => $stateEvent->getKey(), + 'set_number' => 0, + 'score_team_a' => 0, + 'score_team_b' => 0, + 'sets_won_team_a' => 0, + 'sets_won_team_b' => 0, + 'timeouts_team_a' => 0, + 'timeouts_team_b' => 0, + 'substitutions_team_a' => 0, + 'substitutions_team_b' => 0, + 'team_a_side' => TeamSide::Away->value, + 'serving_team' => TeamAB::TeamB->value, + 'rotation_team_a' => [], + 'rotation_team_b' => [], + 'set_in_progress' => false, + 'game_ended' => false, + 'created_at' => Carbon::now(), + ]); + + Livewire::test(TossResultSubmission::class, ['gameId' => $game->getKey()]) + ->assertDontSee('Submit Toss Result') + ->assertDontSee('Save Toss Result'); +}); diff --git a/tests/Unit/ExampleTest.php b/tests/Unit/ExampleTest.php deleted file mode 100644 index 963bc0c..0000000 --- a/tests/Unit/ExampleTest.php +++ /dev/null @@ -1,5 +0,0 @@ -toBeTrue(); -}); From e66b03cdafd05fdd5f65b0cde619e49cc413105f Mon Sep 17 00:00:00 2001 From: Giulio Troccoli-Allard Date: Sun, 29 Mar 2026 17:50:34 +0100 Subject: [PATCH 29/63] feat: extracted functionality for rally-won button --- app/Livewire/Court.php | 38 ---------- app/Livewire/RallyWinnerButton.php | 71 +++++++++++++++++++ resources/views/livewire/court.blade.php | 53 ++++---------- .../livewire/rally-winner-button.blade.php | 19 +++++ .../livewire/start-set-submission.blade.php | 2 +- tests/Feature/CourtTest.php | 13 ++-- 6 files changed, 111 insertions(+), 85 deletions(-) create mode 100644 app/Livewire/RallyWinnerButton.php create mode 100644 resources/views/livewire/rally-winner-button.blade.php diff --git a/app/Livewire/Court.php b/app/Livewire/Court.php index 8337fff..4135c50 100644 --- a/app/Livewire/Court.php +++ b/app/Livewire/Court.php @@ -6,7 +6,6 @@ use App\Data\GameState\GameState; use App\Enums\TeamAB; -use App\Exceptions\InvalidGameEventTransition; use App\Models\Game; use App\Services\GameSideResolver; use Illuminate\Contracts\View\View; @@ -32,43 +31,6 @@ public function render(): View return view('livewire.court', $this->courtContext()); } - public function recordRallyWinner(string $team): void - { - $this->resetValidation('rallyWinner'); - - if (! $this->canRecordRallyWinner()) { - $this->addError('rallyWinner', 'A rally result can only be recorded while a set is in progress.'); - - return; - } - - $winningTeam = TeamAB::tryFrom($team); - - if ($winningTeam === null) { - $this->addError('rallyWinner', 'The selected team is invalid.'); - - return; - } - - $activeGame = $this->activeGame(); - - if ($activeGame === null) { - $this->addError('rallyWinner', 'No active game is available to record the rally winner.'); - - return; - } - - try { - $activeGame->recordRallyWinner($winningTeam); - } catch (InvalidGameEventTransition $exception) { - $this->addError('rallyWinner', $exception->getMessage()); - - return; - } - - $this->dispatch('game-event-recorded'); - } - /** * @return array{ * leftTeam: TeamAB, diff --git a/app/Livewire/RallyWinnerButton.php b/app/Livewire/RallyWinnerButton.php new file mode 100644 index 0000000..6dd8687 --- /dev/null +++ b/app/Livewire/RallyWinnerButton.php @@ -0,0 +1,71 @@ +gameId = $gameId; + $this->team = $team; + $this->side = $side; + } + + public function recordRallyWinner(): void + { + $this->resetValidation('submit'); + + $activeGame = $this->activeGame(); + + if ($activeGame === null) { + $this->addError('submit', 'No active game is available to record the rally winner.'); + + return; + } + + try { + $activeGame->recordRallyWinner($this->team); + } catch (InvalidGameEventTransition $exception) { + $this->addError('submit', $exception->getMessage()); + + return; + } + + $this->dispatch('game-event-recorded'); + } + + public function render(): View + { + return view('livewire.rally-winner-button'); + } + + private function activeGame(): ?Game + { + if ($this->gameId === null) { + return null; + } + + return Game::query()->whereKey($this->gameId)->first(); + } +} diff --git a/resources/views/livewire/court.blade.php b/resources/views/livewire/court.blade.php index 391fcce..43c35c6 100644 --- a/resources/views/livewire/court.blade.php +++ b/resources/views/livewire/court.blade.php @@ -1,4 +1,4 @@ -
+
@php $leftCourtPositionClasses = [ 1 => 'left-[12%] bottom-[14%]', @@ -82,48 +82,21 @@ class="absolute {{ $markerPositionClass }} flex h-8 w-8 items-center justify-cen @if ($canRecordRallyWinner)
- - - + +
@endif - @error('rallyWinner') - {{ $message }} - @enderror - @if ($showRosters)
diff --git a/resources/views/livewire/rally-winner-button.blade.php b/resources/views/livewire/rally-winner-button.blade.php new file mode 100644 index 0000000..d931e37 --- /dev/null +++ b/resources/views/livewire/rally-winner-button.blade.php @@ -0,0 +1,19 @@ +
+ + Winner + + + @error('submit') + {{ $message }} + @enderror +
diff --git a/resources/views/livewire/start-set-submission.blade.php b/resources/views/livewire/start-set-submission.blade.php index 0f8da34..a6e33d2 100644 --- a/resources/views/livewire/start-set-submission.blade.php +++ b/resources/views/livewire/start-set-submission.blade.php @@ -1,6 +1,6 @@
@if ($canStartSet) -
+
assertSeeHtml('data-rally-winner-side-team="right-team_a"'); }); -test('court records rally winner for the selected team and dispatches a refresh event', function (): void { +test('rally winner button records rally winner for the selected team and dispatches a refresh event', function (): void { $game = gameWithStartedSet(); - Livewire::test(Court::class, [ + Livewire::test(RallyWinnerButton::class, [ 'gameId' => $game->getKey(), - 'gameState' => $game->stateAt(), + 'team' => TeamAB::TeamA, + 'side' => 'left', ]) - ->assertSee('Winner') ->assertSeeHtml('data-rally-winner-button="team_a"') - ->assertSeeHtml('data-rally-winner-button="team_b"') - ->call('recordRallyWinner', TeamAB::TeamA->value) + ->assertSeeHtml('data-rally-winner-side-team="left-team_a"') + ->call('recordRallyWinner') ->assertHasNoErrors() ->assertDispatched('game-event-recorded'); From cc1310fc7e11bc86643d0baccdcc2efc44b3b85b Mon Sep 17 00:00:00 2001 From: Giulio Troccoli-Allard Date: Sun, 29 Mar 2026 18:01:21 +0100 Subject: [PATCH 30/63] fix: exclude Unit tests as we don't have any --- phpunit.xml | 3 --- 1 file changed, 3 deletions(-) diff --git a/phpunit.xml b/phpunit.xml index d703241..6ab7d4f 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -5,9 +5,6 @@ colors="true" > - - tests/Unit - tests/Feature From ebb021087c38ce86602ce43deee517eb9b534503 Mon Sep 17 00:00:00 2001 From: Giulio Troccoli-Allard Date: Sun, 29 Mar 2026 18:12:02 +0100 Subject: [PATCH 31/63] feat: ensure use of Flux UI component --- .ai/guidelines/fluxui.md | 4 +++ .../livewire/lineup-submission.blade.php | 8 +++--- .../views/livewire/team-roster.blade.php | 26 +++++++++---------- 3 files changed, 21 insertions(+), 17 deletions(-) create mode 100644 .ai/guidelines/fluxui.md diff --git a/.ai/guidelines/fluxui.md b/.ai/guidelines/fluxui.md new file mode 100644 index 0000000..b0f276f --- /dev/null +++ b/.ai/guidelines/fluxui.md @@ -0,0 +1,4 @@ +## FluxUI + +- When building UI, use FluxUI components whenever possible. +- Use custom HTML/components only when FluxUI does not provide an appropriate option for the required behavior or layout. diff --git a/resources/views/livewire/lineup-submission.blade.php b/resources/views/livewire/lineup-submission.blade.php index 63598fc..c3f9410 100644 --- a/resources/views/livewire/lineup-submission.blade.php +++ b/resources/views/livewire/lineup-submission.blade.php @@ -28,16 +28,16 @@ class="h-12! w-12!" @enderror @if ($rosterNumbers !== []) -
    +
    @foreach ($rosterNumbers as $rosterNumber) -
  • {{ $rosterNumber }} -
  • + @endforeach -
+
@endif
diff --git a/resources/views/livewire/team-roster.blade.php b/resources/views/livewire/team-roster.blade.php index 609efb6..c38dd0a 100644 --- a/resources/views/livewire/team-roster.blade.php +++ b/resources/views/livewire/team-roster.blade.php @@ -1,37 +1,37 @@
@elseif ($players === [] && ! $hasRosterPlayers) -

No players available.

+ No players available. @elseif ($players !== []) -
    @foreach ($players as $player) -
  • {{ $player['number'] }} -
  • + @endforeach -
+
@endif @if ($staffMarkers !== []) -
    @@ -50,8 +50,8 @@ class="flex h-8 w-8 items-center justify-center rounded-full border border-slate {{ $staffMarker['subscript'] }} @endif - + @endforeach -
+
@endif From 98c85c7ef12c5e42c7ee646199786eb445927954 Mon Sep 17 00:00:00 2001 From: Giulio Troccoli-Allard Date: Sun, 29 Mar 2026 18:25:33 +0100 Subject: [PATCH 32/63] fix: ensure serving team is on the correct side --- app/Services/GameState/GameStateProjector.php | 64 ++++++++++++++++--- tests/Feature/CourtTest.php | 44 +++++++++++++ tests/Feature/GameStateProjectionTest.php | 59 +++++++++++++++++ 3 files changed, 158 insertions(+), 9 deletions(-) diff --git a/app/Services/GameState/GameStateProjector.php b/app/Services/GameState/GameStateProjector.php index d25e966..953dbe3 100644 --- a/app/Services/GameState/GameStateProjector.php +++ b/app/Services/GameState/GameStateProjector.php @@ -18,6 +18,9 @@ class GameStateProjector { + /** @var array */ + private array $tossServingTeamByGame = []; + public function project(GameState $state, GameEvent $event): GameState { return match ($event->type) { @@ -26,8 +29,8 @@ public function project(GameState $state, GameEvent $event): GameState GameEventType::RallyEnded => $this->applyRallyEnded($state, $event), GameEventType::SubstitutionCompleted => $this->applySubstitutionCompleted($state, $event), GameEventType::TimeOutRequested => $this->applyTimeOutRequested($state, $event), - GameEventType::SetStarted => $this->applySetStarted($state), - GameEventType::SetEnded => $this->applySetEnded($state), + GameEventType::SetStarted => $this->applySetStarted($state, $event), + GameEventType::SetEnded => $this->applySetEnded($state, $event), GameEventType::GameEnded => $this->applyGameEnded($state), }; } @@ -138,16 +141,17 @@ private function applyTimeOutRequested(GameState $state, GameEvent $event): Game return $state; } - private function applySetStarted(GameState $state): GameState + private function applySetStarted(GameState $state, GameEvent $event): GameState { $state->setNumber = max(1, $state->setNumber + 1); $state->resetCurrentSetCounters(); + $state->servingTeam = $this->servingTeamForSet($event->game_id, $state->setNumber) ?? $state->servingTeam; $state->setInProgress = true; return $state; } - private function applySetEnded(GameState $state): GameState + private function applySetEnded(GameState $state, GameEvent $event): GameState { if ($state->scoreTeamA > $state->scoreTeamB) { $state->setsWonTeamA++; @@ -155,11 +159,8 @@ private function applySetEnded(GameState $state): GameState $state->setsWonTeamB++; } - if ($state->servingTeam !== null) { - $state->servingTeam = $state->servingTeam === TeamAB::TeamA - ? TeamAB::TeamB - : TeamAB::TeamA; - } + $nextSetNumber = max(1, $state->setNumber + 1); + $state->servingTeam = $this->servingTeamForSet($event->game_id, $nextSetNumber) ?? $state->servingTeam; $state->scoreTeamA = 0; $state->scoreTeamB = 0; @@ -227,4 +228,49 @@ private function substitute(array $positions, int $playerOut, int $playerIn): ar return $positions; } + + private function servingTeamForSet(int $gameId, int $setNumber): ?TeamAB + { + $tossServingTeam = $this->tossServingTeamForGame($gameId); + + if ($tossServingTeam === null) { + return null; + } + + return $setNumber % 2 === 1 + ? $tossServingTeam + : $this->oppositeTeam($tossServingTeam); + } + + private function tossServingTeamForGame(int $gameId): ?TeamAB + { + if (array_key_exists($gameId, $this->tossServingTeamByGame)) { + return $this->tossServingTeamByGame[$gameId]; + } + + /** @var GameEvent|null $tossEvent */ + $tossEvent = GameEvent::query() + ->where('game_id', $gameId) + ->where('type', GameEventType::TossCompleted) + ->orderBy('created_at') + ->orderBy('id') + ->first(); + + if ($tossEvent === null || ! $tossEvent->payload instanceof TossCompletedPayload) { + $this->tossServingTeamByGame[$gameId] = null; + + return null; + } + + $this->tossServingTeamByGame[$gameId] = $tossEvent->payload->serving; + + return $this->tossServingTeamByGame[$gameId]; + } + + private function oppositeTeam(TeamAB $team): TeamAB + { + return $team === TeamAB::TeamA + ? TeamAB::TeamB + : TeamAB::TeamA; + } } diff --git a/tests/Feature/CourtTest.php b/tests/Feature/CourtTest.php index ec7ec6b..f611255 100644 --- a/tests/Feature/CourtTest.php +++ b/tests/Feature/CourtTest.php @@ -243,6 +243,50 @@ ->assertSeeHtml('left-[12%] bottom-[14%]'); }); +test('court keeps the serving marker on the left side after a set swap', function (): void { + $game = gameWithStartedSet(); + + for ($index = 0; $index < 25; $index++) { + $game->recordRallyWinner(TeamAB::TeamA); + } + + $game->recordLineup(2, TeamAB::TeamA, [1 => 1, 2 => 2, 3 => 3, 4 => 4, 5 => 5, 6 => 6]); + $game->recordLineup(2, TeamAB::TeamB, [1 => 11, 2 => 12, 3 => 13, 4 => 14, 5 => 15, 6 => 16]); + $game->recordSetStarted(); + + Livewire::test(Court::class, [ + 'gameId' => $game->getKey(), + 'gameState' => $game->stateAt(), + ]) + ->assertSeeHtml('data-court-marker="left-team_b-1"') + ->assertSeeHtml('data-court-serving-player="1"') + ->assertSeeHtml('-left-10 bottom-[14%]') + ->assertSeeHtml('data-court-marker="right-team_a-1"') + ->assertSeeHtml('right-[12%] top-[14%]'); +}); + +test('court keeps serving on the left side after swap even when team b won the previous set', function (): void { + $game = gameWithStartedSet(); + + for ($index = 0; $index < 25; $index++) { + $game->recordRallyWinner(TeamAB::TeamB); + } + + $game->recordLineup(2, TeamAB::TeamA, [1 => 1, 2 => 2, 3 => 3, 4 => 4, 5 => 5, 6 => 6]); + $game->recordLineup(2, TeamAB::TeamB, [1 => 11, 2 => 12, 3 => 13, 4 => 14, 5 => 15, 6 => 16]); + $game->recordSetStarted(); + + Livewire::test(Court::class, [ + 'gameId' => $game->getKey(), + 'gameState' => $game->stateAt(), + ]) + ->assertSeeHtml('data-court-marker="left-team_b-1"') + ->assertSeeHtml('data-court-serving-player="1"') + ->assertSeeHtml('-left-10 bottom-[14%]') + ->assertSeeHtml('data-court-marker="right-team_a-1"') + ->assertSeeHtml('right-[12%] top-[14%]'); +}); + test('court swaps sides as soon as a set ends before the next set starts', function (): void { $game = gameWithNumberedRosters(); $game->recordToss(TeamSide::Home, TeamAB::TeamA); diff --git a/tests/Feature/GameStateProjectionTest.php b/tests/Feature/GameStateProjectionTest.php index 0bea3bc..392b057 100644 --- a/tests/Feature/GameStateProjectionTest.php +++ b/tests/Feature/GameStateProjectionTest.php @@ -137,10 +137,69 @@ ->and($state->setInProgress)->toBeFalse() ->and($state->setsWonTeamA)->toBe(1) ->and($state->setsWonTeamB)->toBe(0) + ->and($state->servingTeam)->toBe(TeamAB::TeamB) ->and($state->scoreTeamA)->toBe(0) ->and($state->scoreTeamB)->toBe(0); }); +test('serving remains on the same court side after a set ends and sides swap', function (): void { + $homeTeam = Team::factory()->create(); + $awayTeam = Team::factory()->create(); + $game = Game::factory()->betweenTeams($homeTeam, $awayTeam)->create(); + ensureRostersForProjection($game, $homeTeam, $awayTeam); + + $game->recordToss(TeamSide::Home, TeamAB::TeamA); + submitProjectionLineupsForSet($game, 1); + $game->recordSetStarted(); + + for ($index = 0; $index < 25; $index++) { + $game->recordRallyWinner(TeamAB::TeamA); + } + + submitProjectionLineupsForSet($game, 2); + $game->recordSetStarted(); + + $state = $game->fresh()->stateAt(); + + expect($state->setNumber)->toBe(2) + ->and($state->setInProgress)->toBeTrue() + ->and($state->setsWonTeamA)->toBe(1) + ->and($state->setsWonTeamB)->toBe(0) + ->and($state->servingTeam)->toBe(TeamAB::TeamB); +}); + +test('serving for the next set does not depend on the previous set winner', function (): void { + $homeTeam = Team::factory()->create(); + $awayTeam = Team::factory()->create(); + $game = Game::factory()->betweenTeams($homeTeam, $awayTeam)->create(); + ensureRostersForProjection($game, $homeTeam, $awayTeam); + + $game->recordToss(TeamSide::Home, TeamAB::TeamA); + submitProjectionLineupsForSet($game, 1); + $game->recordSetStarted(); + + for ($index = 0; $index < 25; $index++) { + $game->recordRallyWinner(TeamAB::TeamB); + } + + $stateAfterSetEnd = $game->fresh()->stateAt(); + + expect($stateAfterSetEnd->setNumber)->toBe(1) + ->and($stateAfterSetEnd->setInProgress)->toBeFalse() + ->and($stateAfterSetEnd->setsWonTeamA)->toBe(0) + ->and($stateAfterSetEnd->setsWonTeamB)->toBe(1) + ->and($stateAfterSetEnd->servingTeam)->toBe(TeamAB::TeamB); + + submitProjectionLineupsForSet($game, 2); + $game->recordSetStarted(); + + $stateAtSetTwoStart = $game->fresh()->stateAt(); + + expect($stateAtSetTwoStart->setNumber)->toBe(2) + ->and($stateAtSetTwoStart->setInProgress)->toBeTrue() + ->and($stateAtSetTwoStart->servingTeam)->toBe(TeamAB::TeamB); +}); + test('game state snapshot accepts a serialized serving team and casts it back to enum', function (): void { $game = Game::factory()->create(); From 55f97ae895b5a00d9b7364f87a177be04d8a2a28 Mon Sep 17 00:00:00 2001 From: Giulio Troccoli-Allard Date: Fri, 3 Apr 2026 12:10:48 +0100 Subject: [PATCH 33/63] fiz: move Winner button above court --- app/Livewire/Court.php | 20 +-------- ...nnerButton.php => RallyWinnerControls.php} | 37 +++++++++++----- resources/views/livewire/court.blade.php | 17 -------- resources/views/livewire/game.blade.php | 1 + .../livewire/rally-winner-button.blade.php | 19 --------- .../livewire/rally-winner-controls.blade.php | 39 +++++++++++++++++ tests/Feature/CourtTest.php | 42 ++++++++++++++----- 7 files changed, 99 insertions(+), 76 deletions(-) rename app/Livewire/{RallyWinnerButton.php => RallyWinnerControls.php} (51%) delete mode 100644 resources/views/livewire/rally-winner-button.blade.php create mode 100644 resources/views/livewire/rally-winner-controls.blade.php diff --git a/app/Livewire/Court.php b/app/Livewire/Court.php index 4135c50..06cd66e 100644 --- a/app/Livewire/Court.php +++ b/app/Livewire/Court.php @@ -9,7 +9,6 @@ use App\Models\Game; use App\Services\GameSideResolver; use Illuminate\Contracts\View\View; -use Livewire\Attributes\Computed; use Livewire\Attributes\Reactive; use Livewire\Component; @@ -37,7 +36,6 @@ public function render(): View * rightTeam: TeamAB, * servingTeam: TeamAB|null, * showRosters: bool, - * canRecordRallyWinner: bool, * leftRotation: array, * rightRotation: array * } @@ -48,7 +46,6 @@ private function courtContext(): array $completedSetCount = $this->completedSetCount(); $leftTeam = $this->gameSideResolver()->teamOnLeft($completedSetCount); $rightTeam = $this->gameSideResolver()->teamOnRight($completedSetCount); - $canRecordRallyWinner = $this->canRecordRallyWinner($game); $showRosters = $game !== null && $this->gameSideResolver()->hasRecordedToss($game); return [ @@ -56,7 +53,6 @@ private function courtContext(): array 'rightTeam' => $rightTeam, 'servingTeam' => $this->resolvedGameState()->servingTeam, 'showRosters' => $showRosters, - 'canRecordRallyWinner' => $canRecordRallyWinner, 'leftRotation' => $this->rotationForTeam($leftTeam), 'rightRotation' => $this->rotationForTeam($rightTeam), ]; @@ -85,21 +81,7 @@ private function completedSetCount(): int return $state->setsWonTeamA + $state->setsWonTeamB; } - private function canRecordRallyWinner(?Game $activeGame = null): bool - { - $activeGame ??= $this->activeGame(); - - if ($activeGame === null) { - return false; - } - - $state = $this->resolvedGameState(); - - return $state->setInProgress && ! $state->gameEnded; - } - - #[Computed] - public function activeGame(): ?Game + private function activeGame(): ?Game { if ($this->gameId === null) { return null; diff --git a/app/Livewire/RallyWinnerButton.php b/app/Livewire/RallyWinnerControls.php similarity index 51% rename from app/Livewire/RallyWinnerButton.php rename to app/Livewire/RallyWinnerControls.php index 6dd8687..ba0efbe 100644 --- a/app/Livewire/RallyWinnerButton.php +++ b/app/Livewire/RallyWinnerControls.php @@ -4,38 +4,39 @@ namespace App\Livewire; +use App\Data\GameState\GameState; use App\Enums\TeamAB; use App\Exceptions\InvalidGameEventTransition; use App\Models\Game; +use App\Services\GameSideResolver; use Illuminate\Contracts\View\View; use Livewire\Attributes\Locked; use Livewire\Attributes\Reactive; use Livewire\Component; -class RallyWinnerButton extends Component +class RallyWinnerControls extends Component { #[Reactive] #[Locked] public ?int $gameId = null; - public TeamAB $team = TeamAB::TeamA; - - public string $side = 'left'; + #[Reactive] + public ?GameState $gameState = null; - public function mount(TeamAB $team, string $side, ?int $gameId = null): void + public function mount(?int $gameId = null): void { abort_if($gameId === null, 404); - abort_unless(in_array($side, ['left', 'right'], true), 404); $this->gameId = $gameId; - $this->team = $team; - $this->side = $side; } - public function recordRallyWinner(): void + public function recordRallyWinner(string $team): void { $this->resetValidation('submit'); + $winningTeam = TeamAB::tryFrom($team); + abort_if($winningTeam === null, 404); + $activeGame = $this->activeGame(); if ($activeGame === null) { @@ -45,7 +46,7 @@ public function recordRallyWinner(): void } try { - $activeGame->recordRallyWinner($this->team); + $activeGame->recordRallyWinner($winningTeam); } catch (InvalidGameEventTransition $exception) { $this->addError('submit', $exception->getMessage()); @@ -57,7 +58,16 @@ public function recordRallyWinner(): void public function render(): View { - return view('livewire.rally-winner-button'); + $state = $this->gameState ?? GameState::initial(); + $completedSetCount = $state->setsWonTeamA + $state->setsWonTeamB; + $leftTeam = $this->gameSideResolver()->teamOnLeft($completedSetCount); + $rightTeam = $this->gameSideResolver()->teamOnRight($completedSetCount); + + return view('livewire.rally-winner-controls', [ + 'leftTeam' => $leftTeam, + 'rightTeam' => $rightTeam, + 'canRecordRallyWinner' => $state->setInProgress && ! $state->gameEnded, + ]); } private function activeGame(): ?Game @@ -68,4 +78,9 @@ private function activeGame(): ?Game return Game::query()->whereKey($this->gameId)->first(); } + + private function gameSideResolver(): GameSideResolver + { + return app(GameSideResolver::class); + } } diff --git a/resources/views/livewire/court.blade.php b/resources/views/livewire/court.blade.php index 43c35c6..3d8ce31 100644 --- a/resources/views/livewire/court.blade.php +++ b/resources/views/livewire/court.blade.php @@ -80,23 +80,6 @@ class="absolute {{ $markerPositionClass }} flex h-8 w-8 items-center justify-cen @endforeach
- @if ($canRecordRallyWinner) -
- - -
- @endif - @if ($showRosters)
diff --git a/resources/views/livewire/game.blade.php b/resources/views/livewire/game.blade.php index 4e98bf8..77d95f5 100644 --- a/resources/views/livewire/game.blade.php +++ b/resources/views/livewire/game.blade.php @@ -5,6 +5,7 @@ class="relative flex min-h-[680px] w-full max-w-[1536px] items-center justify-ce > +
diff --git a/resources/views/livewire/rally-winner-button.blade.php b/resources/views/livewire/rally-winner-button.blade.php deleted file mode 100644 index d931e37..0000000 --- a/resources/views/livewire/rally-winner-button.blade.php +++ /dev/null @@ -1,19 +0,0 @@ -
- - Winner - - - @error('submit') - {{ $message }} - @enderror -
diff --git a/resources/views/livewire/rally-winner-controls.blade.php b/resources/views/livewire/rally-winner-controls.blade.php new file mode 100644 index 0000000..ecc3fa3 --- /dev/null +++ b/resources/views/livewire/rally-winner-controls.blade.php @@ -0,0 +1,39 @@ +
+ @if ($canRecordRallyWinner) +
+
+ + Winner + + + + Winner + +
+ + @error('submit') + {{ $message }} + @enderror +
+ @endif +
diff --git a/tests/Feature/CourtTest.php b/tests/Feature/CourtTest.php index f611255..495cf27 100644 --- a/tests/Feature/CourtTest.php +++ b/tests/Feature/CourtTest.php @@ -8,7 +8,7 @@ use App\Enums\TeamSide; use App\Events\Payloads\RallyEndedPayload; use App\Livewire\Court; -use App\Livewire\RallyWinnerButton; +use App\Livewire\RallyWinnerControls; use App\Models\Game; use App\Models\Player; use App\Models\Team; @@ -340,10 +340,25 @@ ]); }); -test('court shows rally winner buttons only while a set is in progress', function (): void { +test('court does not render rally winner controls', function (): void { $game = Game::factory()->create(); Livewire::test(Court::class, [ + 'gameId' => $game->getKey(), + 'gameState' => gameState([ + 'set_number' => 1, + 'set_in_progress' => true, + ]), + ]) + ->assertDontSee('Winner') + ->assertDontSeeHtml('data-rally-winner-button="team_a"') + ->assertDontSeeHtml('data-rally-winner-button="team_b"'); +}); + +test('rally winner controls show buttons only while a set is in progress and game is not ended', function (): void { + $game = Game::factory()->create(); + + Livewire::test(RallyWinnerControls::class, [ 'gameId' => $game->getKey(), 'gameState' => gameState(['set_number' => 1, 'set_in_progress' => false]), ]) @@ -351,19 +366,27 @@ ->assertDontSeeHtml('data-rally-winner-button="team_a"') ->assertDontSeeHtml('data-rally-winner-button="team_b"'); - Livewire::test(Court::class, [ + Livewire::test(RallyWinnerControls::class, [ 'gameId' => $game->getKey(), 'gameState' => gameState(['set_number' => 1, 'set_in_progress' => true]), ]) ->assertSee('Winner') ->assertSeeHtml('data-rally-winner-button="team_a"') ->assertSeeHtml('data-rally-winner-button="team_b"'); + + Livewire::test(RallyWinnerControls::class, [ + 'gameId' => $game->getKey(), + 'gameState' => gameState(['set_number' => 5, 'set_in_progress' => true, 'game_ended' => true]), + ]) + ->assertDontSee('Winner') + ->assertDontSeeHtml('data-rally-winner-button="team_a"') + ->assertDontSeeHtml('data-rally-winner-button="team_b"'); }); -test('court swaps rally winner buttons as soon as sides swap', function (): void { +test('rally winner controls swap sides as soon as sides swap', function (): void { $game = Game::factory()->create(); - Livewire::test(Court::class, [ + Livewire::test(RallyWinnerControls::class, [ 'gameId' => $game->getKey(), 'gameState' => gameState([ 'set_number' => 2, @@ -375,17 +398,16 @@ ->assertSeeHtml('data-rally-winner-side-team="right-team_a"'); }); -test('rally winner button records rally winner for the selected team and dispatches a refresh event', function (): void { +test('rally winner controls record rally winner for the selected team and dispatches a refresh event', function (): void { $game = gameWithStartedSet(); - Livewire::test(RallyWinnerButton::class, [ + Livewire::test(RallyWinnerControls::class, [ 'gameId' => $game->getKey(), - 'team' => TeamAB::TeamA, - 'side' => 'left', + 'gameState' => gameState(['set_number' => 1, 'set_in_progress' => true]), ]) ->assertSeeHtml('data-rally-winner-button="team_a"') ->assertSeeHtml('data-rally-winner-side-team="left-team_a"') - ->call('recordRallyWinner') + ->call('recordRallyWinner', TeamAB::TeamA->value) ->assertHasNoErrors() ->assertDispatched('game-event-recorded'); From 4702d7137e1d3b40f2071e1ad505f90340154a91 Mon Sep 17 00:00:00 2001 From: Giulio Troccoli-Allard Date: Tue, 28 Apr 2026 17:47:41 +0100 Subject: [PATCH 34/63] chore: rename CacheRepository as it does not deal with caching --- app/Livewire/TeamRoster.php | 10 +++++----- ...itory.php => ScoresheetDataRepository.php} | 2 +- ...t.php => ScoresheetDataRepositoryTest.php} | 20 +++++++++---------- 3 files changed, 16 insertions(+), 16 deletions(-) rename app/Services/{CacheRepository.php => ScoresheetDataRepository.php} (98%) rename tests/Feature/{CacheRepositoryTest.php => ScoresheetDataRepositoryTest.php} (86%) diff --git a/app/Livewire/TeamRoster.php b/app/Livewire/TeamRoster.php index f178f30..511b669 100644 --- a/app/Livewire/TeamRoster.php +++ b/app/Livewire/TeamRoster.php @@ -9,8 +9,8 @@ use App\Enums\TeamAB; use App\Enums\TeamSide; use App\Models\Game; -use App\Services\CacheRepository; use App\Services\GameSideResolver; +use App\Services\ScoresheetDataRepository; use Illuminate\Contracts\View\View; use Livewire\Attributes\Computed; use Livewire\Attributes\Locked; @@ -86,7 +86,7 @@ public function teamPlayers(): array return []; } - return $this->cacheRepository()->playersForSide($game, $this->targetSideForTeam($game, $this->team)); + return $this->scoresheetDataRepository()->playersForSide($game, $this->targetSideForTeam($game, $this->team)); } /** @@ -116,7 +116,7 @@ public function teamStaff(): array return []; } - return $this->cacheRepository()->staffForSide($game, $this->targetSideForTeam($game, $this->team)); + return $this->scoresheetDataRepository()->staffForSide($game, $this->targetSideForTeam($game, $this->team)); } /** @@ -168,9 +168,9 @@ private function buildStaffMarkers(array $staff): array return $markers; } - private function cacheRepository(): CacheRepository + private function scoresheetDataRepository(): ScoresheetDataRepository { - return app(CacheRepository::class); + return app(ScoresheetDataRepository::class); } private function targetSideForTeam(Game $game, TeamAB $team): TeamSide diff --git a/app/Services/CacheRepository.php b/app/Services/ScoresheetDataRepository.php similarity index 98% rename from app/Services/CacheRepository.php rename to app/Services/ScoresheetDataRepository.php index 425d6ba..64c1f5a 100644 --- a/app/Services/CacheRepository.php +++ b/app/Services/ScoresheetDataRepository.php @@ -13,7 +13,7 @@ use App\Models\Player; use App\Models\Staff; -class CacheRepository +class ScoresheetDataRepository { /** * @return arrayplayersForSide($game, TeamSide::Home); $awayPlayers = $repository->playersForSide($game, TeamSide::Away); @@ -28,8 +28,8 @@ }); test('players for side reflects roster changes on repeated reads', function (): void { - $game = gameWithNumberedRostersForCacheRepository(); - $repository = app(CacheRepository::class); + $game = gameWithNumberedRostersForScoresheetDataRepository(); + $repository = app(ScoresheetDataRepository::class); $playersBeforeRosterChange = $repository->playersForSide($game, TeamSide::Home); @@ -43,10 +43,10 @@ }); test('latest toss payload returns the recorded toss payload', function (): void { - $game = gameWithNumberedRostersForCacheRepository(); + $game = gameWithNumberedRostersForScoresheetDataRepository(); $game->recordToss(TeamSide::Away, TeamAB::TeamB); - $repository = app(CacheRepository::class); + $repository = app(ScoresheetDataRepository::class); $payload = $repository->latestTossPayload($game); expect($payload)->not->toBeNull() @@ -55,10 +55,10 @@ }); test('latest toss payload reflects new toss events on repeated reads', function (): void { - $game = gameWithNumberedRostersForCacheRepository(); + $game = gameWithNumberedRostersForScoresheetDataRepository(); $game->recordToss(TeamSide::Home, TeamAB::TeamA); - $repository = app(CacheRepository::class); + $repository = app(ScoresheetDataRepository::class); $payloadBeforeSecondToss = $repository->latestTossPayload($game); $game->events()->create([ @@ -78,7 +78,7 @@ ->and($payloadAfterRecordingToss?->serving)->toBe(TeamAB::TeamB); }); -function gameWithNumberedRostersForCacheRepository(): Game +function gameWithNumberedRostersForScoresheetDataRepository(): Game { $homeTeam = Team::factory()->create(); $awayTeam = Team::factory()->create(); From abe05fea3d56678b0e515513650ee50142c33b4a Mon Sep 17 00:00:00 2001 From: Giulio Troccoli-Allard Date: Tue, 28 Apr 2026 17:51:37 +0100 Subject: [PATCH 35/63] chore: update to Laravel 13 --- app/Models/User.php | 34 +- composer.json | 8 +- composer.lock | 832 +++++++++++++++++++------------------------- config/cache.php | 13 + rector.php | 9 +- 5 files changed, 392 insertions(+), 504 deletions(-) diff --git a/app/Models/User.php b/app/Models/User.php index 3dbc51b..2377c89 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -6,38 +6,26 @@ // use Illuminate\Contracts\Auth\MustVerifyEmail; use Database\Factories\UserFactory; +use Illuminate\Database\Eloquent\Attributes\Fillable; +use Illuminate\Database\Eloquent\Attributes\Hidden; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; +#[Fillable([ + 'name', + 'email', + 'password', +])] +#[Hidden([ + 'password', + 'remember_token', +])] class User extends Authenticatable { /** @use HasFactory */ use HasFactory, Notifiable; - /** - * The attributes that are mass assignable. - * - * @var list - */ - #[\Override] - protected $fillable = [ - 'name', - 'email', - 'password', - ]; - - /** - * The attributes that should be hidden for serialization. - * - * @var list - */ - #[\Override] - protected $hidden = [ - 'password', - 'remember_token', - ]; - /** * Get the attributes that should be cast. * diff --git a/composer.json b/composer.json index 3b136b2..67470d1 100644 --- a/composer.json +++ b/composer.json @@ -11,8 +11,8 @@ "require": { "php": "^8.5", "ext-iconv": "*", - "laravel/framework": "^12.0", - "laravel/tinker": "^2.10.1", + "laravel/framework": "^13.0", + "laravel/tinker": "^3.0", "livewire/flux": "^2.13", "livewire/flux-pro": "^2.13", "livewire/livewire": "^4.2", @@ -65,9 +65,9 @@ "@php artisan test" ], "lint":[ - "@php ./vendor/bin/pint", + "@php ./vendor/bin/rector process --clear-cache --memory-limit=2G", "@php ./vendor/bin/phpstan analyse --memory-limit=2G", - "@php ./vendor/bin/rector process --clear-cache --memory-limit=2G" + "@php ./vendor/bin/pint" ], "post-autoload-dump": [ "Illuminate\\Foundation\\ComposerScripts::postAutoloadDump", diff --git a/composer.lock b/composer.lock index 58eed7d..7e7e8e0 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": "c7bfcf11f9dc9bf9ddd8d5231e410378", + "content-hash": "e907f6bb60ca7bd9b048784533786dd5", "packages": [ { "name": "brick/math", @@ -1055,24 +1055,24 @@ }, { "name": "laravel/framework", - "version": "v12.54.1", + "version": "v13.6.0", "source": { "type": "git", "url": "https://github.com/laravel/framework.git", - "reference": "325497463e7599cd14224c422c6e5dd2fe832868" + "reference": "416a93ea9c53161e0d4b8a44045f447b65a7d2f1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/framework/zipball/325497463e7599cd14224c422c6e5dd2fe832868", - "reference": "325497463e7599cd14224c422c6e5dd2fe832868", + "url": "https://api.github.com/repos/laravel/framework/zipball/416a93ea9c53161e0d4b8a44045f447b65a7d2f1", + "reference": "416a93ea9c53161e0d4b8a44045f447b65a7d2f1", "shasum": "" }, "require": { - "brick/math": "^0.11|^0.12|^0.13|^0.14", + "brick/math": "^0.14.2 || ^0.15 || ^0.16 || ^0.17", "composer-runtime-api": "^2.2", "doctrine/inflector": "^2.0.5", "dragonmantank/cron-expression": "^3.4", - "egulias/email-validator": "^3.2.1|^4.0", + "egulias/email-validator": "^4.0", "ext-ctype": "*", "ext-filter": "*", "ext-hash": "*", @@ -1082,9 +1082,10 @@ "ext-tokenizer": "*", "fruitcake/php-cors": "^1.3", "guzzlehttp/guzzle": "^7.8.2", + "guzzlehttp/promises": "^2.0.3", "guzzlehttp/uri-template": "^1.0", "laravel/prompts": "^0.3.0", - "laravel/serializable-closure": "^1.3|^2.0", + "laravel/serializable-closure": "^2.0.10", "league/commonmark": "^2.8.1", "league/flysystem": "^3.25.1", "league/flysystem-local": "^3.25.1", @@ -1092,25 +1093,24 @@ "monolog/monolog": "^3.0", "nesbot/carbon": "^3.8.4", "nunomaduro/termwind": "^2.0", - "php": "^8.2", - "psr/container": "^1.1.1|^2.0.1", - "psr/log": "^1.0|^2.0|^3.0", - "psr/simple-cache": "^1.0|^2.0|^3.0", + "php": "^8.3", + "psr/container": "^1.1.1 || ^2.0.1", + "psr/log": "^1.0 || ^2.0 || ^3.0", + "psr/simple-cache": "^1.0 || ^2.0 || ^3.0", "ramsey/uuid": "^4.7", - "symfony/console": "^7.2.0", - "symfony/error-handler": "^7.2.0", - "symfony/finder": "^7.2.0", - "symfony/http-foundation": "^7.2.0", - "symfony/http-kernel": "^7.2.0", - "symfony/mailer": "^7.2.0", - "symfony/mime": "^7.2.0", - "symfony/polyfill-php83": "^1.33", + "symfony/console": "^7.4.0 || ^8.0.0", + "symfony/error-handler": "^7.4.0 || ^8.0.0", + "symfony/finder": "^7.4.0 || ^8.0.0", + "symfony/http-foundation": "^7.4.0 || ^8.0.0", + "symfony/http-kernel": "^7.4.0 || ^8.0.0", + "symfony/mailer": "^7.4.0 || ^8.0.0", + "symfony/mime": "^7.4.0 || ^8.0.0", "symfony/polyfill-php84": "^1.33", "symfony/polyfill-php85": "^1.33", - "symfony/process": "^7.2.0", - "symfony/routing": "^7.2.0", - "symfony/uid": "^7.2.0", - "symfony/var-dumper": "^7.2.0", + "symfony/process": "^7.4.5 || ^8.0.5", + "symfony/routing": "^7.4.0 || ^8.0.0", + "symfony/uid": "^7.4.0 || ^8.0.0", + "symfony/var-dumper": "^7.4.0 || ^8.0.0", "tijsverkoyen/css-to-inline-styles": "^2.2.5", "vlucas/phpdotenv": "^5.6.1", "voku/portable-ascii": "^2.0.2" @@ -1119,9 +1119,9 @@ "tightenco/collect": "<5.5.33" }, "provide": { - "psr/container-implementation": "1.1|2.0", - "psr/log-implementation": "1.0|2.0|3.0", - "psr/simple-cache-implementation": "1.0|2.0|3.0" + "psr/container-implementation": "1.1 || 2.0", + "psr/log-implementation": "1.0 || 2.0 || 3.0", + "psr/simple-cache-implementation": "1.0 || 2.0 || 3.0" }, "replace": { "illuminate/auth": "self.version", @@ -1167,7 +1167,6 @@ "aws/aws-sdk-php": "^3.322.9", "ext-gmp": "*", "fakerphp/faker": "^1.24", - "guzzlehttp/promises": "^2.0.3", "guzzlehttp/psr7": "^2.4", "laravel/pint": "^1.18", "league/flysystem-aws-s3-v3": "^3.25.1", @@ -1177,22 +1176,23 @@ "league/flysystem-sftp-v3": "^3.25.1", "mockery/mockery": "^1.6.10", "opis/json-schema": "^2.4.1", - "orchestra/testbench-core": "^10.9.0", - "pda/pheanstalk": "^5.0.6|^7.0.0", + "orchestra/testbench-core": "^11.0.0", + "pda/pheanstalk": "^7.0.0 || ^8.0.0", "php-http/discovery": "^1.15", "phpstan/phpstan": "^2.0", - "phpunit/phpunit": "^10.5.35|^11.5.3|^12.0.1", - "predis/predis": "^2.3|^3.0", - "resend/resend-php": "^0.10.0|^1.0", - "symfony/cache": "^7.2.0", - "symfony/http-client": "^7.2.0", - "symfony/psr-http-message-bridge": "^7.2.0", - "symfony/translation": "^7.2.0" + "phpunit/phpunit": "^11.5.50 || ^12.5.8 || ^13.0.3", + "predis/predis": "^2.3 || ^3.0", + "rector/rector": "^2.3", + "resend/resend-php": "^1.0", + "symfony/cache": "^7.4.0 || ^8.0.0", + "symfony/http-client": "^7.4.0 || ^8.0.0", + "symfony/psr-http-message-bridge": "^7.4.0 || ^8.0.0", + "symfony/translation": "^7.4.0 || ^8.0.0" }, "suggest": { "ably/ably-php": "Required to use the Ably broadcast driver (^1.0).", "aws/aws-sdk-php": "Required to use the SQS queue driver, DynamoDb failed job storage, and SES mail driver (^3.322.9).", - "brianium/paratest": "Required to run tests in parallel (^7.0|^8.0).", + "brianium/paratest": "Required to run tests in parallel (^7.0 || ^8.0).", "ext-apcu": "Required to use the APC cache driver.", "ext-fileinfo": "Required to use the Filesystem class.", "ext-ftp": "Required to use the Flysystem FTP driver.", @@ -1201,7 +1201,7 @@ "ext-pcntl": "Required to use all features of the queue worker and console signal trapping.", "ext-pdo": "Required to use all database features.", "ext-posix": "Required to use all features of the queue worker.", - "ext-redis": "Required to use the Redis cache and queue drivers (^4.0|^5.0|^6.0).", + "ext-redis": "Required to use the Redis cache and queue drivers (^4.0 || ^5.0 || ^6.0).", "fakerphp/faker": "Required to generate fake data using the fake() helper (^1.23).", "filp/whoops": "Required for friendly error pages in development (^2.14.3).", "laravel/tinker": "Required to use the tinker console command (^2.0).", @@ -1211,24 +1211,25 @@ "league/flysystem-read-only": "Required to use read-only disks (^3.25.1)", "league/flysystem-sftp-v3": "Required to use the Flysystem SFTP driver (^3.25.1).", "mockery/mockery": "Required to use mocking (^1.6).", - "pda/pheanstalk": "Required to use the beanstalk queue driver (^5.0).", + "pda/pheanstalk": "Required to use the beanstalk queue driver (^7.0 || ^8.0).", "php-http/discovery": "Required to use PSR-7 bridging features (^1.15).", - "phpunit/phpunit": "Required to use assertions and run tests (^10.5.35|^11.5.3|^12.0.1).", - "predis/predis": "Required to use the predis connector (^2.3|^3.0).", + "phpunit/phpunit": "Required to use assertions and run tests (^11.5.50 || ^12.5.8 || ^13.0.3).", + "predis/predis": "Required to use the predis connector (^2.3 || ^3.0).", "psr/http-message": "Required to allow Storage::put to accept a StreamInterface (^1.0).", - "pusher/pusher-php-server": "Required to use the Pusher broadcast driver (^6.0|^7.0).", - "resend/resend-php": "Required to enable support for the Resend mail transport (^0.10.0|^1.0).", - "symfony/cache": "Required to PSR-6 cache bridge (^7.2).", - "symfony/filesystem": "Required to enable support for relative symbolic links (^7.2).", - "symfony/http-client": "Required to enable support for the Symfony API mail transports (^7.2).", - "symfony/mailgun-mailer": "Required to enable support for the Mailgun mail transport (^7.2).", - "symfony/postmark-mailer": "Required to enable support for the Postmark mail transport (^7.2).", - "symfony/psr-http-message-bridge": "Required to use PSR-7 bridging features (^7.2)." + "pusher/pusher-php-server": "Required to use the Pusher broadcast driver (^6.0 || ^7.0).", + "resend/resend-php": "Required to enable support for the Resend mail transport (^0.10.0 || ^1.0).", + "spatie/fork": "Required to use the 'fork' concurrency driver (^1.2).", + "symfony/cache": "Required to PSR-6 cache bridge (^7.4 || ^8.0).", + "symfony/filesystem": "Required to enable support for relative symbolic links (^7.4 || ^8.0).", + "symfony/http-client": "Required to enable support for the Symfony API mail transports (^7.4 || ^8.0).", + "symfony/mailgun-mailer": "Required to enable support for the Mailgun mail transport (^7.4 || ^8.0).", + "symfony/postmark-mailer": "Required to enable support for the Postmark mail transport (^7.4 || ^8.0).", + "symfony/psr-http-message-bridge": "Required to use PSR-7 bridging features (^7.4 || ^8.0)." }, "type": "library", "extra": { "branch-alias": { - "dev-master": "12.x-dev" + "dev-master": "13.0.x-dev" } }, "autoload": { @@ -1273,20 +1274,20 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2026-03-10T20:25:56+00:00" + "time": "2026-04-21T13:32:11+00:00" }, { "name": "laravel/prompts", - "version": "v0.3.14", + "version": "v0.3.17", "source": { "type": "git", "url": "https://github.com/laravel/prompts.git", - "reference": "9f0e371244eedfe2ebeaa72c79c54bb5df6e0176" + "reference": "6a82ac19a28b916ae0885828795dbd4c59d9a818" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/prompts/zipball/9f0e371244eedfe2ebeaa72c79c54bb5df6e0176", - "reference": "9f0e371244eedfe2ebeaa72c79c54bb5df6e0176", + "url": "https://api.github.com/repos/laravel/prompts/zipball/6a82ac19a28b916ae0885828795dbd4c59d9a818", + "reference": "6a82ac19a28b916ae0885828795dbd4c59d9a818", "shasum": "" }, "require": { @@ -1330,22 +1331,22 @@ "description": "Add beautiful and user-friendly forms to your command-line applications.", "support": { "issues": "https://github.com/laravel/prompts/issues", - "source": "https://github.com/laravel/prompts/tree/v0.3.14" + "source": "https://github.com/laravel/prompts/tree/v0.3.17" }, - "time": "2026-03-01T09:02:38+00:00" + "time": "2026-04-20T16:07:33+00:00" }, { "name": "laravel/serializable-closure", - "version": "v2.0.10", + "version": "v2.0.12", "source": { "type": "git", "url": "https://github.com/laravel/serializable-closure.git", - "reference": "870fc81d2f879903dfc5b60bf8a0f94a1609e669" + "reference": "a6abb4e54f6fcd3138120b9ad497f0bd146f9919" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/870fc81d2f879903dfc5b60bf8a0f94a1609e669", - "reference": "870fc81d2f879903dfc5b60bf8a0f94a1609e669", + "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/a6abb4e54f6fcd3138120b9ad497f0bd146f9919", + "reference": "a6abb4e54f6fcd3138120b9ad497f0bd146f9919", "shasum": "" }, "require": { @@ -1393,37 +1394,37 @@ "issues": "https://github.com/laravel/serializable-closure/issues", "source": "https://github.com/laravel/serializable-closure" }, - "time": "2026-02-20T19:59:49+00:00" + "time": "2026-04-14T13:33:34+00:00" }, { "name": "laravel/tinker", - "version": "v2.11.1", + "version": "v3.0.2", "source": { "type": "git", "url": "https://github.com/laravel/tinker.git", - "reference": "c9f80cc835649b5c1842898fb043f8cc098dd741" + "reference": "4faba77764bd33411735936acdf30446d058c78b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/tinker/zipball/c9f80cc835649b5c1842898fb043f8cc098dd741", - "reference": "c9f80cc835649b5c1842898fb043f8cc098dd741", + "url": "https://api.github.com/repos/laravel/tinker/zipball/4faba77764bd33411735936acdf30446d058c78b", + "reference": "4faba77764bd33411735936acdf30446d058c78b", "shasum": "" }, "require": { - "illuminate/console": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0", - "illuminate/contracts": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0", - "illuminate/support": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0", - "php": "^7.2.5|^8.0", - "psy/psysh": "^0.11.1|^0.12.0", - "symfony/var-dumper": "^4.3.4|^5.0|^6.0|^7.0|^8.0" + "illuminate/console": "^8.0|^9.0|^10.0|^11.0|^12.0|^13.0", + "illuminate/contracts": "^8.0|^9.0|^10.0|^11.0|^12.0|^13.0", + "illuminate/support": "^8.0|^9.0|^10.0|^11.0|^12.0|^13.0", + "php": "^8.1", + "psy/psysh": "^0.12.0", + "symfony/var-dumper": "^5.4|^6.0|^7.0|^8.0" }, "require-dev": { "mockery/mockery": "~1.3.3|^1.4.2", "phpstan/phpstan": "^1.10", - "phpunit/phpunit": "^8.5.8|^9.3.3|^10.0" + "phpunit/phpunit": "^10.5|^11.5" }, "suggest": { - "illuminate/database": "The Illuminate Database package (^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0)." + "illuminate/database": "The Illuminate Database package (^8.0|^9.0|^10.0|^11.0|^12.0|^13.0)." }, "type": "library", "extra": { @@ -1431,6 +1432,9 @@ "providers": [ "Laravel\\Tinker\\TinkerServiceProvider" ] + }, + "branch-alias": { + "dev-master": "3.x-dev" } }, "autoload": { @@ -1457,22 +1461,22 @@ ], "support": { "issues": "https://github.com/laravel/tinker/issues", - "source": "https://github.com/laravel/tinker/tree/v2.11.1" + "source": "https://github.com/laravel/tinker/tree/v3.0.2" }, - "time": "2026-02-06T14:12:35+00:00" + "time": "2026-03-17T14:54:13+00:00" }, { "name": "league/commonmark", - "version": "2.8.1", + "version": "2.8.2", "source": { "type": "git", "url": "https://github.com/thephpleague/commonmark.git", - "reference": "84b1ca48347efdbe775426f108622a42735a6579" + "reference": "59fb075d2101740c337c7216e3f32b36c204218b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/84b1ca48347efdbe775426f108622a42735a6579", - "reference": "84b1ca48347efdbe775426f108622a42735a6579", + "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/59fb075d2101740c337c7216e3f32b36c204218b", + "reference": "59fb075d2101740c337c7216e3f32b36c204218b", "shasum": "" }, "require": { @@ -1566,7 +1570,7 @@ "type": "tidelift" } ], - "time": "2026-03-05T21:37:03+00:00" + "time": "2026-03-19T13:16:38+00:00" }, { "name": "league/config", @@ -1652,16 +1656,16 @@ }, { "name": "league/flysystem", - "version": "3.32.0", + "version": "3.33.0", "source": { "type": "git", "url": "https://github.com/thephpleague/flysystem.git", - "reference": "254b1595b16b22dbddaaef9ed6ca9fdac4956725" + "reference": "570b8871e0ce693764434b29154c54b434905350" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/254b1595b16b22dbddaaef9ed6ca9fdac4956725", - "reference": "254b1595b16b22dbddaaef9ed6ca9fdac4956725", + "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/570b8871e0ce693764434b29154c54b434905350", + "reference": "570b8871e0ce693764434b29154c54b434905350", "shasum": "" }, "require": { @@ -1729,9 +1733,9 @@ ], "support": { "issues": "https://github.com/thephpleague/flysystem/issues", - "source": "https://github.com/thephpleague/flysystem/tree/3.32.0" + "source": "https://github.com/thephpleague/flysystem/tree/3.33.0" }, - "time": "2026-02-25T17:01:41+00:00" + "time": "2026-03-25T07:59:30+00:00" }, { "name": "league/flysystem-local", @@ -1840,20 +1844,20 @@ }, { "name": "league/uri", - "version": "7.8.0", + "version": "7.8.1", "source": { "type": "git", "url": "https://github.com/thephpleague/uri.git", - "reference": "4436c6ec8d458e4244448b069cc572d088230b76" + "reference": "08cf38e3924d4f56238125547b5720496fac8fd4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/uri/zipball/4436c6ec8d458e4244448b069cc572d088230b76", - "reference": "4436c6ec8d458e4244448b069cc572d088230b76", + "url": "https://api.github.com/repos/thephpleague/uri/zipball/08cf38e3924d4f56238125547b5720496fac8fd4", + "reference": "08cf38e3924d4f56238125547b5720496fac8fd4", "shasum": "" }, "require": { - "league/uri-interfaces": "^7.8", + "league/uri-interfaces": "^7.8.1", "php": "^8.1", "psr/http-factory": "^1" }, @@ -1926,7 +1930,7 @@ "docs": "https://uri.thephpleague.com", "forum": "https://thephpleague.slack.com", "issues": "https://github.com/thephpleague/uri-src/issues", - "source": "https://github.com/thephpleague/uri/tree/7.8.0" + "source": "https://github.com/thephpleague/uri/tree/7.8.1" }, "funding": [ { @@ -1934,20 +1938,20 @@ "type": "github" } ], - "time": "2026-01-14T17:24:56+00:00" + "time": "2026-03-15T20:22:25+00:00" }, { "name": "league/uri-interfaces", - "version": "7.8.0", + "version": "7.8.1", "source": { "type": "git", "url": "https://github.com/thephpleague/uri-interfaces.git", - "reference": "c5c5cd056110fc8afaba29fa6b72a43ced42acd4" + "reference": "85d5c77c5d6d3af6c54db4a78246364908f3c928" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/uri-interfaces/zipball/c5c5cd056110fc8afaba29fa6b72a43ced42acd4", - "reference": "c5c5cd056110fc8afaba29fa6b72a43ced42acd4", + "url": "https://api.github.com/repos/thephpleague/uri-interfaces/zipball/85d5c77c5d6d3af6c54db4a78246364908f3c928", + "reference": "85d5c77c5d6d3af6c54db4a78246364908f3c928", "shasum": "" }, "require": { @@ -2010,7 +2014,7 @@ "docs": "https://uri.thephpleague.com", "forum": "https://thephpleague.slack.com", "issues": "https://github.com/thephpleague/uri-src/issues", - "source": "https://github.com/thephpleague/uri-interfaces/tree/7.8.0" + "source": "https://github.com/thephpleague/uri-interfaces/tree/7.8.1" }, "funding": [ { @@ -2018,7 +2022,7 @@ "type": "github" } ], - "time": "2026-01-15T06:54:53+00:00" + "time": "2026-03-08T20:05:35+00:00" }, { "name": "livewire/flux", @@ -2340,16 +2344,16 @@ }, { "name": "nesbot/carbon", - "version": "3.11.3", + "version": "3.11.4", "source": { "type": "git", "url": "https://github.com/CarbonPHP/carbon.git", - "reference": "6a7e652845bb018c668220c2a545aded8594fbbf" + "reference": "e890471a3494740f7d9326d72ce6a8c559ffee60" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/6a7e652845bb018c668220c2a545aded8594fbbf", - "reference": "6a7e652845bb018c668220c2a545aded8594fbbf", + "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/e890471a3494740f7d9326d72ce6a8c559ffee60", + "reference": "e890471a3494740f7d9326d72ce6a8c559ffee60", "shasum": "" }, "require": { @@ -2441,7 +2445,7 @@ "type": "tidelift" } ], - "time": "2026-03-11T17:23:39+00:00" + "time": "2026-04-07T09:57:54+00:00" }, { "name": "nette/schema", @@ -3235,16 +3239,16 @@ }, { "name": "psy/psysh", - "version": "v0.12.21", + "version": "v0.12.22", "source": { "type": "git", "url": "https://github.com/bobthecow/psysh.git", - "reference": "4821fab5b7cd8c49a673a9fd5754dc9162bb9e97" + "reference": "3be75d5b9244936dd4ac62ade2bfb004d13acf0f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/bobthecow/psysh/zipball/4821fab5b7cd8c49a673a9fd5754dc9162bb9e97", - "reference": "4821fab5b7cd8c49a673a9fd5754dc9162bb9e97", + "url": "https://api.github.com/repos/bobthecow/psysh/zipball/3be75d5b9244936dd4ac62ade2bfb004d13acf0f", + "reference": "3be75d5b9244936dd4ac62ade2bfb004d13acf0f", "shasum": "" }, "require": { @@ -3308,9 +3312,9 @@ ], "support": { "issues": "https://github.com/bobthecow/psysh/issues", - "source": "https://github.com/bobthecow/psysh/tree/v0.12.21" + "source": "https://github.com/bobthecow/psysh/tree/v0.12.22" }, - "time": "2026-03-06T21:21:28+00:00" + "time": "2026-03-22T23:03:24+00:00" }, { "name": "ralouphie/getallheaders", @@ -3630,16 +3634,16 @@ }, { "name": "symfony/clock", - "version": "v8.0.0", + "version": "v8.0.8", "source": { "type": "git", "url": "https://github.com/symfony/clock.git", - "reference": "832119f9b8dbc6c8e6f65f30c5969eca1e88764f" + "reference": "b55a638b189a6faa875e0ccdb00908fb87af95b3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/clock/zipball/832119f9b8dbc6c8e6f65f30c5969eca1e88764f", - "reference": "832119f9b8dbc6c8e6f65f30c5969eca1e88764f", + "url": "https://api.github.com/repos/symfony/clock/zipball/b55a638b189a6faa875e0ccdb00908fb87af95b3", + "reference": "b55a638b189a6faa875e0ccdb00908fb87af95b3", "shasum": "" }, "require": { @@ -3683,7 +3687,7 @@ "time" ], "support": { - "source": "https://github.com/symfony/clock/tree/v8.0.0" + "source": "https://github.com/symfony/clock/tree/v8.0.8" }, "funding": [ { @@ -3703,51 +3707,43 @@ "type": "tidelift" } ], - "time": "2025-11-12T15:46:48+00:00" + "time": "2026-03-30T15:14:47+00:00" }, { "name": "symfony/console", - "version": "v7.4.7", + "version": "v8.0.8", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "e1e6770440fb9c9b0cf725f81d1361ad1835329d" + "reference": "5b66d385dc58f69652e56f78a4184615e3f2b7f7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/e1e6770440fb9c9b0cf725f81d1361ad1835329d", - "reference": "e1e6770440fb9c9b0cf725f81d1361ad1835329d", + "url": "https://api.github.com/repos/symfony/console/zipball/5b66d385dc58f69652e56f78a4184615e3f2b7f7", + "reference": "5b66d385dc58f69652e56f78a4184615e3f2b7f7", "shasum": "" }, "require": { - "php": ">=8.2", - "symfony/deprecation-contracts": "^2.5|^3", - "symfony/polyfill-mbstring": "~1.0", + "php": ">=8.4", + "symfony/polyfill-mbstring": "^1.0", "symfony/service-contracts": "^2.5|^3", - "symfony/string": "^7.2|^8.0" - }, - "conflict": { - "symfony/dependency-injection": "<6.4", - "symfony/dotenv": "<6.4", - "symfony/event-dispatcher": "<6.4", - "symfony/lock": "<6.4", - "symfony/process": "<6.4" + "symfony/string": "^7.4|^8.0" }, "provide": { "psr/log-implementation": "1.0|2.0|3.0" }, "require-dev": { "psr/log": "^1|^2|^3", - "symfony/config": "^6.4|^7.0|^8.0", - "symfony/dependency-injection": "^6.4|^7.0|^8.0", - "symfony/event-dispatcher": "^6.4|^7.0|^8.0", - "symfony/http-foundation": "^6.4|^7.0|^8.0", - "symfony/http-kernel": "^6.4|^7.0|^8.0", - "symfony/lock": "^6.4|^7.0|^8.0", - "symfony/messenger": "^6.4|^7.0|^8.0", - "symfony/process": "^6.4|^7.0|^8.0", - "symfony/stopwatch": "^6.4|^7.0|^8.0", - "symfony/var-dumper": "^6.4|^7.0|^8.0" + "symfony/config": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/event-dispatcher": "^7.4|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/lock": "^7.4|^8.0", + "symfony/messenger": "^7.4|^8.0", + "symfony/process": "^7.4|^8.0", + "symfony/stopwatch": "^7.4|^8.0", + "symfony/var-dumper": "^7.4|^8.0" }, "type": "library", "autoload": { @@ -3781,7 +3777,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v7.4.7" + "source": "https://github.com/symfony/console/tree/v8.0.8" }, "funding": [ { @@ -3801,20 +3797,20 @@ "type": "tidelift" } ], - "time": "2026-03-06T14:06:20+00:00" + "time": "2026-03-30T15:14:47+00:00" }, { "name": "symfony/css-selector", - "version": "v8.0.6", + "version": "v8.0.8", "source": { "type": "git", "url": "https://github.com/symfony/css-selector.git", - "reference": "2a178bf80f05dbbe469a337730eba79d61315262" + "reference": "8db1c00226a94d8ab6aa89d9224eeee91e2ea2ed" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/css-selector/zipball/2a178bf80f05dbbe469a337730eba79d61315262", - "reference": "2a178bf80f05dbbe469a337730eba79d61315262", + "url": "https://api.github.com/repos/symfony/css-selector/zipball/8db1c00226a94d8ab6aa89d9224eeee91e2ea2ed", + "reference": "8db1c00226a94d8ab6aa89d9224eeee91e2ea2ed", "shasum": "" }, "require": { @@ -3850,7 +3846,7 @@ "description": "Converts CSS selectors to XPath expressions", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/css-selector/tree/v8.0.6" + "source": "https://github.com/symfony/css-selector/tree/v8.0.8" }, "funding": [ { @@ -3870,7 +3866,7 @@ "type": "tidelift" } ], - "time": "2026-02-17T13:07:04+00:00" + "time": "2026-03-30T15:14:47+00:00" }, { "name": "symfony/deprecation-contracts", @@ -3941,33 +3937,32 @@ }, { "name": "symfony/error-handler", - "version": "v7.4.4", + "version": "v8.0.8", "source": { "type": "git", "url": "https://github.com/symfony/error-handler.git", - "reference": "8da531f364ddfee53e36092a7eebbbd0b775f6b8" + "reference": "c1119fe8dcfc3825ec74ec061b96ef0c8f281517" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/error-handler/zipball/8da531f364ddfee53e36092a7eebbbd0b775f6b8", - "reference": "8da531f364ddfee53e36092a7eebbbd0b775f6b8", + "url": "https://api.github.com/repos/symfony/error-handler/zipball/c1119fe8dcfc3825ec74ec061b96ef0c8f281517", + "reference": "c1119fe8dcfc3825ec74ec061b96ef0c8f281517", "shasum": "" }, "require": { - "php": ">=8.2", + "php": ">=8.4", "psr/log": "^1|^2|^3", "symfony/polyfill-php85": "^1.32", - "symfony/var-dumper": "^6.4|^7.0|^8.0" + "symfony/var-dumper": "^7.4|^8.0" }, "conflict": { - "symfony/deprecation-contracts": "<2.5", - "symfony/http-kernel": "<6.4" + "symfony/deprecation-contracts": "<2.5" }, "require-dev": { - "symfony/console": "^6.4|^7.0|^8.0", + "symfony/console": "^7.4|^8.0", "symfony/deprecation-contracts": "^2.5|^3", - "symfony/http-kernel": "^6.4|^7.0|^8.0", - "symfony/serializer": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/serializer": "^7.4|^8.0", "symfony/webpack-encore-bundle": "^1.0|^2.0" }, "bin": [ @@ -3999,7 +3994,7 @@ "description": "Provides tools to manage errors and ease debugging PHP code", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/error-handler/tree/v7.4.4" + "source": "https://github.com/symfony/error-handler/tree/v8.0.8" }, "funding": [ { @@ -4019,20 +4014,20 @@ "type": "tidelift" } ], - "time": "2026-01-20T16:42:42+00:00" + "time": "2026-03-30T15:14:47+00:00" }, { "name": "symfony/event-dispatcher", - "version": "v8.0.4", + "version": "v8.0.8", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher.git", - "reference": "99301401da182b6cfaa4700dbe9987bb75474b47" + "reference": "f662acc6ab22a3d6d716dcb44c381c6002940df6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/99301401da182b6cfaa4700dbe9987bb75474b47", - "reference": "99301401da182b6cfaa4700dbe9987bb75474b47", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/f662acc6ab22a3d6d716dcb44c381c6002940df6", + "reference": "f662acc6ab22a3d6d716dcb44c381c6002940df6", "shasum": "" }, "require": { @@ -4084,7 +4079,7 @@ "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/event-dispatcher/tree/v8.0.4" + "source": "https://github.com/symfony/event-dispatcher/tree/v8.0.8" }, "funding": [ { @@ -4104,7 +4099,7 @@ "type": "tidelift" } ], - "time": "2026-01-05T11:45:55+00:00" + "time": "2026-03-30T15:14:47+00:00" }, { "name": "symfony/event-dispatcher-contracts", @@ -4184,23 +4179,23 @@ }, { "name": "symfony/finder", - "version": "v7.4.6", + "version": "v8.0.8", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "8655bf1076b7a3a346cb11413ffdabff50c7ffcf" + "reference": "8da41214757b87d97f181e3d14a4179286151007" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/8655bf1076b7a3a346cb11413ffdabff50c7ffcf", - "reference": "8655bf1076b7a3a346cb11413ffdabff50c7ffcf", + "url": "https://api.github.com/repos/symfony/finder/zipball/8da41214757b87d97f181e3d14a4179286151007", + "reference": "8da41214757b87d97f181e3d14a4179286151007", "shasum": "" }, "require": { - "php": ">=8.2" + "php": ">=8.4" }, "require-dev": { - "symfony/filesystem": "^6.4|^7.0|^8.0" + "symfony/filesystem": "^7.4|^8.0" }, "type": "library", "autoload": { @@ -4228,7 +4223,7 @@ "description": "Finds files and directories via an intuitive fluent interface", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/finder/tree/v7.4.6" + "source": "https://github.com/symfony/finder/tree/v8.0.8" }, "funding": [ { @@ -4248,41 +4243,39 @@ "type": "tidelift" } ], - "time": "2026-01-29T09:40:50+00:00" + "time": "2026-03-30T15:14:47+00:00" }, { "name": "symfony/http-foundation", - "version": "v7.4.7", + "version": "v8.0.8", "source": { "type": "git", "url": "https://github.com/symfony/http-foundation.git", - "reference": "f94b3e7b7dafd40e666f0c9ff2084133bae41e81" + "reference": "02656f7ebeae5c155d659e946f6b3a33df24051b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-foundation/zipball/f94b3e7b7dafd40e666f0c9ff2084133bae41e81", - "reference": "f94b3e7b7dafd40e666f0c9ff2084133bae41e81", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/02656f7ebeae5c155d659e946f6b3a33df24051b", + "reference": "02656f7ebeae5c155d659e946f6b3a33df24051b", "shasum": "" }, "require": { - "php": ">=8.2", - "symfony/deprecation-contracts": "^2.5|^3", + "php": ">=8.4", "symfony/polyfill-mbstring": "^1.1" }, "conflict": { - "doctrine/dbal": "<3.6", - "symfony/cache": "<6.4.12|>=7.0,<7.1.5" + "doctrine/dbal": "<4.3" }, "require-dev": { - "doctrine/dbal": "^3.6|^4", + "doctrine/dbal": "^4.3", "predis/predis": "^1.1|^2.0", - "symfony/cache": "^6.4.12|^7.1.5|^8.0", - "symfony/clock": "^6.4|^7.0|^8.0", - "symfony/dependency-injection": "^6.4|^7.0|^8.0", - "symfony/expression-language": "^6.4|^7.0|^8.0", - "symfony/http-kernel": "^6.4|^7.0|^8.0", - "symfony/mime": "^6.4|^7.0|^8.0", - "symfony/rate-limiter": "^6.4|^7.0|^8.0" + "symfony/cache": "^7.4|^8.0", + "symfony/clock": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/expression-language": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/mime": "^7.4|^8.0", + "symfony/rate-limiter": "^7.4|^8.0" }, "type": "library", "autoload": { @@ -4310,7 +4303,7 @@ "description": "Defines an object-oriented layer for the HTTP specification", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-foundation/tree/v7.4.7" + "source": "https://github.com/symfony/http-foundation/tree/v8.0.8" }, "funding": [ { @@ -4330,78 +4323,63 @@ "type": "tidelift" } ], - "time": "2026-03-06T13:15:18+00:00" + "time": "2026-03-30T15:14:47+00:00" }, { "name": "symfony/http-kernel", - "version": "v7.4.7", + "version": "v8.0.8", "source": { "type": "git", "url": "https://github.com/symfony/http-kernel.git", - "reference": "3b3fcf386c809be990c922e10e4c620d6367cab1" + "reference": "1770f6818d83b2fddc12185025b93f39a90cb628" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-kernel/zipball/3b3fcf386c809be990c922e10e4c620d6367cab1", - "reference": "3b3fcf386c809be990c922e10e4c620d6367cab1", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/1770f6818d83b2fddc12185025b93f39a90cb628", + "reference": "1770f6818d83b2fddc12185025b93f39a90cb628", "shasum": "" }, "require": { - "php": ">=8.2", + "php": ">=8.4", "psr/log": "^1|^2|^3", - "symfony/deprecation-contracts": "^2.5|^3", - "symfony/error-handler": "^6.4|^7.0|^8.0", - "symfony/event-dispatcher": "^7.3|^8.0", + "symfony/error-handler": "^7.4|^8.0", + "symfony/event-dispatcher": "^7.4|^8.0", "symfony/http-foundation": "^7.4|^8.0", "symfony/polyfill-ctype": "^1.8" }, "conflict": { - "symfony/browser-kit": "<6.4", - "symfony/cache": "<6.4", - "symfony/config": "<6.4", - "symfony/console": "<6.4", - "symfony/dependency-injection": "<6.4", - "symfony/doctrine-bridge": "<6.4", "symfony/flex": "<2.10", - "symfony/form": "<6.4", - "symfony/http-client": "<6.4", "symfony/http-client-contracts": "<2.5", - "symfony/mailer": "<6.4", - "symfony/messenger": "<6.4", - "symfony/translation": "<6.4", "symfony/translation-contracts": "<2.5", - "symfony/twig-bridge": "<6.4", - "symfony/validator": "<6.4", - "symfony/var-dumper": "<6.4", - "twig/twig": "<3.12" + "twig/twig": "<3.21" }, "provide": { "psr/log-implementation": "1.0|2.0|3.0" }, "require-dev": { "psr/cache": "^1.0|^2.0|^3.0", - "symfony/browser-kit": "^6.4|^7.0|^8.0", - "symfony/clock": "^6.4|^7.0|^8.0", - "symfony/config": "^6.4|^7.0|^8.0", - "symfony/console": "^6.4|^7.0|^8.0", - "symfony/css-selector": "^6.4|^7.0|^8.0", - "symfony/dependency-injection": "^6.4.1|^7.0.1|^8.0", - "symfony/dom-crawler": "^6.4|^7.0|^8.0", - "symfony/expression-language": "^6.4|^7.0|^8.0", - "symfony/finder": "^6.4|^7.0|^8.0", + "symfony/browser-kit": "^7.4|^8.0", + "symfony/clock": "^7.4|^8.0", + "symfony/config": "^7.4|^8.0", + "symfony/console": "^7.4|^8.0", + "symfony/css-selector": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/dom-crawler": "^7.4|^8.0", + "symfony/expression-language": "^7.4|^8.0", + "symfony/finder": "^7.4|^8.0", "symfony/http-client-contracts": "^2.5|^3", - "symfony/process": "^6.4|^7.0|^8.0", - "symfony/property-access": "^7.1|^8.0", - "symfony/routing": "^6.4|^7.0|^8.0", - "symfony/serializer": "^7.1|^8.0", - "symfony/stopwatch": "^6.4|^7.0|^8.0", - "symfony/translation": "^6.4|^7.0|^8.0", + "symfony/process": "^7.4|^8.0", + "symfony/property-access": "^7.4|^8.0", + "symfony/routing": "^7.4|^8.0", + "symfony/serializer": "^7.4|^8.0", + "symfony/stopwatch": "^7.4|^8.0", + "symfony/translation": "^7.4|^8.0", "symfony/translation-contracts": "^2.5|^3", - "symfony/uid": "^6.4|^7.0|^8.0", - "symfony/validator": "^6.4|^7.0|^8.0", - "symfony/var-dumper": "^6.4|^7.0|^8.0", - "symfony/var-exporter": "^6.4|^7.0|^8.0", - "twig/twig": "^3.12" + "symfony/uid": "^7.4|^8.0", + "symfony/validator": "^7.4|^8.0", + "symfony/var-dumper": "^7.4|^8.0", + "symfony/var-exporter": "^7.4|^8.0", + "twig/twig": "^3.21" }, "type": "library", "autoload": { @@ -4429,7 +4407,7 @@ "description": "Provides a structured process for converting a Request into a Response", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-kernel/tree/v7.4.7" + "source": "https://github.com/symfony/http-kernel/tree/v8.0.8" }, "funding": [ { @@ -4449,43 +4427,39 @@ "type": "tidelift" } ], - "time": "2026-03-06T16:33:18+00:00" + "time": "2026-03-31T21:14:05+00:00" }, { "name": "symfony/mailer", - "version": "v7.4.6", + "version": "v8.0.8", "source": { "type": "git", "url": "https://github.com/symfony/mailer.git", - "reference": "b02726f39a20bc65e30364f5c750c4ddbf1f58e9" + "reference": "ca5f6edaf8780ece814404b58a4482b22b509c56" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mailer/zipball/b02726f39a20bc65e30364f5c750c4ddbf1f58e9", - "reference": "b02726f39a20bc65e30364f5c750c4ddbf1f58e9", + "url": "https://api.github.com/repos/symfony/mailer/zipball/ca5f6edaf8780ece814404b58a4482b22b509c56", + "reference": "ca5f6edaf8780ece814404b58a4482b22b509c56", "shasum": "" }, "require": { "egulias/email-validator": "^2.1.10|^3|^4", - "php": ">=8.2", + "php": ">=8.4", "psr/event-dispatcher": "^1", "psr/log": "^1|^2|^3", - "symfony/event-dispatcher": "^6.4|^7.0|^8.0", - "symfony/mime": "^7.2|^8.0", + "symfony/event-dispatcher": "^7.4|^8.0", + "symfony/mime": "^7.4|^8.0", "symfony/service-contracts": "^2.5|^3" }, "conflict": { - "symfony/http-client-contracts": "<2.5", - "symfony/http-kernel": "<6.4", - "symfony/messenger": "<6.4", - "symfony/mime": "<6.4", - "symfony/twig-bridge": "<6.4" + "symfony/http-client-contracts": "<2.5" }, "require-dev": { - "symfony/console": "^6.4|^7.0|^8.0", - "symfony/http-client": "^6.4|^7.0|^8.0", - "symfony/messenger": "^6.4|^7.0|^8.0", - "symfony/twig-bridge": "^6.4|^7.0|^8.0" + "symfony/console": "^7.4|^8.0", + "symfony/http-client": "^7.4|^8.0", + "symfony/messenger": "^7.4|^8.0", + "symfony/twig-bridge": "^7.4|^8.0" }, "type": "library", "autoload": { @@ -4513,7 +4487,7 @@ "description": "Helps sending emails", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/mailer/tree/v7.4.6" + "source": "https://github.com/symfony/mailer/tree/v8.0.8" }, "funding": [ { @@ -4533,44 +4507,41 @@ "type": "tidelift" } ], - "time": "2026-02-25T16:50:00+00:00" + "time": "2026-03-30T15:14:47+00:00" }, { "name": "symfony/mime", - "version": "v7.4.7", + "version": "v8.0.8", "source": { "type": "git", "url": "https://github.com/symfony/mime.git", - "reference": "da5ab4fde3f6c88ab06e96185b9922f48b677cd1" + "reference": "ddff21f14c7ce04b98101b399a9463dce8b0ce66" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mime/zipball/da5ab4fde3f6c88ab06e96185b9922f48b677cd1", - "reference": "da5ab4fde3f6c88ab06e96185b9922f48b677cd1", + "url": "https://api.github.com/repos/symfony/mime/zipball/ddff21f14c7ce04b98101b399a9463dce8b0ce66", + "reference": "ddff21f14c7ce04b98101b399a9463dce8b0ce66", "shasum": "" }, "require": { - "php": ">=8.2", - "symfony/deprecation-contracts": "^2.5|^3", + "php": ">=8.4", "symfony/polyfill-intl-idn": "^1.10", "symfony/polyfill-mbstring": "^1.0" }, "conflict": { "egulias/email-validator": "~3.0.0", "phpdocumentor/reflection-docblock": "<5.2|>=7", - "phpdocumentor/type-resolver": "<1.5.1", - "symfony/mailer": "<6.4", - "symfony/serializer": "<6.4.3|>7.0,<7.0.3" + "phpdocumentor/type-resolver": "<1.5.1" }, "require-dev": { "egulias/email-validator": "^2.1.10|^3.1|^4", "league/html-to-markdown": "^5.0", "phpdocumentor/reflection-docblock": "^5.2|^6.0", - "symfony/dependency-injection": "^6.4|^7.0|^8.0", - "symfony/process": "^6.4|^7.0|^8.0", - "symfony/property-access": "^6.4|^7.0|^8.0", - "symfony/property-info": "^6.4|^7.0|^8.0", - "symfony/serializer": "^6.4.3|^7.0.3|^8.0" + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/process": "^7.4|^8.0", + "symfony/property-access": "^7.4|^8.0", + "symfony/property-info": "^7.4|^8.0", + "symfony/serializer": "^7.4|^8.0" }, "type": "library", "autoload": { @@ -4602,7 +4573,7 @@ "mime-type" ], "support": { - "source": "https://github.com/symfony/mime/tree/v7.4.7" + "source": "https://github.com/symfony/mime/tree/v8.0.8" }, "funding": [ { @@ -4622,20 +4593,20 @@ "type": "tidelift" } ], - "time": "2026-03-05T15:24:09+00:00" + "time": "2026-03-30T15:14:47+00:00" }, { "name": "symfony/polyfill-ctype", - "version": "v1.33.0", + "version": "v1.37.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-ctype.git", - "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638" + "reference": "141046a8f9477948ff284fa65be2095baafb94f2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/a3cc8b044a6ea513310cbd48ef7333b384945638", - "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/141046a8f9477948ff284fa65be2095baafb94f2", + "reference": "141046a8f9477948ff284fa65be2095baafb94f2", "shasum": "" }, "require": { @@ -4685,7 +4656,7 @@ "portable" ], "support": { - "source": "https://github.com/symfony/polyfill-ctype/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.37.0" }, "funding": [ { @@ -4705,20 +4676,20 @@ "type": "tidelift" } ], - "time": "2024-09-09T11:45:10+00:00" + "time": "2026-04-10T16:19:22+00:00" }, { "name": "symfony/polyfill-intl-grapheme", - "version": "v1.33.0", + "version": "v1.37.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-grapheme.git", - "reference": "380872130d3a5dd3ace2f4010d95125fde5d5c70" + "reference": "4864388bfbd3001ce88e234fab652acd91fdc57e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/380872130d3a5dd3ace2f4010d95125fde5d5c70", - "reference": "380872130d3a5dd3ace2f4010d95125fde5d5c70", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/4864388bfbd3001ce88e234fab652acd91fdc57e", + "reference": "4864388bfbd3001ce88e234fab652acd91fdc57e", "shasum": "" }, "require": { @@ -4767,7 +4738,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.37.0" }, "funding": [ { @@ -4787,11 +4758,11 @@ "type": "tidelift" } ], - "time": "2025-06-27T09:58:17+00:00" + "time": "2026-04-26T13:13:48+00:00" }, { "name": "symfony/polyfill-intl-idn", - "version": "v1.33.0", + "version": "v1.37.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-idn.git", @@ -4854,7 +4825,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.37.0" }, "funding": [ { @@ -4878,7 +4849,7 @@ }, { "name": "symfony/polyfill-intl-normalizer", - "version": "v1.33.0", + "version": "v1.37.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-normalizer.git", @@ -4939,7 +4910,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.37.0" }, "funding": [ { @@ -4963,16 +4934,16 @@ }, { "name": "symfony/polyfill-mbstring", - "version": "v1.33.0", + "version": "v1.37.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-mbstring.git", - "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493" + "reference": "6a21eb99c6973357967f6ce3708cd55a6bec6315" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/6d857f4d76bd4b343eac26d6b539585d2bc56493", - "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/6a21eb99c6973357967f6ce3708cd55a6bec6315", + "reference": "6a21eb99c6973357967f6ce3708cd55a6bec6315", "shasum": "" }, "require": { @@ -5024,7 +4995,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.37.0" }, "funding": [ { @@ -5044,20 +5015,20 @@ "type": "tidelift" } ], - "time": "2024-12-23T08:48:59+00:00" + "time": "2026-04-10T17:25:58+00:00" }, { "name": "symfony/polyfill-php80", - "version": "v1.33.0", + "version": "v1.37.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php80.git", - "reference": "0cc9dd0f17f61d8131e7df6b84bd344899fe2608" + "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/0cc9dd0f17f61d8131e7df6b84bd344899fe2608", - "reference": "0cc9dd0f17f61d8131e7df6b84bd344899fe2608", + "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/dfb55726c3a76ea3b6459fcfda1ec2d80a682411", + "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411", "shasum": "" }, "require": { @@ -5108,87 +5079,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php80/tree/v1.33.0" - }, - "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": "2025-01-02T08:10:11+00:00" - }, - { - "name": "symfony/polyfill-php83", - "version": "v1.33.0", - "source": { - "type": "git", - "url": "https://github.com/symfony/polyfill-php83.git", - "reference": "17f6f9a6b1735c0f163024d959f700cfbc5155e5" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/17f6f9a6b1735c0f163024d959f700cfbc5155e5", - "reference": "17f6f9a6b1735c0f163024d959f700cfbc5155e5", - "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.33.0" + "source": "https://github.com/symfony/polyfill-php80/tree/v1.37.0" }, "funding": [ { @@ -5208,20 +5099,20 @@ "type": "tidelift" } ], - "time": "2025-07-08T02:45:35+00:00" + "time": "2026-04-10T16:19:22+00:00" }, { "name": "symfony/polyfill-php84", - "version": "v1.33.0", + "version": "v1.37.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php84.git", - "reference": "d8ced4d875142b6a7426000426b8abc631d6b191" + "reference": "88486db2c389b290bf87ff1de7ebc1e13e42bb06" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/d8ced4d875142b6a7426000426b8abc631d6b191", - "reference": "d8ced4d875142b6a7426000426b8abc631d6b191", + "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/88486db2c389b290bf87ff1de7ebc1e13e42bb06", + "reference": "88486db2c389b290bf87ff1de7ebc1e13e42bb06", "shasum": "" }, "require": { @@ -5268,7 +5159,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php84/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-php84/tree/v1.37.0" }, "funding": [ { @@ -5288,20 +5179,20 @@ "type": "tidelift" } ], - "time": "2025-06-24T13:30:11+00:00" + "time": "2026-04-10T18:47:49+00:00" }, { "name": "symfony/polyfill-php85", - "version": "v1.33.0", + "version": "v1.37.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php85.git", - "reference": "d4e5fcd4ab3d998ab16c0db48e6cbb9a01993f91" + "reference": "fcfa4973a9917cef23f2e38774da74a2b7d115ee" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/d4e5fcd4ab3d998ab16c0db48e6cbb9a01993f91", - "reference": "d4e5fcd4ab3d998ab16c0db48e6cbb9a01993f91", + "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/fcfa4973a9917cef23f2e38774da74a2b7d115ee", + "reference": "fcfa4973a9917cef23f2e38774da74a2b7d115ee", "shasum": "" }, "require": { @@ -5348,7 +5239,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php85/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-php85/tree/v1.37.0" }, "funding": [ { @@ -5368,20 +5259,20 @@ "type": "tidelift" } ], - "time": "2025-06-23T16:12:55+00:00" + "time": "2026-04-26T13:10:57+00:00" }, { "name": "symfony/polyfill-uuid", - "version": "v1.33.0", + "version": "v1.37.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-uuid.git", - "reference": "21533be36c24be3f4b1669c4725c7d1d2bab4ae2" + "reference": "26dfec253c4cf3e51b541b52ddf7e42cb0908e94" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-uuid/zipball/21533be36c24be3f4b1669c4725c7d1d2bab4ae2", - "reference": "21533be36c24be3f4b1669c4725c7d1d2bab4ae2", + "url": "https://api.github.com/repos/symfony/polyfill-uuid/zipball/26dfec253c4cf3e51b541b52ddf7e42cb0908e94", + "reference": "26dfec253c4cf3e51b541b52ddf7e42cb0908e94", "shasum": "" }, "require": { @@ -5431,7 +5322,7 @@ "uuid" ], "support": { - "source": "https://github.com/symfony/polyfill-uuid/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-uuid/tree/v1.37.0" }, "funding": [ { @@ -5451,24 +5342,24 @@ "type": "tidelift" } ], - "time": "2024-09-09T11:45:10+00:00" + "time": "2026-04-10T16:19:22+00:00" }, { "name": "symfony/process", - "version": "v7.4.5", + "version": "v8.0.8", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "608476f4604102976d687c483ac63a79ba18cc97" + "reference": "cb8939aff03470d1a9d1d1b66d08c6fa71b3bbdc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/608476f4604102976d687c483ac63a79ba18cc97", - "reference": "608476f4604102976d687c483ac63a79ba18cc97", + "url": "https://api.github.com/repos/symfony/process/zipball/cb8939aff03470d1a9d1d1b66d08c6fa71b3bbdc", + "reference": "cb8939aff03470d1a9d1d1b66d08c6fa71b3bbdc", "shasum": "" }, "require": { - "php": ">=8.2" + "php": ">=8.4" }, "type": "library", "autoload": { @@ -5496,7 +5387,7 @@ "description": "Executes commands in sub-processes", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/process/tree/v7.4.5" + "source": "https://github.com/symfony/process/tree/v8.0.8" }, "funding": [ { @@ -5516,38 +5407,33 @@ "type": "tidelift" } ], - "time": "2026-01-26T15:07:59+00:00" + "time": "2026-03-30T15:14:47+00:00" }, { "name": "symfony/routing", - "version": "v7.4.6", + "version": "v8.0.8", "source": { "type": "git", "url": "https://github.com/symfony/routing.git", - "reference": "238d749c56b804b31a9bf3e26519d93b65a60938" + "reference": "0de330ec2ea922a7b08ec45615bd51179de7fda4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/routing/zipball/238d749c56b804b31a9bf3e26519d93b65a60938", - "reference": "238d749c56b804b31a9bf3e26519d93b65a60938", + "url": "https://api.github.com/repos/symfony/routing/zipball/0de330ec2ea922a7b08ec45615bd51179de7fda4", + "reference": "0de330ec2ea922a7b08ec45615bd51179de7fda4", "shasum": "" }, "require": { - "php": ">=8.2", + "php": ">=8.4", "symfony/deprecation-contracts": "^2.5|^3" }, - "conflict": { - "symfony/config": "<6.4", - "symfony/dependency-injection": "<6.4", - "symfony/yaml": "<6.4" - }, "require-dev": { "psr/log": "^1|^2|^3", - "symfony/config": "^6.4|^7.0|^8.0", - "symfony/dependency-injection": "^6.4|^7.0|^8.0", - "symfony/expression-language": "^6.4|^7.0|^8.0", - "symfony/http-foundation": "^6.4|^7.0|^8.0", - "symfony/yaml": "^6.4|^7.0|^8.0" + "symfony/config": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/expression-language": "^7.4|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/yaml": "^7.4|^8.0" }, "type": "library", "autoload": { @@ -5581,7 +5467,7 @@ "url" ], "support": { - "source": "https://github.com/symfony/routing/tree/v7.4.6" + "source": "https://github.com/symfony/routing/tree/v8.0.8" }, "funding": [ { @@ -5601,7 +5487,7 @@ "type": "tidelift" } ], - "time": "2026-02-25T16:50:00+00:00" + "time": "2026-03-30T15:14:47+00:00" }, { "name": "symfony/service-contracts", @@ -5692,16 +5578,16 @@ }, { "name": "symfony/string", - "version": "v8.0.6", + "version": "v8.0.8", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "6c9e1108041b5dce21a9a4984b531c4923aa9ec4" + "reference": "ae9488f874d7603f9d2dfbf120203882b645d963" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/6c9e1108041b5dce21a9a4984b531c4923aa9ec4", - "reference": "6c9e1108041b5dce21a9a4984b531c4923aa9ec4", + "url": "https://api.github.com/repos/symfony/string/zipball/ae9488f874d7603f9d2dfbf120203882b645d963", + "reference": "ae9488f874d7603f9d2dfbf120203882b645d963", "shasum": "" }, "require": { @@ -5758,7 +5644,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v8.0.6" + "source": "https://github.com/symfony/string/tree/v8.0.8" }, "funding": [ { @@ -5778,20 +5664,20 @@ "type": "tidelift" } ], - "time": "2026-02-09T10:14:57+00:00" + "time": "2026-03-30T15:14:47+00:00" }, { "name": "symfony/translation", - "version": "v8.0.6", + "version": "v8.0.8", "source": { "type": "git", "url": "https://github.com/symfony/translation.git", - "reference": "13ff19bcf2bea492d3c2fbeaa194dd6f4599ce1b" + "reference": "27c03ae3940de24ba2f71cfdbac824f2aa1fdf2f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/translation/zipball/13ff19bcf2bea492d3c2fbeaa194dd6f4599ce1b", - "reference": "13ff19bcf2bea492d3c2fbeaa194dd6f4599ce1b", + "url": "https://api.github.com/repos/symfony/translation/zipball/27c03ae3940de24ba2f71cfdbac824f2aa1fdf2f", + "reference": "27c03ae3940de24ba2f71cfdbac824f2aa1fdf2f", "shasum": "" }, "require": { @@ -5851,7 +5737,7 @@ "description": "Provides tools to internationalize your application", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/translation/tree/v8.0.6" + "source": "https://github.com/symfony/translation/tree/v8.0.8" }, "funding": [ { @@ -5871,7 +5757,7 @@ "type": "tidelift" } ], - "time": "2026-02-17T13:07:04+00:00" + "time": "2026-03-30T15:14:47+00:00" }, { "name": "symfony/translation-contracts", @@ -5957,24 +5843,24 @@ }, { "name": "symfony/uid", - "version": "v7.4.4", + "version": "v8.0.8", "source": { "type": "git", "url": "https://github.com/symfony/uid.git", - "reference": "7719ce8aba76be93dfe249192f1fbfa52c588e36" + "reference": "f63fa6096a24147283bce4d29327d285326438e0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/uid/zipball/7719ce8aba76be93dfe249192f1fbfa52c588e36", - "reference": "7719ce8aba76be93dfe249192f1fbfa52c588e36", + "url": "https://api.github.com/repos/symfony/uid/zipball/f63fa6096a24147283bce4d29327d285326438e0", + "reference": "f63fa6096a24147283bce4d29327d285326438e0", "shasum": "" }, "require": { - "php": ">=8.2", + "php": ">=8.4", "symfony/polyfill-uuid": "^1.15" }, "require-dev": { - "symfony/console": "^6.4|^7.0|^8.0" + "symfony/console": "^7.4|^8.0" }, "type": "library", "autoload": { @@ -6011,7 +5897,7 @@ "uuid" ], "support": { - "source": "https://github.com/symfony/uid/tree/v7.4.4" + "source": "https://github.com/symfony/uid/tree/v8.0.8" }, "funding": [ { @@ -6031,35 +5917,35 @@ "type": "tidelift" } ], - "time": "2026-01-03T23:30:35+00:00" + "time": "2026-03-30T15:14:47+00:00" }, { "name": "symfony/var-dumper", - "version": "v7.4.6", + "version": "v8.0.8", "source": { "type": "git", "url": "https://github.com/symfony/var-dumper.git", - "reference": "045321c440ac18347b136c63d2e9bf28a2dc0291" + "reference": "cfb7badd53bf4177f6e9416cfbbccc13c0e773a1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-dumper/zipball/045321c440ac18347b136c63d2e9bf28a2dc0291", - "reference": "045321c440ac18347b136c63d2e9bf28a2dc0291", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/cfb7badd53bf4177f6e9416cfbbccc13c0e773a1", + "reference": "cfb7badd53bf4177f6e9416cfbbccc13c0e773a1", "shasum": "" }, "require": { - "php": ">=8.2", - "symfony/deprecation-contracts": "^2.5|^3", - "symfony/polyfill-mbstring": "~1.0" + "php": ">=8.4", + "symfony/polyfill-mbstring": "^1.0" }, "conflict": { - "symfony/console": "<6.4" + "symfony/console": "<7.4", + "symfony/error-handler": "<7.4" }, "require-dev": { - "symfony/console": "^6.4|^7.0|^8.0", - "symfony/http-kernel": "^6.4|^7.0|^8.0", - "symfony/process": "^6.4|^7.0|^8.0", - "symfony/uid": "^6.4|^7.0|^8.0", + "symfony/console": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/process": "^7.4|^8.0", + "symfony/uid": "^7.4|^8.0", "twig/twig": "^3.12" }, "bin": [ @@ -6098,7 +5984,7 @@ "dump" ], "support": { - "source": "https://github.com/symfony/var-dumper/tree/v7.4.6" + "source": "https://github.com/symfony/var-dumper/tree/v8.0.8" }, "funding": [ { @@ -6118,7 +6004,7 @@ "type": "tidelift" } ], - "time": "2026-02-15T10:53:20+00:00" + "time": "2026-03-31T07:15:36+00:00" }, { "name": "tijsverkoyen/css-to-inline-styles", @@ -6261,23 +6147,23 @@ }, { "name": "voku/portable-ascii", - "version": "2.0.3", + "version": "2.1.1", "source": { "type": "git", "url": "https://github.com/voku/portable-ascii.git", - "reference": "b1d923f88091c6bf09699efcd7c8a1b1bfd7351d" + "reference": "8e1051fe39379367aecf014f41744ce7539a856f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/voku/portable-ascii/zipball/b1d923f88091c6bf09699efcd7c8a1b1bfd7351d", - "reference": "b1d923f88091c6bf09699efcd7c8a1b1bfd7351d", + "url": "https://api.github.com/repos/voku/portable-ascii/zipball/8e1051fe39379367aecf014f41744ce7539a856f", + "reference": "8e1051fe39379367aecf014f41744ce7539a856f", "shasum": "" }, "require": { - "php": ">=7.0.0" + "php": ">=7.1.0" }, "require-dev": { - "phpunit/phpunit": "~6.0 || ~7.0 || ~9.0" + "phpunit/phpunit": "~8.5 || ~9.6 || ~10.5 || ~11.5" }, "suggest": { "ext-intl": "Use Intl for transliterator_transliterate() support" @@ -6307,7 +6193,7 @@ ], "support": { "issues": "https://github.com/voku/portable-ascii/issues", - "source": "https://github.com/voku/portable-ascii/tree/2.0.3" + "source": "https://github.com/voku/portable-ascii/tree/2.1.1" }, "funding": [ { @@ -6331,7 +6217,7 @@ "type": "tidelift" } ], - "time": "2024-11-21T01:49:47+00:00" + "time": "2026-04-26T05:33:54+00:00" } ], "packages-dev": [ @@ -6951,16 +6837,16 @@ }, { "name": "laravel/boost", - "version": "v2.3.1", + "version": "v2.4.6", "source": { "type": "git", "url": "https://github.com/laravel/boost.git", - "reference": "ba0a9e6497398b6ce8243f5517b67d6761509150" + "reference": "c9ea6368c66f7c0e6a9b26706b401de900cdb9ac" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/boost/zipball/ba0a9e6497398b6ce8243f5517b67d6761509150", - "reference": "ba0a9e6497398b6ce8243f5517b67d6761509150", + "url": "https://api.github.com/repos/laravel/boost/zipball/c9ea6368c66f7c0e6a9b26706b401de900cdb9ac", + "reference": "c9ea6368c66f7c0e6a9b26706b401de900cdb9ac", "shasum": "" }, "require": { @@ -6969,7 +6855,7 @@ "illuminate/contracts": "^11.45.3|^12.41.1|^13.0", "illuminate/routing": "^11.45.3|^12.41.1|^13.0", "illuminate/support": "^11.45.3|^12.41.1|^13.0", - "laravel/mcp": "^0.5.1|^0.6.0", + "laravel/mcp": "^0.5.1|^0.6.0|^0.7.0", "laravel/prompts": "^0.3.10", "laravel/roster": "^0.5.0", "php": "^8.2" @@ -7013,7 +6899,7 @@ "issues": "https://github.com/laravel/boost/issues", "source": "https://github.com/laravel/boost" }, - "time": "2026-03-12T09:06:47+00:00" + "time": "2026-04-28T11:52:01+00:00" }, { "name": "laravel/mcp", diff --git a/config/cache.php b/config/cache.php index b32aead..a5ae0fa 100644 --- a/config/cache.php +++ b/config/cache.php @@ -114,4 +114,17 @@ 'prefix' => env('CACHE_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-cache-'), + /* + |-------------------------------------------------------------------------- + | Cache Serializable Classes + |-------------------------------------------------------------------------- + | + | This option controls which PHP classes may be unserialized from cache. + | Set to false to disable object unserialization entirely, or provide an + | explicit allow-list of classes your application stores in cache. + | + */ + + 'serializable_classes' => false, + ]; diff --git a/rector.php b/rector.php index 8714b12..7a2b392 100644 --- a/rector.php +++ b/rector.php @@ -11,16 +11,17 @@ __DIR__.'/app', __DIR__.'/bootstrap', __DIR__.'/config', - __DIR__.'/public', - __DIR__.'/resources', __DIR__.'/routes', __DIR__.'/tests', ]) + ->withSkip([ + __DIR__.'/bootstrap/cache', + ]) ->withPhpSets() ->withSets([ - LaravelLevelSetList::UP_TO_LARAVEL_120, + LaravelLevelSetList::UP_TO_LARAVEL_130, ]) -// ->withSetProviders(LaravelSetProvider::class) + ->withSetProviders(LaravelSetProvider::class) ->withComposerBased(laravel: true) ->withTypeCoverageLevel(0) ->withDeadCodeLevel(0) From 92322525481246e747ad5ce3a7b0e51db342b7f7 Mon Sep 17 00:00:00 2001 From: Giulio Troccoli-Allard Date: Tue, 28 Apr 2026 17:52:02 +0100 Subject: [PATCH 36/63] chore: update Flux and Livewire --- composer.lock | 346 ++++++++++++++++++++++++-------------------------- 1 file changed, 167 insertions(+), 179 deletions(-) diff --git a/composer.lock b/composer.lock index 7e7e8e0..35c77d0 100644 --- a/composer.lock +++ b/composer.lock @@ -2026,16 +2026,16 @@ }, { "name": "livewire/flux", - "version": "v2.13.0", + "version": "v2.14.1", "source": { "type": "git", "url": "https://github.com/livewire/flux.git", - "reference": "741be2d4526e90b97c7a59e079a2f27ecdce2461" + "reference": "44f240efd186b5629d5e4c4a4cc7bbba0111fb48" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/livewire/flux/zipball/741be2d4526e90b97c7a59e079a2f27ecdce2461", - "reference": "741be2d4526e90b97c7a59e079a2f27ecdce2461", + "url": "https://api.github.com/repos/livewire/flux/zipball/44f240efd186b5629d5e4c4a4cc7bbba0111fb48", + "reference": "44f240efd186b5629d5e4c4a4cc7bbba0111fb48", "shasum": "" }, "require": { @@ -2086,25 +2086,25 @@ ], "support": { "issues": "https://github.com/livewire/flux/issues", - "source": "https://github.com/livewire/flux/tree/v2.13.0" + "source": "https://github.com/livewire/flux/tree/v2.14.1" }, - "time": "2026-03-03T03:32:35+00:00" + "time": "2026-04-23T23:00:08+00:00" }, { "name": "livewire/flux-pro", - "version": "2.13.0", + "version": "2.14.1", "dist": { "type": "zip", - "url": "https://composer.fluxui.dev/download/a1357bcd-0c9f-48ec-a532-2aced09459e7/flux-pro-2.13.0.zip", - "reference": "3d91059d053ac94a2f522944f581c3876963656c", - "shasum": "dc3d4e5baedc6e525f41f25474885413e5c1e13e" + "url": "https://composer.fluxui.dev/download/a19daa53-2ce1-4180-8867-7bf8cf84639a/flux-pro-2.14.1.zip", + "reference": "61404a28ab2271b5381a784c9c6727a4a38124de", + "shasum": "eb709ab3bb9493ecc4bad3c084dd951fe3247ce3" }, "require": { "illuminate/console": "^10.0|^11.0|^12.0|^13.0", "illuminate/support": "^10.0|^11.0|^12.0|^13.0", "illuminate/view": "^10.0|^11.0|^12.0|^13.0", "laravel/prompts": "^0.1.24|^0.2|^0.3", - "livewire/flux": "2.13.0|dev-main", + "livewire/flux": "2.14.1|dev-main", "livewire/livewire": "^3.7.4|^4.0", "php": "^8.1", "symfony/console": "^6.0|^7.0|^8.0" @@ -2161,20 +2161,20 @@ "livewire", "ui" ], - "time": "2026-03-03T04:07:04+00:00" + "time": "2026-04-23T23:03:15+00:00" }, { "name": "livewire/livewire", - "version": "v4.2.1", + "version": "v4.2.4", "source": { "type": "git", "url": "https://github.com/livewire/livewire.git", - "reference": "93e972fa42c1b34fff1550093ab94f778d81ea5a" + "reference": "7d0bfa46269b1ec186b8cdd38baffee5cc647d10" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/livewire/livewire/zipball/93e972fa42c1b34fff1550093ab94f778d81ea5a", - "reference": "93e972fa42c1b34fff1550093ab94f778d81ea5a", + "url": "https://api.github.com/repos/livewire/livewire/zipball/7d0bfa46269b1ec186b8cdd38baffee5cc647d10", + "reference": "7d0bfa46269b1ec186b8cdd38baffee5cc647d10", "shasum": "" }, "require": { @@ -2229,7 +2229,7 @@ "description": "A front-end framework for Laravel.", "support": { "issues": "https://github.com/livewire/livewire/issues", - "source": "https://github.com/livewire/livewire/tree/v4.2.1" + "source": "https://github.com/livewire/livewire/tree/v4.2.4" }, "funding": [ { @@ -2237,7 +2237,7 @@ "type": "github" } ], - "time": "2026-02-28T00:01:19+00:00" + "time": "2026-04-02T20:48:35+00:00" }, { "name": "monolog/monolog", @@ -6223,16 +6223,16 @@ "packages-dev": [ { "name": "brianium/paratest", - "version": "v7.19.0", + "version": "v7.20.0", "source": { "type": "git", "url": "https://github.com/paratestphp/paratest.git", - "reference": "7c6c29af7c4b406b49ce0c6b0a3a81d3684474e6" + "reference": "81c80677c9ec0ed4ef16b246167f11dec81a6e3d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/paratestphp/paratest/zipball/7c6c29af7c4b406b49ce0c6b0a3a81d3684474e6", - "reference": "7c6c29af7c4b406b49ce0c6b0a3a81d3684474e6", + "url": "https://api.github.com/repos/paratestphp/paratest/zipball/81c80677c9ec0ed4ef16b246167f11dec81a6e3d", + "reference": "81c80677c9ec0ed4ef16b246167f11dec81a6e3d", "shasum": "" }, "require": { @@ -6246,9 +6246,9 @@ "phpunit/php-code-coverage": "^12.5.3 || ^13.0.1", "phpunit/php-file-iterator": "^6.0.1 || ^7", "phpunit/php-timer": "^8 || ^9", - "phpunit/phpunit": "^12.5.9 || ^13", + "phpunit/phpunit": "^12.5.14 || ^13.0.5", "sebastian/environment": "^8.0.3 || ^9", - "symfony/console": "^7.4.4 || ^8.0.4", + "symfony/console": "^7.4.7 || ^8.0.7", "symfony/process": "^7.4.5 || ^8.0.5" }, "require-dev": { @@ -6256,11 +6256,11 @@ "ext-pcntl": "*", "ext-pcov": "*", "ext-posix": "*", - "phpstan/phpstan": "^2.1.38", - "phpstan/phpstan-deprecation-rules": "^2.0.3", - "phpstan/phpstan-phpunit": "^2.0.12", - "phpstan/phpstan-strict-rules": "^2.0.8", - "symfony/filesystem": "^7.4.0 || ^8.0.1" + "phpstan/phpstan": "^2.1.44", + "phpstan/phpstan-deprecation-rules": "^2.0.4", + "phpstan/phpstan-phpunit": "^2.0.16", + "phpstan/phpstan-strict-rules": "^2.0.10", + "symfony/filesystem": "^7.4.6 || ^8.0.6" }, "bin": [ "bin/paratest", @@ -6300,7 +6300,7 @@ ], "support": { "issues": "https://github.com/paratestphp/paratest/issues", - "source": "https://github.com/paratestphp/paratest/tree/v7.19.0" + "source": "https://github.com/paratestphp/paratest/tree/v7.20.0" }, "funding": [ { @@ -6312,7 +6312,7 @@ "type": "paypal" } ], - "time": "2026-02-06T10:53:26+00:00" + "time": "2026-03-29T15:46:14+00:00" }, { "name": "doctrine/deprecations", @@ -6364,22 +6364,22 @@ }, { "name": "driftingly/rector-laravel", - "version": "2.1.12", + "version": "2.3.0", "source": { "type": "git", "url": "https://github.com/driftingly/rector-laravel.git", - "reference": "2a2175eefabca6d15c247d55de17c75dc2f787a9" + "reference": "3c1c13f335b3b4d1a1f944a8ea194020044871ed" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/driftingly/rector-laravel/zipball/2a2175eefabca6d15c247d55de17c75dc2f787a9", - "reference": "2a2175eefabca6d15c247d55de17c75dc2f787a9", + "url": "https://api.github.com/repos/driftingly/rector-laravel/zipball/3c1c13f335b3b4d1a1f944a8ea194020044871ed", + "reference": "3c1c13f335b3b4d1a1f944a8ea194020044871ed", "shasum": "" }, "require": { "php": "^7.4 || ^8.0", "rector/rector": "^2.2.7", - "webmozart/assert": "^1.11" + "webmozart/assert": "^1.11 || ^2.0" }, "type": "rector-extension", "autoload": { @@ -6394,9 +6394,9 @@ "description": "Rector upgrades rules for Laravel Framework", "support": { "issues": "https://github.com/driftingly/rector-laravel/issues", - "source": "https://github.com/driftingly/rector-laravel/tree/2.1.12" + "source": "https://github.com/driftingly/rector-laravel/tree/2.3.0" }, - "time": "2026-03-06T19:59:21+00:00" + "time": "2026-04-08T10:52:44+00:00" }, { "name": "fakerphp/faker", @@ -6747,16 +6747,16 @@ }, { "name": "larastan/larastan", - "version": "v3.9.3", + "version": "v3.9.6", "source": { "type": "git", "url": "https://github.com/larastan/larastan.git", - "reference": "64a52bcc5347c89fdf131cb59f96ebfbc8d1ad65" + "reference": "9ad17e83e96b63536cb6ac39c3d40d29ff9cf636" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/larastan/larastan/zipball/64a52bcc5347c89fdf131cb59f96ebfbc8d1ad65", - "reference": "64a52bcc5347c89fdf131cb59f96ebfbc8d1ad65", + "url": "https://api.github.com/repos/larastan/larastan/zipball/9ad17e83e96b63536cb6ac39c3d40d29ff9cf636", + "reference": "9ad17e83e96b63536cb6ac39c3d40d29ff9cf636", "shasum": "" }, "require": { @@ -6770,7 +6770,7 @@ "illuminate/pipeline": "^11.44.2 || ^12.4.1 || ^13", "illuminate/support": "^11.44.2 || ^12.4.1 || ^13", "php": "^8.2", - "phpstan/phpstan": "^2.1.32" + "phpstan/phpstan": "^2.1.44" }, "require-dev": { "doctrine/coding-standard": "^13", @@ -6825,7 +6825,7 @@ ], "support": { "issues": "https://github.com/larastan/larastan/issues", - "source": "https://github.com/larastan/larastan/tree/v3.9.3" + "source": "https://github.com/larastan/larastan/tree/v3.9.6" }, "funding": [ { @@ -6833,7 +6833,7 @@ "type": "github" } ], - "time": "2026-02-20T12:07:12+00:00" + "time": "2026-04-16T10:02:43+00:00" }, { "name": "laravel/boost", @@ -6903,16 +6903,16 @@ }, { "name": "laravel/mcp", - "version": "v0.6.2", + "version": "v0.7.0", "source": { "type": "git", "url": "https://github.com/laravel/mcp.git", - "reference": "f696e44735b95ff275392eab8ce5a3b4b42a2223" + "reference": "3513b4feca5f1678be4d2261dcfa8e456436d02a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/mcp/zipball/f696e44735b95ff275392eab8ce5a3b4b42a2223", - "reference": "f696e44735b95ff275392eab8ce5a3b4b42a2223", + "url": "https://api.github.com/repos/laravel/mcp/zipball/3513b4feca5f1678be4d2261dcfa8e456436d02a", + "reference": "3513b4feca5f1678be4d2261dcfa8e456436d02a", "shasum": "" }, "require": { @@ -6972,7 +6972,7 @@ "issues": "https://github.com/laravel/mcp/issues", "source": "https://github.com/laravel/mcp" }, - "time": "2026-03-10T20:00:23+00:00" + "time": "2026-04-21T10:23:03+00:00" }, { "name": "laravel/pail", @@ -7056,16 +7056,16 @@ }, { "name": "laravel/pint", - "version": "v1.29.0", + "version": "v1.29.1", "source": { "type": "git", "url": "https://github.com/laravel/pint.git", - "reference": "bdec963f53172c5e36330f3a400604c69bf02d39" + "reference": "0770e9b7fafd50d4586881d456d6eb41c9247a80" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/pint/zipball/bdec963f53172c5e36330f3a400604c69bf02d39", - "reference": "bdec963f53172c5e36330f3a400604c69bf02d39", + "url": "https://api.github.com/repos/laravel/pint/zipball/0770e9b7fafd50d4586881d456d6eb41c9247a80", + "reference": "0770e9b7fafd50d4586881d456d6eb41c9247a80", "shasum": "" }, "require": { @@ -7076,14 +7076,14 @@ "php": "^8.2.0" }, "require-dev": { - "friendsofphp/php-cs-fixer": "^3.94.2", - "illuminate/view": "^12.54.1", - "larastan/larastan": "^3.9.3", - "laravel-zero/framework": "^12.0.5", + "friendsofphp/php-cs-fixer": "^3.95.1", + "illuminate/view": "^12.56.0", + "larastan/larastan": "^3.9.6", + "laravel-zero/framework": "^12.1.0", "mockery/mockery": "^1.6.12", "nunomaduro/termwind": "^2.4.0", "pestphp/pest": "^3.8.6", - "shipfastlabs/agent-detector": "^1.1.0" + "shipfastlabs/agent-detector": "^1.1.3" }, "bin": [ "builds/pint" @@ -7120,7 +7120,7 @@ "issues": "https://github.com/laravel/pint/issues", "source": "https://github.com/laravel/pint" }, - "time": "2026-03-12T15:51:39+00:00" + "time": "2026-04-20T15:26:14+00:00" }, { "name": "laravel/roster", @@ -7185,16 +7185,16 @@ }, { "name": "laravel/sail", - "version": "v1.53.0", + "version": "v1.57.0", "source": { "type": "git", "url": "https://github.com/laravel/sail.git", - "reference": "e340eaa2bea9b99192570c48ed837155dbf24fbb" + "reference": "fa8d057b6e9310380ccbc3a209ed7f927d54f648" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/sail/zipball/e340eaa2bea9b99192570c48ed837155dbf24fbb", - "reference": "e340eaa2bea9b99192570c48ed837155dbf24fbb", + "url": "https://api.github.com/repos/laravel/sail/zipball/fa8d057b6e9310380ccbc3a209ed7f927d54f648", + "reference": "fa8d057b6e9310380ccbc3a209ed7f927d54f648", "shasum": "" }, "require": { @@ -7244,7 +7244,7 @@ "issues": "https://github.com/laravel/sail/issues", "source": "https://github.com/laravel/sail" }, - "time": "2026-02-06T12:16:02+00:00" + "time": "2026-04-14T13:32:04+00:00" }, { "name": "mockery/mockery", @@ -7391,23 +7391,23 @@ }, { "name": "nunomaduro/collision", - "version": "v8.9.1", + "version": "v8.9.4", "source": { "type": "git", "url": "https://github.com/nunomaduro/collision.git", - "reference": "a1ed3fa530fd60bc515f9303e8520fcb7d4bd935" + "reference": "716af8f95a470e9094cfca09ed897b023be191a5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nunomaduro/collision/zipball/a1ed3fa530fd60bc515f9303e8520fcb7d4bd935", - "reference": "a1ed3fa530fd60bc515f9303e8520fcb7d4bd935", + "url": "https://api.github.com/repos/nunomaduro/collision/zipball/716af8f95a470e9094cfca09ed897b023be191a5", + "reference": "716af8f95a470e9094cfca09ed897b023be191a5", "shasum": "" }, "require": { "filp/whoops": "^2.18.4", "nunomaduro/termwind": "^2.4.0", "php": "^8.2.0", - "symfony/console": "^7.4.4 || ^8.0.4" + "symfony/console": "^7.4.8 || ^8.0.8" }, "conflict": { "laravel/framework": "<11.48.0 || >=14.0.0", @@ -7415,12 +7415,12 @@ }, "require-dev": { "brianium/paratest": "^7.8.5", - "larastan/larastan": "^3.9.2", - "laravel/framework": "^11.48.0 || ^12.52.0", - "laravel/pint": "^1.27.1", - "orchestra/testbench-core": "^9.12.0 || ^10.9.0", - "pestphp/pest": "^3.8.5 || ^4.4.1 || ^5.0.0", - "sebastian/environment": "^7.2.1 || ^8.0.3 || ^9.0.0" + "larastan/larastan": "^3.9.6", + "laravel/framework": "^11.48.0 || ^12.56.0 || ^13.5.0", + "laravel/pint": "^1.29.1", + "orchestra/testbench-core": "^9.12.0 || ^10.12.1 || ^11.2.1", + "pestphp/pest": "^3.8.5 || ^4.4.3 || ^5.0.0", + "sebastian/environment": "^7.2.1 || ^8.0.4 || ^9.3.0" }, "type": "library", "extra": { @@ -7483,45 +7483,46 @@ "type": "patreon" } ], - "time": "2026-02-17T17:33:08+00:00" + "time": "2026-04-21T14:04:20+00:00" }, { "name": "pestphp/pest", - "version": "v4.4.2", + "version": "v4.6.3", "source": { "type": "git", "url": "https://github.com/pestphp/pest.git", - "reference": "5d42e8fe3ae1d9fdf7c9f73ee88138fd30265701" + "reference": "bff44562a99d30aa37573995566051b0344f9f8e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/pestphp/pest/zipball/5d42e8fe3ae1d9fdf7c9f73ee88138fd30265701", - "reference": "5d42e8fe3ae1d9fdf7c9f73ee88138fd30265701", + "url": "https://api.github.com/repos/pestphp/pest/zipball/bff44562a99d30aa37573995566051b0344f9f8e", + "reference": "bff44562a99d30aa37573995566051b0344f9f8e", "shasum": "" }, "require": { - "brianium/paratest": "^7.19.0", - "nunomaduro/collision": "^8.9.1", + "brianium/paratest": "^7.20.0", + "nunomaduro/collision": "^8.9.3", "nunomaduro/termwind": "^2.4.0", "pestphp/pest-plugin": "^4.0.0", - "pestphp/pest-plugin-arch": "^4.0.0", + "pestphp/pest-plugin-arch": "^4.0.2", "pestphp/pest-plugin-mutate": "^4.0.1", "pestphp/pest-plugin-profanity": "^4.2.1", "php": "^8.3.0", - "phpunit/phpunit": "^12.5.12", - "symfony/process": "^7.4.5|^8.0.5" + "phpunit/phpunit": "^12.5.23", + "symfony/process": "^7.4.8|^8.0.8" }, "conflict": { "filp/whoops": "<2.18.3", - "phpunit/phpunit": ">12.5.12", + "phpunit/phpunit": ">12.5.23", "sebastian/exporter": "<7.0.0", "webmozart/assert": "<1.11.0" }, "require-dev": { + "mrpunyapal/peststan": "^0.2.5", "pestphp/pest-dev-tools": "^4.1.0", - "pestphp/pest-plugin-browser": "^4.3.0", - "pestphp/pest-plugin-type-coverage": "^4.0.3", - "psy/psysh": "^0.12.21" + "pestphp/pest-plugin-browser": "^4.3.1", + "pestphp/pest-plugin-type-coverage": "^4.0.4", + "psy/psysh": "^0.12.22" }, "bin": [ "bin/pest" @@ -7587,7 +7588,7 @@ ], "support": { "issues": "https://github.com/pestphp/pest/issues", - "source": "https://github.com/pestphp/pest/tree/v4.4.2" + "source": "https://github.com/pestphp/pest/tree/v4.6.3" }, "funding": [ { @@ -7599,7 +7600,7 @@ "type": "github" } ], - "time": "2026-03-10T21:09:12+00:00" + "time": "2026-04-18T13:51:25+00:00" }, { "name": "pestphp/pest-plugin", @@ -7673,26 +7674,26 @@ }, { "name": "pestphp/pest-plugin-arch", - "version": "v4.0.0", + "version": "v4.0.2", "source": { "type": "git", "url": "https://github.com/pestphp/pest-plugin-arch.git", - "reference": "25bb17e37920ccc35cbbcda3b00d596aadf3e58d" + "reference": "3fb0d02a91b9da504b139dc7ab2a31efb7c3215c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/pestphp/pest-plugin-arch/zipball/25bb17e37920ccc35cbbcda3b00d596aadf3e58d", - "reference": "25bb17e37920ccc35cbbcda3b00d596aadf3e58d", + "url": "https://api.github.com/repos/pestphp/pest-plugin-arch/zipball/3fb0d02a91b9da504b139dc7ab2a31efb7c3215c", + "reference": "3fb0d02a91b9da504b139dc7ab2a31efb7c3215c", "shasum": "" }, "require": { "pestphp/pest-plugin": "^4.0.0", "php": "^8.3", - "ta-tikoma/phpunit-architecture-test": "^0.8.5" + "ta-tikoma/phpunit-architecture-test": "^0.8.7" }, "require-dev": { - "pestphp/pest": "^4.0.0", - "pestphp/pest-dev-tools": "^4.0.0" + "pestphp/pest": "^4.4.6", + "pestphp/pest-dev-tools": "^4.1.0" }, "type": "library", "extra": { @@ -7727,7 +7728,7 @@ "unit" ], "support": { - "source": "https://github.com/pestphp/pest-plugin-arch/tree/v4.0.0" + "source": "https://github.com/pestphp/pest-plugin-arch/tree/v4.0.2" }, "funding": [ { @@ -7739,7 +7740,7 @@ "type": "github" } ], - "time": "2025-08-20T13:10:51+00:00" + "time": "2026-04-10T17:20:19+00:00" }, { "name": "pestphp/pest-plugin-laravel", @@ -8120,16 +8121,16 @@ }, { "name": "phpdocumentor/reflection-docblock", - "version": "6.0.2", + "version": "6.0.3", "source": { "type": "git", "url": "https://github.com/phpDocumentor/ReflectionDocBlock.git", - "reference": "897b5986ece6b4f9d8413fea345c7d49c757d6bf" + "reference": "7bae67520aa9f5ecc506d646810bd40d9da54582" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/897b5986ece6b4f9d8413fea345c7d49c757d6bf", - "reference": "897b5986ece6b4f9d8413fea345c7d49c757d6bf", + "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/7bae67520aa9f5ecc506d646810bd40d9da54582", + "reference": "7bae67520aa9f5ecc506d646810bd40d9da54582", "shasum": "" }, "require": { @@ -8179,9 +8180,9 @@ "description": "With this component, a library can provide support for annotations via DocBlocks or otherwise retrieve information that is embedded in a DocBlock.", "support": { "issues": "https://github.com/phpDocumentor/ReflectionDocBlock/issues", - "source": "https://github.com/phpDocumentor/ReflectionDocBlock/tree/6.0.2" + "source": "https://github.com/phpDocumentor/ReflectionDocBlock/tree/6.0.3" }, - "time": "2026-03-01T18:43:49+00:00" + "time": "2026-03-18T20:49:53+00:00" }, { "name": "phpdocumentor/type-resolver", @@ -8338,11 +8339,11 @@ }, { "name": "phpstan/phpstan", - "version": "2.1.40", + "version": "2.1.53", "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/9b2c7aeb83a75d8680ea5e7c9b7fca88052b766b", - "reference": "9b2c7aeb83a75d8680ea5e7c9b7fca88052b766b", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/ef67586798c003274797b288a68b221e4270dca7", + "reference": "ef67586798c003274797b288a68b221e4270dca7", "shasum": "" }, "require": { @@ -8387,20 +8388,20 @@ "type": "github" } ], - "time": "2026-02-23T15:04:35+00:00" + "time": "2026-04-28T16:09:00+00:00" }, { "name": "phpunit/php-code-coverage", - "version": "12.5.3", + "version": "12.5.6", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-code-coverage.git", - "reference": "b015312f28dd75b75d3422ca37dff2cd1a565e8d" + "reference": "876099a072646c7745f673d7aeab5382c4439691" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/b015312f28dd75b75d3422ca37dff2cd1a565e8d", - "reference": "b015312f28dd75b75d3422ca37dff2cd1a565e8d", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/876099a072646c7745f673d7aeab5382c4439691", + "reference": "876099a072646c7745f673d7aeab5382c4439691", "shasum": "" }, "require": { @@ -8409,7 +8410,6 @@ "ext-xmlwriter": "*", "nikic/php-parser": "^5.7.0", "php": ">=8.3", - "phpunit/php-file-iterator": "^6.0", "phpunit/php-text-template": "^5.0", "sebastian/complexity": "^5.0", "sebastian/environment": "^8.0.3", @@ -8456,7 +8456,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", - "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/12.5.3" + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/12.5.6" }, "funding": [ { @@ -8476,7 +8476,7 @@ "type": "tidelift" } ], - "time": "2026-02-06T06:01:44+00:00" + "time": "2026-04-15T08:23:17+00:00" }, { "name": "phpunit/php-file-iterator", @@ -8737,16 +8737,16 @@ }, { "name": "phpunit/phpunit", - "version": "12.5.12", + "version": "12.5.23", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "418e06b3b46b0d54bad749ff4907fc7dfb530199" + "reference": "c54fcf3d6bcb6e96ac2f7e40097dc37b5f139969" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/418e06b3b46b0d54bad749ff4907fc7dfb530199", - "reference": "418e06b3b46b0d54bad749ff4907fc7dfb530199", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/c54fcf3d6bcb6e96ac2f7e40097dc37b5f139969", + "reference": "c54fcf3d6bcb6e96ac2f7e40097dc37b5f139969", "shasum": "" }, "require": { @@ -8760,15 +8760,15 @@ "phar-io/manifest": "^2.0.4", "phar-io/version": "^3.2.1", "php": ">=8.3", - "phpunit/php-code-coverage": "^12.5.3", + "phpunit/php-code-coverage": "^12.5.6", "phpunit/php-file-iterator": "^6.0.1", "phpunit/php-invoker": "^6.0.0", "phpunit/php-text-template": "^5.0.0", "phpunit/php-timer": "^8.0.0", "sebastian/cli-parser": "^4.2.0", - "sebastian/comparator": "^7.1.4", + "sebastian/comparator": "^7.1.6", "sebastian/diff": "^7.0.0", - "sebastian/environment": "^8.0.3", + "sebastian/environment": "^8.1.0", "sebastian/exporter": "^7.0.2", "sebastian/global-state": "^8.0.2", "sebastian/object-enumerator": "^7.0.0", @@ -8815,49 +8815,33 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.12" + "source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.23" }, "funding": [ { - "url": "https://phpunit.de/sponsors.html", - "type": "custom" - }, - { - "url": "https://github.com/sebastianbergmann", - "type": "github" - }, - { - "url": "https://liberapay.com/sebastianbergmann", - "type": "liberapay" - }, - { - "url": "https://thanks.dev/u/gh/sebastianbergmann", - "type": "thanks_dev" - }, - { - "url": "https://tidelift.com/funding/github/packagist/phpunit/phpunit", - "type": "tidelift" + "url": "https://phpunit.de/sponsoring.html", + "type": "other" } ], - "time": "2026-02-16T08:34:36+00:00" + "time": "2026-04-18T06:12:49+00:00" }, { "name": "rector/rector", - "version": "2.3.8", + "version": "2.4.2", "source": { "type": "git", "url": "https://github.com/rectorphp/rector.git", - "reference": "bbd37aedd8df749916cffa2a947cfc4714d1ba2c" + "reference": "e645b6463c6a88ea5b44b17d3387d35a912c7946" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/rectorphp/rector/zipball/bbd37aedd8df749916cffa2a947cfc4714d1ba2c", - "reference": "bbd37aedd8df749916cffa2a947cfc4714d1ba2c", + "url": "https://api.github.com/repos/rectorphp/rector/zipball/e645b6463c6a88ea5b44b17d3387d35a912c7946", + "reference": "e645b6463c6a88ea5b44b17d3387d35a912c7946", "shasum": "" }, "require": { "php": "^7.4|^8.0", - "phpstan/phpstan": "^2.1.38" + "phpstan/phpstan": "^2.1.48" }, "conflict": { "rector/rector-doctrine": "*", @@ -8891,7 +8875,7 @@ ], "support": { "issues": "https://github.com/rectorphp/rector/issues", - "source": "https://github.com/rectorphp/rector/tree/2.3.8" + "source": "https://github.com/rectorphp/rector/tree/2.4.2" }, "funding": [ { @@ -8899,7 +8883,7 @@ "type": "github" } ], - "time": "2026-02-22T09:45:50+00:00" + "time": "2026-04-16T13:07:34+00:00" }, { "name": "sebastian/cli-parser", @@ -8972,16 +8956,16 @@ }, { "name": "sebastian/comparator", - "version": "7.1.4", + "version": "7.1.6", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/comparator.git", - "reference": "6a7de5df2e094f9a80b40a522391a7e6022df5f6" + "reference": "c769009dee98f494e0edc3fd4f4087501688f11e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/6a7de5df2e094f9a80b40a522391a7e6022df5f6", - "reference": "6a7de5df2e094f9a80b40a522391a7e6022df5f6", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/c769009dee98f494e0edc3fd4f4087501688f11e", + "reference": "c769009dee98f494e0edc3fd4f4087501688f11e", "shasum": "" }, "require": { @@ -9040,7 +9024,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/comparator/issues", "security": "https://github.com/sebastianbergmann/comparator/security/policy", - "source": "https://github.com/sebastianbergmann/comparator/tree/7.1.4" + "source": "https://github.com/sebastianbergmann/comparator/tree/7.1.6" }, "funding": [ { @@ -9060,7 +9044,7 @@ "type": "tidelift" } ], - "time": "2026-01-24T09:28:48+00:00" + "time": "2026-04-14T08:23:15+00:00" }, { "name": "sebastian/complexity", @@ -9189,16 +9173,16 @@ }, { "name": "sebastian/environment", - "version": "8.0.4", + "version": "8.1.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/environment.git", - "reference": "7b8842c2d8e85d0c3a5831236bf5869af6ab2a11" + "reference": "b121608b28a13f721e76ffbbd386d08eff58f3f6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/7b8842c2d8e85d0c3a5831236bf5869af6ab2a11", - "reference": "7b8842c2d8e85d0c3a5831236bf5869af6ab2a11", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/b121608b28a13f721e76ffbbd386d08eff58f3f6", + "reference": "b121608b28a13f721e76ffbbd386d08eff58f3f6", "shasum": "" }, "require": { @@ -9213,7 +9197,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "8.0-dev" + "dev-main": "8.1-dev" } }, "autoload": { @@ -9241,7 +9225,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/environment/issues", "security": "https://github.com/sebastianbergmann/environment/security/policy", - "source": "https://github.com/sebastianbergmann/environment/tree/8.0.4" + "source": "https://github.com/sebastianbergmann/environment/tree/8.1.0" }, "funding": [ { @@ -9261,7 +9245,7 @@ "type": "tidelift" } ], - "time": "2026-03-15T07:05:40+00:00" + "time": "2026-04-15T12:13:01+00:00" }, { "name": "sebastian/exporter", @@ -9852,16 +9836,16 @@ }, { "name": "symfony/yaml", - "version": "v8.0.6", + "version": "v8.0.8", "source": { "type": "git", "url": "https://github.com/symfony/yaml.git", - "reference": "5f006c50a981e1630bbb70ad409c5d85f9a716e0" + "reference": "54174ab48c0c0f9e21512b304be17f8150ccf8f1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/yaml/zipball/5f006c50a981e1630bbb70ad409c5d85f9a716e0", - "reference": "5f006c50a981e1630bbb70ad409c5d85f9a716e0", + "url": "https://api.github.com/repos/symfony/yaml/zipball/54174ab48c0c0f9e21512b304be17f8150ccf8f1", + "reference": "54174ab48c0c0f9e21512b304be17f8150ccf8f1", "shasum": "" }, "require": { @@ -9903,7 +9887,7 @@ "description": "Loads and dumps YAML files", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/yaml/tree/v8.0.6" + "source": "https://github.com/symfony/yaml/tree/v8.0.8" }, "funding": [ { @@ -9923,7 +9907,7 @@ "type": "tidelift" } ], - "time": "2026-02-09T10:14:57+00:00" + "time": "2026-03-30T15:14:47+00:00" }, { "name": "ta-tikoma/phpunit-architecture-test", @@ -10093,23 +10077,23 @@ }, { "name": "webmozart/assert", - "version": "1.12.1", + "version": "2.3.0", "source": { "type": "git", "url": "https://github.com/webmozarts/assert.git", - "reference": "9be6926d8b485f55b9229203f962b51ed377ba68" + "reference": "eb0d790f735ba6cff25c683a85a1da0eadeff9e4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/webmozarts/assert/zipball/9be6926d8b485f55b9229203f962b51ed377ba68", - "reference": "9be6926d8b485f55b9229203f962b51ed377ba68", + "url": "https://api.github.com/repos/webmozarts/assert/zipball/eb0d790f735ba6cff25c683a85a1da0eadeff9e4", + "reference": "eb0d790f735ba6cff25c683a85a1da0eadeff9e4", "shasum": "" }, "require": { "ext-ctype": "*", "ext-date": "*", "ext-filter": "*", - "php": "^7.2 || ^8.0" + "php": "^8.2" }, "suggest": { "ext-intl": "", @@ -10119,7 +10103,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "1.10-dev" + "dev-feature/2-0": "2.0-dev" } }, "autoload": { @@ -10135,6 +10119,10 @@ { "name": "Bernhard Schussek", "email": "bschussek@gmail.com" + }, + { + "name": "Woody Gilk", + "email": "woody.gilk@gmail.com" } ], "description": "Assertions to validate method input/output with nice error messages.", @@ -10145,9 +10133,9 @@ ], "support": { "issues": "https://github.com/webmozarts/assert/issues", - "source": "https://github.com/webmozarts/assert/tree/1.12.1" + "source": "https://github.com/webmozarts/assert/tree/2.3.0" }, - "time": "2025-10-29T15:56:20+00:00" + "time": "2026-04-11T10:33:05+00:00" } ], "aliases": [], From 94eed44e62985188f8135ff1fea36d30feed7a6c Mon Sep 17 00:00:00 2001 From: Giulio Troccoli-Allard Date: Tue, 28 Apr 2026 17:52:23 +0100 Subject: [PATCH 37/63] chore: update Node packages --- package-lock.json | 723 +++++++++++++++++++++++----------------------- 1 file changed, 363 insertions(+), 360 deletions(-) diff --git a/package-lock.json b/package-lock.json index 1305d4c..cf1950b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,9 +14,9 @@ } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz", - "integrity": "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", "cpu": [ "ppc64" ], @@ -31,9 +31,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.4.tgz", - "integrity": "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", "cpu": [ "arm" ], @@ -48,9 +48,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz", - "integrity": "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", "cpu": [ "arm64" ], @@ -65,9 +65,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.4.tgz", - "integrity": "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", "cpu": [ "x64" ], @@ -82,9 +82,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz", - "integrity": "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", "cpu": [ "arm64" ], @@ -99,9 +99,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz", - "integrity": "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", "cpu": [ "x64" ], @@ -116,9 +116,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.4.tgz", - "integrity": "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", "cpu": [ "arm64" ], @@ -133,9 +133,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz", - "integrity": "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", "cpu": [ "x64" ], @@ -150,9 +150,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.4.tgz", - "integrity": "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", "cpu": [ "arm" ], @@ -167,9 +167,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz", - "integrity": "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", "cpu": [ "arm64" ], @@ -184,9 +184,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz", - "integrity": "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", "cpu": [ "ia32" ], @@ -201,9 +201,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz", - "integrity": "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", "cpu": [ "loong64" ], @@ -218,9 +218,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.4.tgz", - "integrity": "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", "cpu": [ "mips64el" ], @@ -235,9 +235,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz", - "integrity": "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", "cpu": [ "ppc64" ], @@ -252,9 +252,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz", - "integrity": "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", "cpu": [ "riscv64" ], @@ -269,9 +269,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.4.tgz", - "integrity": "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", "cpu": [ "s390x" ], @@ -286,9 +286,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz", - "integrity": "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", "cpu": [ "x64" ], @@ -303,9 +303,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.4.tgz", - "integrity": "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", "cpu": [ "arm64" ], @@ -320,9 +320,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz", - "integrity": "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", "cpu": [ "x64" ], @@ -337,9 +337,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.4.tgz", - "integrity": "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", "cpu": [ "arm64" ], @@ -354,9 +354,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz", - "integrity": "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", "cpu": [ "x64" ], @@ -371,9 +371,9 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz", - "integrity": "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", "cpu": [ "arm64" ], @@ -388,9 +388,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz", - "integrity": "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", "cpu": [ "x64" ], @@ -405,9 +405,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz", - "integrity": "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", "cpu": [ "arm64" ], @@ -422,9 +422,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz", - "integrity": "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", "cpu": [ "ia32" ], @@ -439,9 +439,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz", - "integrity": "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", "cpu": [ "x64" ], @@ -506,9 +506,9 @@ } }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", - "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.2.tgz", + "integrity": "sha512-dnlp69efPPg6Uaw2dVqzWRfAWRnYVb1XJ8CyyhIbZeaq4CA5/mLeZ1IEt9QqQxmbdvagjLIm2ZL8BxXv5lH4Yw==", "cpu": [ "arm" ], @@ -520,9 +520,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", - "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.2.tgz", + "integrity": "sha512-OqZTwDRDchGRHHm/hwLOL7uVPB9aUvI0am/eQuWMNyFHf5PSEQmyEeYYheA0EPPKUO/l0uigCp+iaTjoLjVoHg==", "cpu": [ "arm64" ], @@ -534,9 +534,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", - "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.2.tgz", + "integrity": "sha512-UwRE7CGpvSVEQS8gUMBe1uADWjNnVgP3Iusyda1nSRwNDCsRjnGc7w6El6WLQsXmZTbLZx9cecegumcitNfpmA==", "cpu": [ "arm64" ], @@ -548,9 +548,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", - "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.2.tgz", + "integrity": "sha512-gjEtURKLCC5VXm1I+2i1u9OhxFsKAQJKTVB8WvDAHF+oZlq0GTVFOlTlO1q3AlCTE/DF32c16ESvfgqR7343/g==", "cpu": [ "x64" ], @@ -562,9 +562,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", - "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.2.tgz", + "integrity": "sha512-Bcl6CYDeAgE70cqZaMojOi/eK63h5Me97ZqAQoh77VPjMysA/4ORQBRGo3rRy45x4MzVlU9uZxs8Uwy7ZaKnBw==", "cpu": [ "arm64" ], @@ -576,9 +576,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", - "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.2.tgz", + "integrity": "sha512-LU+TPda3mAE2QB0/Hp5VyeKJivpC6+tlOXd1VMoXV/YFMvk/MNk5iXeBfB4MQGRWyOYVJ01625vjkr0Az98OJQ==", "cpu": [ "x64" ], @@ -590,9 +590,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", - "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.2.tgz", + "integrity": "sha512-2QxQrM+KQ7DAW4o22j+XZ6RKdxjLD7BOWTP0Bv0tmjdyhXSsr2Ul1oJDQqh9Zf5qOwTuTc7Ek83mOFaKnodPjg==", "cpu": [ "arm" ], @@ -604,9 +604,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", - "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.2.tgz", + "integrity": "sha512-TbziEu2DVsTEOPif2mKWkMeDMLoYjx95oESa9fkQQK7r/Orta0gnkcDpzwufEcAO2BLBsD7mZkXGFqEdMRRwfw==", "cpu": [ "arm" ], @@ -618,9 +618,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", - "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.2.tgz", + "integrity": "sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg==", "cpu": [ "arm64" ], @@ -632,9 +632,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", - "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.2.tgz", + "integrity": "sha512-hr26p7e93Rl0Za+JwW7EAnwAvKkehh12BU1Llm9Ykiibg4uIr2rbpxG9WCf56GuvidlTG9KiiQT/TXT1yAWxTA==", "cpu": [ "arm64" ], @@ -646,9 +646,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", - "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.2.tgz", + "integrity": "sha512-pOjB/uSIyDt+ow3k/RcLvUAOGpysT2phDn7TTUB3n75SlIgZzM6NKAqlErPhoFU+npgY3/n+2HYIQVbF70P9/A==", "cpu": [ "loong64" ], @@ -660,9 +660,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", - "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.2.tgz", + "integrity": "sha512-2/w+q8jszv9Ww1c+6uJT3OwqhdmGP2/4T17cu8WuwyUuuaCDDJ2ojdyYwZzCxx0GcsZBhzi3HmH+J5pZNXnd+Q==", "cpu": [ "loong64" ], @@ -674,9 +674,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", - "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.2.tgz", + "integrity": "sha512-11+aL5vKheYgczxtPVVRhdptAM2H7fcDR5Gw4/bTcteuZBlH4oP9f5s9zYO9aGZvoGeBpqXI/9TZZihZ609wKw==", "cpu": [ "ppc64" ], @@ -688,9 +688,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", - "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.2.tgz", + "integrity": "sha512-i16fokAGK46IVZuV8LIIwMdtqhin9hfYkCh8pf8iC3QU3LpwL+1FSFGej+O7l3E/AoknL6Dclh2oTdnRMpTzFQ==", "cpu": [ "ppc64" ], @@ -702,9 +702,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", - "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.2.tgz", + "integrity": "sha512-49FkKS6RGQoriDSK/6E2GkAsAuU5kETFCh7pG4yD/ylj9rKhTmO3elsnmBvRD4PgJPds5W2PkhC82aVwmUcJ7A==", "cpu": [ "riscv64" ], @@ -716,9 +716,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", - "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.2.tgz", + "integrity": "sha512-mjYNkHPfGpUR00DuM1ZZIgs64Hpf4bWcz9Z41+4Q+pgDx73UwWdAYyf6EG/lRFldmdHHzgrYyge5akFUW0D3mQ==", "cpu": [ "riscv64" ], @@ -730,9 +730,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", - "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.2.tgz", + "integrity": "sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA==", "cpu": [ "s390x" ], @@ -744,9 +744,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", - "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.2.tgz", + "integrity": "sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ==", "cpu": [ "x64" ], @@ -758,9 +758,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", - "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.2.tgz", + "integrity": "sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw==", "cpu": [ "x64" ], @@ -772,9 +772,9 @@ ] }, "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", - "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.2.tgz", + "integrity": "sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg==", "cpu": [ "x64" ], @@ -786,9 +786,9 @@ ] }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", - "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.2.tgz", + "integrity": "sha512-NetAg5iO2uN7eB8zE5qrZ3CSil+7IJt4WDFLcC75Ymywq1VZVD6qJ6EvNLjZ3rEm6gB7XW5JdT60c6MN35Z85Q==", "cpu": [ "arm64" ], @@ -800,9 +800,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", - "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.2.tgz", + "integrity": "sha512-NCYhOotpgWZ5kdxCZsv6Iudx0wX8980Q/oW4pNFNihpBKsDbEA1zpkfxJGC0yugsUuyDZ7gL37dbzwhR0VI7pQ==", "cpu": [ "arm64" ], @@ -814,9 +814,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", - "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.2.tgz", + "integrity": "sha512-RXsaOqXxfoUBQoOgvmmijVxJnW2IGB0eoMO7F8FAjaj0UTywUO/luSqimWBJn04WNgUkeNhh7fs7pESXajWmkg==", "cpu": [ "ia32" ], @@ -828,9 +828,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", - "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.2.tgz", + "integrity": "sha512-qdAzEULD+/hzObedtmV6iBpdL5TIbKVztGiK7O3/KYSf+HIzU257+MX1EXJcyIiDbMAqmbwaufcYPvyRryeZtA==", "cpu": [ "x64" ], @@ -842,9 +842,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", - "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.2.tgz", + "integrity": "sha512-Nd/SgG27WoA9e+/TdK74KnHz852TLa94ovOYySo/yMPuTmpckK/jIF2jSwS3g7ELSKXK13/cVdmg1Z/DaCWKxA==", "cpu": [ "x64" ], @@ -856,49 +856,49 @@ ] }, "node_modules/@tailwindcss/node": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.1.tgz", - "integrity": "sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg==", + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.4.tgz", + "integrity": "sha512-Ai7+yQPxz3ddrDQzFfBKdHEVBg0w3Zl83jnjuwxnZOsnH9pGn93QHQtpU0p/8rYWxvbFZHneni6p1BSLK4DkGA==", "dev": true, "license": "MIT", "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.19.0", "jiti": "^2.6.1", - "lightningcss": "1.31.1", + "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", - "tailwindcss": "4.2.1" + "tailwindcss": "4.2.4" } }, "node_modules/@tailwindcss/oxide": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.1.tgz", - "integrity": "sha512-yv9jeEFWnjKCI6/T3Oq50yQEOqmpmpfzG1hcZsAOaXFQPfzWprWrlHSdGPEF3WQTi8zu8ohC9Mh9J470nT5pUw==", + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.4.tgz", + "integrity": "sha512-9El/iI069DKDSXwTvB9J4BwdO5JhRrOweGaK25taBAvBXyXqJAX+Jqdvs8r8gKpsI/1m0LeJLyQYTf/WLrBT1Q==", "dev": true, "license": "MIT", "engines": { "node": ">= 20" }, "optionalDependencies": { - "@tailwindcss/oxide-android-arm64": "4.2.1", - "@tailwindcss/oxide-darwin-arm64": "4.2.1", - "@tailwindcss/oxide-darwin-x64": "4.2.1", - "@tailwindcss/oxide-freebsd-x64": "4.2.1", - "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.1", - "@tailwindcss/oxide-linux-arm64-gnu": "4.2.1", - "@tailwindcss/oxide-linux-arm64-musl": "4.2.1", - "@tailwindcss/oxide-linux-x64-gnu": "4.2.1", - "@tailwindcss/oxide-linux-x64-musl": "4.2.1", - "@tailwindcss/oxide-wasm32-wasi": "4.2.1", - "@tailwindcss/oxide-win32-arm64-msvc": "4.2.1", - "@tailwindcss/oxide-win32-x64-msvc": "4.2.1" + "@tailwindcss/oxide-android-arm64": "4.2.4", + "@tailwindcss/oxide-darwin-arm64": "4.2.4", + "@tailwindcss/oxide-darwin-x64": "4.2.4", + "@tailwindcss/oxide-freebsd-x64": "4.2.4", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.4", + "@tailwindcss/oxide-linux-arm64-gnu": "4.2.4", + "@tailwindcss/oxide-linux-arm64-musl": "4.2.4", + "@tailwindcss/oxide-linux-x64-gnu": "4.2.4", + "@tailwindcss/oxide-linux-x64-musl": "4.2.4", + "@tailwindcss/oxide-wasm32-wasi": "4.2.4", + "@tailwindcss/oxide-win32-arm64-msvc": "4.2.4", + "@tailwindcss/oxide-win32-x64-msvc": "4.2.4" } }, "node_modules/@tailwindcss/oxide-android-arm64": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.1.tgz", - "integrity": "sha512-eZ7G1Zm5EC8OOKaesIKuw77jw++QJ2lL9N+dDpdQiAB/c/B2wDh0QPFHbkBVrXnwNugvrbJFk1gK2SsVjwWReg==", + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.4.tgz", + "integrity": "sha512-e7MOr1SAn9U8KlZzPi1ZXGZHeC5anY36qjNwmZv9pOJ8E4Q6jmD1vyEHkQFmNOIN7twGPEMXRHmitN4zCMN03g==", "cpu": [ "arm64" ], @@ -913,9 +913,9 @@ } }, "node_modules/@tailwindcss/oxide-darwin-arm64": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.1.tgz", - "integrity": "sha512-q/LHkOstoJ7pI1J0q6djesLzRvQSIfEto148ppAd+BVQK0JYjQIFSK3JgYZJa+Yzi0DDa52ZsQx2rqytBnf8Hw==", + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.4.tgz", + "integrity": "sha512-tSC/Kbqpz/5/o/C2sG7QvOxAKqyd10bq+ypZNf+9Fi2TvbVbv1zNpcEptcsU7DPROaSbVgUXmrzKhurFvo5eDg==", "cpu": [ "arm64" ], @@ -930,9 +930,9 @@ } }, "node_modules/@tailwindcss/oxide-darwin-x64": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.1.tgz", - "integrity": "sha512-/f/ozlaXGY6QLbpvd/kFTro2l18f7dHKpB+ieXz+Cijl4Mt9AI2rTrpq7V+t04nK+j9XBQHnSMdeQRhbGyt6fw==", + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.4.tgz", + "integrity": "sha512-yPyUXn3yO/ufR6+Kzv0t4fCg2qNr90jxXc5QqBpjlPNd0NqyDXcmQb/6weunH/MEDXW5dhyEi+agTDiqa3WsGg==", "cpu": [ "x64" ], @@ -947,9 +947,9 @@ } }, "node_modules/@tailwindcss/oxide-freebsd-x64": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.1.tgz", - "integrity": "sha512-5e/AkgYJT/cpbkys/OU2Ei2jdETCLlifwm7ogMC7/hksI2fC3iiq6OcXwjibcIjPung0kRtR3TxEITkqgn0TcA==", + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.4.tgz", + "integrity": "sha512-BoMIB4vMQtZsXdGLVc2z+P9DbETkiopogfWZKbWwM8b/1Vinbs4YcUwo+kM/KeLkX3Ygrf4/PsRndKaYhS8Eiw==", "cpu": [ "x64" ], @@ -964,9 +964,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.1.tgz", - "integrity": "sha512-Uny1EcVTTmerCKt/1ZuKTkb0x8ZaiuYucg2/kImO5A5Y/kBz41/+j0gxUZl+hTF3xkWpDmHX+TaWhOtba2Fyuw==", + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.4.tgz", + "integrity": "sha512-7pIHBLTHYRAlS7V22JNuTh33yLH4VElwKtB3bwchK/UaKUPpQ0lPQiOWcbm4V3WP2I6fNIJ23vABIvoy2izdwA==", "cpu": [ "arm" ], @@ -981,9 +981,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.1.tgz", - "integrity": "sha512-CTrwomI+c7n6aSSQlsPL0roRiNMDQ/YzMD9EjcR+H4f0I1SQ8QqIuPnsVp7QgMkC1Qi8rtkekLkOFjo7OlEFRQ==", + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.4.tgz", + "integrity": "sha512-+E4wxJ0ZGOzSH325reXTWB48l42i93kQqMvDyz5gqfRzRZ7faNhnmvlV4EPGJU3QJM/3Ab5jhJ5pCRUsKn6OQw==", "cpu": [ "arm64" ], @@ -998,9 +998,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm64-musl": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.1.tgz", - "integrity": "sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ==", + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.4.tgz", + "integrity": "sha512-bBADEGAbo4ASnppIziaQJelekCxdMaxisrk+fB7Thit72IBnALp9K6ffA2G4ruj90G9XRS2VQ6q2bCKbfFV82g==", "cpu": [ "arm64" ], @@ -1015,9 +1015,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-x64-gnu": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.1.tgz", - "integrity": "sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g==", + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.4.tgz", + "integrity": "sha512-7Mx25E4WTfnht0TVRTyC00j3i0M+EeFe7wguMDTlX4mRxafznw0CA8WJkFjWYH5BlgELd1kSjuU2JiPnNZbJDA==", "cpu": [ "x64" ], @@ -1032,9 +1032,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-x64-musl": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.1.tgz", - "integrity": "sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g==", + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.4.tgz", + "integrity": "sha512-2wwJRF7nyhOR0hhHoChc04xngV3iS+akccHTGtz965FwF0up4b2lOdo6kI1EbDaEXKgvcrFBYcYQQ/rrnWFVfA==", "cpu": [ "x64" ], @@ -1049,9 +1049,9 @@ } }, "node_modules/@tailwindcss/oxide-wasm32-wasi": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.1.tgz", - "integrity": "sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q==", + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.4.tgz", + "integrity": "sha512-FQsqApeor8Fo6gUEklzmaa9994orJZZDBAlQpK2Mq+DslRKFJeD6AjHpBQ0kZFQohVr8o85PPh8eOy86VlSCmw==", "bundleDependencies": [ "@napi-rs/wasm-runtime", "@emnapi/core", @@ -1079,9 +1079,9 @@ } }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.1.tgz", - "integrity": "sha512-YlUEHRHBGnCMh4Nj4GnqQyBtsshUPdiNroZj8VPkvTZSoHsilRCwXcVKnG9kyi0ZFAS/3u+qKHBdDc81SADTRA==", + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.4.tgz", + "integrity": "sha512-L9BXqxC4ToVgwMFqj3pmZRqyHEztulpUJzCxUtLjobMCzTPsGt1Fa9enKbOpY2iIyVtaHNeNvAK8ERP/64sqGQ==", "cpu": [ "arm64" ], @@ -1096,9 +1096,9 @@ } }, "node_modules/@tailwindcss/oxide-win32-x64-msvc": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.1.tgz", - "integrity": "sha512-rbO34G5sMWWyrN/idLeVxAZgAKWrn5LiR3/I90Q9MkA67s6T1oB0xtTe+0heoBvHSpbU9Mk7i6uwJnpo4u21XQ==", + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.4.tgz", + "integrity": "sha512-ESlKG0EpVJQwRjXDDa9rLvhEAh0mhP1sF7sap9dNZT0yyl9SAG6T7gdP09EH0vIv0UNTlo6jPWyujD6559fZvw==", "cpu": [ "x64" ], @@ -1113,18 +1113,18 @@ } }, "node_modules/@tailwindcss/vite": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.2.1.tgz", - "integrity": "sha512-TBf2sJjYeb28jD2U/OhwdW0bbOsxkWPwQ7SrqGf9sVcoYwZj7rkXljroBO9wKBut9XnmQLXanuDUeqQK0lGg/w==", + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.2.4.tgz", + "integrity": "sha512-pCvohwOCspk3ZFn6eJzrrX3g4n2JY73H6MmYC87XfGPyTty4YsCjYTMArRZm/zOI8dIt3+EcrLHAFPe5A4bgtw==", "dev": true, "license": "MIT", "dependencies": { - "@tailwindcss/node": "4.2.1", - "@tailwindcss/oxide": "4.2.1", - "tailwindcss": "4.2.1" + "@tailwindcss/node": "4.2.4", + "@tailwindcss/oxide": "4.2.4", + "tailwindcss": "4.2.4" }, "peerDependencies": { - "vite": "^5.2.0 || ^6 || ^7" + "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "node_modules/@types/estree": { @@ -1168,15 +1168,15 @@ "license": "MIT" }, "node_modules/axios": { - "version": "1.13.6", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz", - "integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==", + "version": "1.15.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.2.tgz", + "integrity": "sha512-wLrXxPtcrPTsNlJmKjkPnNPK2Ihe0hn0wGSaTEiHRPxwjvJwT3hKmXF4dpqxmPO9SoNb2FsYXj/xEo0gHN+D5A==", "dev": true, "license": "MIT", "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", - "proxy-from-env": "^1.1.0" + "proxy-from-env": "^2.1.0" } }, "node_modules/call-bind-apply-helpers": { @@ -1339,14 +1339,14 @@ "license": "MIT" }, "node_modules/enhanced-resolve": { - "version": "5.20.0", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.0.tgz", - "integrity": "sha512-/ce7+jQ1PQ6rVXwe+jKEg5hW5ciicHwIQUagZkp6IufBoY3YDgdTTY1azVs0qoRgVmvsNB+rbjLJxDAeHHtwsQ==", + "version": "5.21.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.0.tgz", + "integrity": "sha512-otxSQPw4lkOZWkHpB3zaEQs6gWYEsmX4xQF68ElXC/TWvGxGMSGOvoNbaLXm6/cS/fSfHtsEdw90y20PCd+sCA==", "dev": true, "license": "MIT", "dependencies": { "graceful-fs": "^4.2.4", - "tapable": "^2.3.0" + "tapable": "^2.3.3" }, "engines": { "node": ">=10.13.0" @@ -1402,9 +1402,9 @@ } }, "node_modules/esbuild": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz", - "integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -1415,32 +1415,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.4", - "@esbuild/android-arm": "0.27.4", - "@esbuild/android-arm64": "0.27.4", - "@esbuild/android-x64": "0.27.4", - "@esbuild/darwin-arm64": "0.27.4", - "@esbuild/darwin-x64": "0.27.4", - "@esbuild/freebsd-arm64": "0.27.4", - "@esbuild/freebsd-x64": "0.27.4", - "@esbuild/linux-arm": "0.27.4", - "@esbuild/linux-arm64": "0.27.4", - "@esbuild/linux-ia32": "0.27.4", - "@esbuild/linux-loong64": "0.27.4", - "@esbuild/linux-mips64el": "0.27.4", - "@esbuild/linux-ppc64": "0.27.4", - "@esbuild/linux-riscv64": "0.27.4", - "@esbuild/linux-s390x": "0.27.4", - "@esbuild/linux-x64": "0.27.4", - "@esbuild/netbsd-arm64": "0.27.4", - "@esbuild/netbsd-x64": "0.27.4", - "@esbuild/openbsd-arm64": "0.27.4", - "@esbuild/openbsd-x64": "0.27.4", - "@esbuild/openharmony-arm64": "0.27.4", - "@esbuild/sunos-x64": "0.27.4", - "@esbuild/win32-arm64": "0.27.4", - "@esbuild/win32-ia32": "0.27.4", - "@esbuild/win32-x64": "0.27.4" + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" } }, "node_modules/escalade": { @@ -1472,9 +1472,9 @@ } }, "node_modules/follow-redirects": { - "version": "1.15.11", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", - "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", "dev": true, "funding": [ { @@ -1643,9 +1643,9 @@ } }, "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", "dev": true, "license": "MIT", "dependencies": { @@ -1696,9 +1696,9 @@ } }, "node_modules/lightningcss": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.31.1.tgz", - "integrity": "sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", "dev": true, "license": "MPL-2.0", "dependencies": { @@ -1712,23 +1712,23 @@ "url": "https://opencollective.com/parcel" }, "optionalDependencies": { - "lightningcss-android-arm64": "1.31.1", - "lightningcss-darwin-arm64": "1.31.1", - "lightningcss-darwin-x64": "1.31.1", - "lightningcss-freebsd-x64": "1.31.1", - "lightningcss-linux-arm-gnueabihf": "1.31.1", - "lightningcss-linux-arm64-gnu": "1.31.1", - "lightningcss-linux-arm64-musl": "1.31.1", - "lightningcss-linux-x64-gnu": "1.31.1", - "lightningcss-linux-x64-musl": "1.31.1", - "lightningcss-win32-arm64-msvc": "1.31.1", - "lightningcss-win32-x64-msvc": "1.31.1" + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" } }, "node_modules/lightningcss-android-arm64": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.31.1.tgz", - "integrity": "sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", "cpu": [ "arm64" ], @@ -1747,9 +1747,9 @@ } }, "node_modules/lightningcss-darwin-arm64": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.31.1.tgz", - "integrity": "sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", "cpu": [ "arm64" ], @@ -1768,9 +1768,9 @@ } }, "node_modules/lightningcss-darwin-x64": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.31.1.tgz", - "integrity": "sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", "cpu": [ "x64" ], @@ -1789,9 +1789,9 @@ } }, "node_modules/lightningcss-freebsd-x64": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.31.1.tgz", - "integrity": "sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", "cpu": [ "x64" ], @@ -1810,9 +1810,9 @@ } }, "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.31.1.tgz", - "integrity": "sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", "cpu": [ "arm" ], @@ -1831,9 +1831,9 @@ } }, "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.31.1.tgz", - "integrity": "sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", "cpu": [ "arm64" ], @@ -1852,9 +1852,9 @@ } }, "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.31.1.tgz", - "integrity": "sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", "cpu": [ "arm64" ], @@ -1873,9 +1873,9 @@ } }, "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.31.1.tgz", - "integrity": "sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", "cpu": [ "x64" ], @@ -1894,9 +1894,9 @@ } }, "node_modules/lightningcss-linux-x64-musl": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.31.1.tgz", - "integrity": "sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", "cpu": [ "x64" ], @@ -1915,9 +1915,9 @@ } }, "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.31.1.tgz", - "integrity": "sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", "cpu": [ "arm64" ], @@ -1936,9 +1936,9 @@ } }, "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.31.1.tgz", - "integrity": "sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", "cpu": [ "x64" ], @@ -2026,9 +2026,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", "engines": { @@ -2039,9 +2039,9 @@ } }, "node_modules/postcss": { - "version": "8.5.8", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", - "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "version": "8.5.12", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.12.tgz", + "integrity": "sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==", "dev": true, "funding": [ { @@ -2068,11 +2068,14 @@ } }, "node_modules/proxy-from-env": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=10" + } }, "node_modules/require-directory": { "version": "2.1.1", @@ -2085,9 +2088,9 @@ } }, "node_modules/rollup": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", - "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.2.tgz", + "integrity": "sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2101,31 +2104,31 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.59.0", - "@rollup/rollup-android-arm64": "4.59.0", - "@rollup/rollup-darwin-arm64": "4.59.0", - "@rollup/rollup-darwin-x64": "4.59.0", - "@rollup/rollup-freebsd-arm64": "4.59.0", - "@rollup/rollup-freebsd-x64": "4.59.0", - "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", - "@rollup/rollup-linux-arm-musleabihf": "4.59.0", - "@rollup/rollup-linux-arm64-gnu": "4.59.0", - "@rollup/rollup-linux-arm64-musl": "4.59.0", - "@rollup/rollup-linux-loong64-gnu": "4.59.0", - "@rollup/rollup-linux-loong64-musl": "4.59.0", - "@rollup/rollup-linux-ppc64-gnu": "4.59.0", - "@rollup/rollup-linux-ppc64-musl": "4.59.0", - "@rollup/rollup-linux-riscv64-gnu": "4.59.0", - "@rollup/rollup-linux-riscv64-musl": "4.59.0", - "@rollup/rollup-linux-s390x-gnu": "4.59.0", - "@rollup/rollup-linux-x64-gnu": "4.59.0", - "@rollup/rollup-linux-x64-musl": "4.59.0", - "@rollup/rollup-openbsd-x64": "4.59.0", - "@rollup/rollup-openharmony-arm64": "4.59.0", - "@rollup/rollup-win32-arm64-msvc": "4.59.0", - "@rollup/rollup-win32-ia32-msvc": "4.59.0", - "@rollup/rollup-win32-x64-gnu": "4.59.0", - "@rollup/rollup-win32-x64-msvc": "4.59.0", + "@rollup/rollup-android-arm-eabi": "4.60.2", + "@rollup/rollup-android-arm64": "4.60.2", + "@rollup/rollup-darwin-arm64": "4.60.2", + "@rollup/rollup-darwin-x64": "4.60.2", + "@rollup/rollup-freebsd-arm64": "4.60.2", + "@rollup/rollup-freebsd-x64": "4.60.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.2", + "@rollup/rollup-linux-arm-musleabihf": "4.60.2", + "@rollup/rollup-linux-arm64-gnu": "4.60.2", + "@rollup/rollup-linux-arm64-musl": "4.60.2", + "@rollup/rollup-linux-loong64-gnu": "4.60.2", + "@rollup/rollup-linux-loong64-musl": "4.60.2", + "@rollup/rollup-linux-ppc64-gnu": "4.60.2", + "@rollup/rollup-linux-ppc64-musl": "4.60.2", + "@rollup/rollup-linux-riscv64-gnu": "4.60.2", + "@rollup/rollup-linux-riscv64-musl": "4.60.2", + "@rollup/rollup-linux-s390x-gnu": "4.60.2", + "@rollup/rollup-linux-x64-gnu": "4.60.2", + "@rollup/rollup-linux-x64-musl": "4.60.2", + "@rollup/rollup-openbsd-x64": "4.60.2", + "@rollup/rollup-openharmony-arm64": "4.60.2", + "@rollup/rollup-win32-arm64-msvc": "4.60.2", + "@rollup/rollup-win32-ia32-msvc": "4.60.2", + "@rollup/rollup-win32-x64-gnu": "4.60.2", + "@rollup/rollup-win32-x64-msvc": "4.60.2", "fsevents": "~2.3.2" } }, @@ -2207,16 +2210,16 @@ } }, "node_modules/tailwindcss": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.1.tgz", - "integrity": "sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw==", + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.4.tgz", + "integrity": "sha512-HhKppgO81FQof5m6TEnuBWCZGgfRAWbaeOaGT00KOy/Pf/j6oUihdvBpA7ltCeAvZpFhW3j0PTclkxsd4IXYDA==", "dev": true, "license": "MIT" }, "node_modules/tapable": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", - "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", "dev": true, "license": "MIT", "engines": { @@ -2228,14 +2231,14 @@ } }, "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", - "picomatch": "^4.0.3" + "picomatch": "^4.0.4" }, "engines": { "node": ">=12.0.0" @@ -2262,9 +2265,9 @@ "license": "0BSD" }, "node_modules/vite": { - "version": "7.3.1", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", - "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "version": "7.3.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz", + "integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==", "dev": true, "license": "MIT", "dependencies": { @@ -2348,9 +2351,9 @@ } }, "node_modules/vite-plugin-full-reload/node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "dev": true, "license": "MIT", "engines": { From e4ace0b0c8806773eb663bd38d36b49d865ce2d4 Mon Sep 17 00:00:00 2001 From: Giulio Troccoli-Allard Date: Tue, 28 Apr 2026 18:20:36 +0100 Subject: [PATCH 38/63] feat: add cards for timeouts and substitutions --- app/Livewire/TeamRoster.php | 56 ++++++- app/Services/GameState/GameStateProjector.php | 2 + .../views/livewire/team-roster.blade.php | 120 +++++++++++++- tests/Feature/GameEventTest.php | 16 ++ tests/Feature/TeamRosterTest.php | 151 ++++++++++++++++++ 5 files changed, 342 insertions(+), 3 deletions(-) diff --git a/app/Livewire/TeamRoster.php b/app/Livewire/TeamRoster.php index 511b669..9adeb65 100644 --- a/app/Livewire/TeamRoster.php +++ b/app/Livewire/TeamRoster.php @@ -8,6 +8,7 @@ use App\Enums\StaffRole; use App\Enums\TeamAB; use App\Enums\TeamSide; +use App\Exceptions\InvalidGameEventTransition; use App\Models\Game; use App\Services\GameSideResolver; use App\Services\ScoresheetDataRepository; @@ -44,6 +45,35 @@ public function mount( $this->leftSide = $leftSide; } + public function requestTimeout(): void + { + $activeGame = $this->activeGame(); + + if ($activeGame === null) { + $this->addError('timeout', 'No active game is available to record the timeout.'); + + return; + } + + $state = $activeGame->stateAt(); + $timeoutsTaken = $this->team === TeamAB::TeamA ? $state->timeoutsTeamA : $state->timeoutsTeamB; + $hasTimeoutLeft = $timeoutsTaken < 2; + + if ($hasTimeoutLeft) { + try { + $activeGame->recordTimeOut($this->team); + } catch (InvalidGameEventTransition $exception) { + $this->addError('timeout', $exception->getMessage()); + + return; + } + + $this->dispatch('game-event-recorded'); + } + + $this->dispatch('timeout-recorded', team: $this->team->value, hasTimeoutLeft: $hasTimeoutLeft); + } + public function render(): View { $teamPlayers = $this->teamPlayers(); @@ -58,9 +88,12 @@ public function render(): View 'placeholderCount' => $placeholderCount, 'hasRosterPlayers' => $rosterPlayerCount > 0, 'staffMarkers' => $this->buildStaffMarkers($this->teamStaff()), - 'reverseStaffOrder' => $this->leftSide, + 'reverseLayout' => $this->leftSide, 'keyPrefix' => $this->leftSide ? 'left-player' : 'right-player', 'markerTone' => $this->team === TeamAB::TeamA ? 'bg-blue-600' : 'bg-red-600', + 'timeoutsTaken' => $this->timeoutsTaken(), + 'substitutionsTaken' => $this->substitutionsTaken(), + 'canRequestTimeout' => $this->canRequestTimeout(), ]); } @@ -195,6 +228,27 @@ private function hasLineupBeenSubmitted(TeamAB $team): bool return $this->onCourtRosterNumbers($team) !== []; } + private function canRequestTimeout(): bool + { + $state = $this->gameState ?? GameState::initial(); + + return $state->setInProgress && ! $state->gameEnded; + } + + private function timeoutsTaken(): int + { + $state = $this->gameState ?? GameState::initial(); + + return $this->team === TeamAB::TeamA ? $state->timeoutsTeamA : $state->timeoutsTeamB; + } + + private function substitutionsTaken(): int + { + $state = $this->gameState ?? GameState::initial(); + + return $this->team === TeamAB::TeamA ? $state->substitutionsTeamA : $state->substitutionsTeamB; + } + private function gameSideResolver(): GameSideResolver { return app(GameSideResolver::class); diff --git a/app/Services/GameState/GameStateProjector.php b/app/Services/GameState/GameStateProjector.php index 953dbe3..fc68afb 100644 --- a/app/Services/GameState/GameStateProjector.php +++ b/app/Services/GameState/GameStateProjector.php @@ -164,6 +164,8 @@ private function applySetEnded(GameState $state, GameEvent $event): GameState $state->scoreTeamA = 0; $state->scoreTeamB = 0; + $state->timeoutsTeamA = 0; + $state->timeoutsTeamB = 0; $state->rotationTeamA = []; $state->rotationTeamB = []; $state->setInProgress = false; diff --git a/resources/views/livewire/team-roster.blade.php b/resources/views/livewire/team-roster.blade.php index c38dd0a..7ef194a 100644 --- a/resources/views/livewire/team-roster.blade.php +++ b/resources/views/livewire/team-roster.blade.php @@ -18,7 +18,7 @@ class="h-8 w-8 rounded-full border border-slate-300 bg-slate-300 shadow-sm" role="list" class="flex flex-nowrap items-center gap-2 overflow-x-auto pb-1" > - @foreach ($players as $player) + @foreach (($reverseLayout ? array_reverse($players) : $players) as $player) @endif + +
$reverseLayout])> + @if ($canRequestTimeout) +
+ + + Timeouts + {{ $timeoutsTaken }}/2 + + + + +
+
+ Request Timeout + Confirm that you want to request a timeout for this team. +
+ @error('timeout') + {{ $message }} + @enderror +
+ + + Cancel + + + Confirm + +
+
+
+ + +
+ Timeout in progress + + seconds remaining +
+ +
+ Time's up! +
+ Return to play +
+
+ +
+ No timeout left +
+ Return to play +
+
+
+
+ @else + + Timeouts + {{ $timeoutsTaken }}/2 + + @endif + + + Substitutions + {{ $substitutionsTaken }}/6 + +
diff --git a/tests/Feature/GameEventTest.php b/tests/Feature/GameEventTest.php index a8fcabc..764a7f9 100644 --- a/tests/Feature/GameEventTest.php +++ b/tests/Feature/GameEventTest.php @@ -445,6 +445,22 @@ function submitLineupsForSet(Game $game, int $set): void 'team B' => [TeamAB::TeamB], ]); +test('timeout counts reset to zero when a set ends', function (): void { + $homeTeam = Team::factory()->create(); + $awayTeam = Team::factory()->create(); + $game = Game::factory()->betweenTeams($homeTeam, $awayTeam)->create(); + + prepareActiveSet($game); + $game->recordTimeOut(TeamAB::TeamA); + $game->recordTimeOut(TeamAB::TeamB); + + winSet($game, TeamAB::TeamA); + + $state = $game->fresh()->stateAt(); + expect($state->timeoutsTeamA)->toBe(0) + ->and($state->timeoutsTeamB)->toBe(0); +}); + test('a set started event can be recorded with the correct type and empty payload', function (): void { $homeTeam = Team::factory()->create(); $awayTeam = Team::factory()->create(); diff --git a/tests/Feature/TeamRosterTest.php b/tests/Feature/TeamRosterTest.php index 5edc991..e9753c2 100644 --- a/tests/Feature/TeamRosterTest.php +++ b/tests/Feature/TeamRosterTest.php @@ -3,6 +3,7 @@ declare(strict_types=1); use App\Data\GameState\GameState; +use App\Enums\GameEventType; use App\Enums\StaffRole; use App\Enums\TeamAB; use App\Enums\TeamSide; @@ -242,6 +243,156 @@ ->assertDontSeeHtml('data-team-roster-staff-role="T"'); }); +test('timeout card shows modal trigger when a set is in progress', function (): void { + $game = gameWithActiveSetForTeamRoster(); + + Livewire::test(TeamRoster::class, [ + 'gameId' => $game->getKey(), + 'team' => TeamAB::TeamA, + 'leftSide' => true, + 'gameState' => $game->stateAt(), + ])->assertSeeHtml('data-team-roster-timeouts') + ->assertSeeHtml('request-timeout-team_a'); +}); + +test('timeout card is not a modal trigger when no set is in progress', function (): void { + $homeTeam = Team::factory()->create(); + $awayTeam = Team::factory()->create(); + $game = Game::factory()->betweenTeams($homeTeam, $awayTeam)->create(); + + Livewire::test(TeamRoster::class, [ + 'gameId' => $game->getKey(), + 'team' => TeamAB::TeamA, + 'leftSide' => true, + ])->assertSeeHtml('data-team-roster-timeouts') + ->assertDontSeeHtml('request-timeout-team_a'); +}); + +test('timeout card remains interactive when team has used all timeouts', function (): void { + $game = gameWithActiveSetForTeamRoster(); + $game->recordTimeOut(TeamAB::TeamA); + $game->recordTimeOut(TeamAB::TeamA); + + Livewire::test(TeamRoster::class, [ + 'gameId' => $game->getKey(), + 'team' => TeamAB::TeamA, + 'leftSide' => true, + 'gameState' => $game->stateAt(), + ])->assertSeeHtml('data-team-roster-timeouts') + ->assertSeeHtml('request-timeout-team_a'); +}); + +test('requesting a timeout dispatches hasTimeoutLeft true when timeouts remain', function (): void { + $game = gameWithActiveSetForTeamRoster(); + + Livewire::test(TeamRoster::class, [ + 'gameId' => $game->getKey(), + 'team' => TeamAB::TeamA, + 'leftSide' => true, + 'gameState' => $game->stateAt(), + ]) + ->call('requestTimeout') + ->assertDispatched('timeout-recorded', team: 'team_a', hasTimeoutLeft: true); +}); + +test('requesting a timeout dispatches hasTimeoutLeft false and does not record an event when no timeouts remain', function (): void { + $game = gameWithActiveSetForTeamRoster(); + $game->recordTimeOut(TeamAB::TeamA); + $game->recordTimeOut(TeamAB::TeamA); + $eventCountBefore = $game->events()->count(); + + Livewire::test(TeamRoster::class, [ + 'gameId' => $game->getKey(), + 'team' => TeamAB::TeamA, + 'leftSide' => true, + 'gameState' => $game->stateAt(), + ]) + ->call('requestTimeout') + ->assertDispatched('timeout-recorded', team: 'team_a', hasTimeoutLeft: false) + ->assertNotDispatched('game-event-recorded'); + + expect($game->events()->count())->toBe($eventCountBefore); +}); + +test('timeout card becomes interactive again for a new set after two timeouts in the previous set', function (): void { + $game = gameWithActiveSetForTeamRoster(); + $game->recordTimeOut(TeamAB::TeamA); + $game->recordTimeOut(TeamAB::TeamA); + + // End set 1 (auto-ended by reactor at 25-0) and start set 2 + for ($i = 0; $i < 25; $i++) { + $game->recordRallyWinner(TeamAB::TeamA); + } + $game->recordLineup(2, TeamAB::TeamA, [1 => 1, 2 => 2, 3 => 3, 4 => 4, 5 => 5, 6 => 6]); + $game->recordLineup(2, TeamAB::TeamB, [1 => 11, 2 => 12, 3 => 13, 4 => 14, 5 => 15, 6 => 16]); + $game->recordSetStarted(); + + Livewire::test(TeamRoster::class, [ + 'gameId' => $game->getKey(), + 'team' => TeamAB::TeamA, + 'leftSide' => true, + 'gameState' => $game->stateAt(), + ])->assertSeeHtml('request-timeout-team_a'); +}); + +test('requesting a timeout records the event and dispatches events', function (): void { + $game = gameWithActiveSetForTeamRoster(); + + Livewire::test(TeamRoster::class, [ + 'gameId' => $game->getKey(), + 'team' => TeamAB::TeamA, + 'leftSide' => true, + 'gameState' => $game->stateAt(), + ]) + ->call('requestTimeout') + ->assertHasNoErrors() + ->assertDispatched('game-event-recorded') + ->assertDispatched('timeout-recorded', team: 'team_a'); + + $latestEvent = $game->fresh()->events->last(); + expect($latestEvent->type)->toBe(GameEventType::TimeOutRequested); +}); + +test('requesting a timeout adds an error when no set is in progress', function (): void { + $homeTeam = Team::factory()->create(); + $awayTeam = Team::factory()->create(); + $game = Game::factory()->betweenTeams($homeTeam, $awayTeam)->create(); + + Livewire::test(TeamRoster::class, [ + 'gameId' => $game->getKey(), + 'team' => TeamAB::TeamA, + 'leftSide' => true, + ]) + ->call('requestTimeout') + ->assertHasErrors('timeout') + ->assertNotDispatched('game-event-recorded') + ->assertNotDispatched('timeout-recorded'); +}); + +function gameWithActiveSetForTeamRoster(): Game +{ + $homeTeam = Team::factory()->create(); + $awayTeam = Team::factory()->create(); + $game = Game::factory()->betweenTeams($homeTeam, $awayTeam)->create(); + + $homePlayers = Player::factory()->for($homeTeam)->count(6)->create(); + foreach ($homePlayers as $index => $player) { + $game->addPlayer($player, number: $index + 1); + } + + $awayPlayers = Player::factory()->for($awayTeam)->count(6)->create(); + foreach ($awayPlayers as $index => $player) { + $game->addPlayer($player, number: $index + 11); + } + + $game->recordToss(TeamSide::Home, TeamAB::TeamA); + $game->recordLineup(1, TeamAB::TeamA, [1 => 1, 2 => 2, 3 => 3, 4 => 4, 5 => 5, 6 => 6]); + $game->recordLineup(1, TeamAB::TeamB, [1 => 11, 2 => 12, 3 => 13, 4 => 14, 5 => 15, 6 => 16]); + $game->recordSetStarted(); + + return $game; +} + function gameWithNumberedRostersForTeamRoster(): Game { $homeTeam = Team::factory()->create(); From 836391ff8ced75f4409042fc85863d9e6d582011 Mon Sep 17 00:00:00 2001 From: Giulio Troccoli-Allard Date: Sat, 9 May 2026 14:51:00 +0100 Subject: [PATCH 39/63] feat: move submit lineup buttons to the side of the benches Co-Authored-By: Claude Sonnet 4.6 --- resources/views/livewire/court.blade.php | 76 +++++++++++++----------- 1 file changed, 42 insertions(+), 34 deletions(-) diff --git a/resources/views/livewire/court.blade.php b/resources/views/livewire/court.blade.php index 3d8ce31..ec27773 100644 --- a/resources/views/livewire/court.blade.php +++ b/resources/views/livewire/court.blade.php @@ -80,42 +80,50 @@ class="absolute {{ $markerPositionClass }} flex h-8 w-8 items-center justify-cen @endforeach
- @if ($showRosters) -
-
- -
+
+
+ +
+ + @if ($showRosters) +
+
+ +
-
- +
+ +
-
- @endif -
+ @else +
+ @endif -
- - +
+ +
+
From fab853091bc0f0c16b6043b96bda94fce076def8 Mon Sep 17 00:00:00 2001 From: Giulio Troccoli-Allard Date: Sat, 9 May 2026 15:37:16 +0100 Subject: [PATCH 40/63] feat: improved lineup submission --- .../livewire/lineup-submission.blade.php | 53 ++++++++++++++----- 1 file changed, 39 insertions(+), 14 deletions(-) diff --git a/resources/views/livewire/lineup-submission.blade.php b/resources/views/livewire/lineup-submission.blade.php index c3f9410..42dfd1d 100644 --- a/resources/views/livewire/lineup-submission.blade.php +++ b/resources/views/livewire/lineup-submission.blade.php @@ -1,25 +1,49 @@
@if ($canSubmitLineup) - Submit Lineup + Submit Lineup - -
+ + {{ $this->modalHeading() }}
@for ($position = 1; $position <= 6; $position++) - +
+ +
@endfor
@@ -32,7 +56,8 @@ class="h-12! w-12!" @foreach ($rosterNumbers as $rosterNumber) {{ $rosterNumber }} From 7f56935acf1ba4a3fff58ac28ebb14c3c7db7140 Mon Sep 17 00:00:00 2001 From: Giulio Troccoli-Allard Date: Sat, 9 May 2026 15:44:03 +0100 Subject: [PATCH 41/63] fix: removed icons from buttons --- resources/views/livewire/start-set-submission.blade.php | 3 +-- resources/views/livewire/toss-result-submission.blade.php | 8 ++++---- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/resources/views/livewire/start-set-submission.blade.php b/resources/views/livewire/start-set-submission.blade.php index a6e33d2..f92acb3 100644 --- a/resources/views/livewire/start-set-submission.blade.php +++ b/resources/views/livewire/start-set-submission.blade.php @@ -3,7 +3,6 @@
@error('startSet') - {{ $message }} + {{ $message }} @enderror
@endif diff --git a/resources/views/livewire/toss-result-submission.blade.php b/resources/views/livewire/toss-result-submission.blade.php index 6d1909b..bf1f0f9 100644 --- a/resources/views/livewire/toss-result-submission.blade.php +++ b/resources/views/livewire/toss-result-submission.blade.php @@ -2,7 +2,7 @@ @unless ($hasSubmittedToss)
- + Submit Toss Result @@ -26,7 +26,7 @@ @error('teamA') - {{ $message }} + {{ $message }} @enderror @error('serving') - {{ $message }} + {{ $message }} @enderror @error('submit') - {{ $message }} + {{ $message }} @enderror
From c136ec9773a7a321d06934a8e6e57c9c101d7baa Mon Sep 17 00:00:00 2001 From: Giulio Troccoli-Allard Date: Sat, 9 May 2026 16:00:43 +0100 Subject: [PATCH 42/63] feat: show set-end confirmation modal with score and winning team Co-Authored-By: Claude Sonnet 4.6 --- app/Livewire/Game.php | 53 ++++++++++++++++++++++++- resources/views/livewire/game.blade.php | 39 ++++++++++++------ 2 files changed, 79 insertions(+), 13 deletions(-) diff --git a/app/Livewire/Game.php b/app/Livewire/Game.php index a1e493d..c5be9f9 100644 --- a/app/Livewire/Game.php +++ b/app/Livewire/Game.php @@ -5,7 +5,11 @@ namespace App\Livewire; use App\Data\GameState\GameState; +use App\Enums\TeamAB; +use App\Enums\TeamSide; use App\Models\Game as GameModel; +use App\Services\GameSideResolver; +use Flux\Flux; use Illuminate\Contracts\View\View; use Livewire\Attributes\On; use Livewire\Component; @@ -16,6 +20,14 @@ class Game extends Component public GameState $gameState; + public ?int $justEndedSetNumber = null; + + public ?string $setWinnerCode = null; + + public ?int $finalScoreWinner = null; + + public ?int $finalScoreLoser = null; + public function mount(GameModel $game): void { $this->gameId = $game->getKey(); @@ -25,12 +37,51 @@ public function mount(GameModel $game): void #[On('game-event-recorded')] public function synchronizeGameContext(): void { + $wasSetInProgress = isset($this->gameState) && $this->gameState->setInProgress; + $previousSetNumber = isset($this->gameState) ? $this->gameState->setNumber : 0; + $previousSetsWonTeamA = isset($this->gameState) ? $this->gameState->setsWonTeamA : 0; + $previousScoreTeamA = isset($this->gameState) ? $this->gameState->scoreTeamA : 0; + $previousScoreTeamB = isset($this->gameState) ? $this->gameState->scoreTeamB : 0; + $game = GameModel::query()->findSole($this->gameId); $this->gameState = $game->stateAt(); + + if ($wasSetInProgress && ! $this->gameState->setInProgress && ! $this->gameState->gameEnded) { + $winnerIsTeamA = $this->gameState->setsWonTeamA > $previousSetsWonTeamA; + $winnerTeam = $winnerIsTeamA ? TeamAB::TeamA : TeamAB::TeamB; + $this->justEndedSetNumber = $previousSetNumber; + $this->setWinnerCode = $this->countryCodeForTeam($game, $winnerTeam); + $this->finalScoreWinner = $winnerIsTeamA ? $previousScoreTeamA : $previousScoreTeamB; + $this->finalScoreLoser = $winnerIsTeamA ? $previousScoreTeamB : $previousScoreTeamA; + Flux::modal('set-ended')->show(); + } + } + + public function acknowledgeSetEnd(): void + { + $this->justEndedSetNumber = null; + $this->setWinnerCode = null; + $this->finalScoreWinner = null; + $this->finalScoreLoser = null; + Flux::modal('set-ended')->close(); } public function render(): View { - return view('livewire.game'); + return view('livewire.game', [ + 'justEndedSetNumber' => $this->justEndedSetNumber, + 'setWinnerCode' => $this->setWinnerCode, + 'finalScoreWinner' => $this->finalScoreWinner, + 'finalScoreLoser' => $this->finalScoreLoser, + ]); + } + + private function countryCodeForTeam(GameModel $game, TeamAB $team): string + { + $resolver = app(GameSideResolver::class); + + return $resolver->sideForTeam($game, $team) === TeamSide::Home + ? $game->homeTeam->country_code + : $game->awayTeam->country_code; } } diff --git a/resources/views/livewire/game.blade.php b/resources/views/livewire/game.blade.php index 77d95f5..e2a64b7 100644 --- a/resources/views/livewire/game.blade.php +++ b/resources/views/livewire/game.blade.php @@ -1,12 +1,27 @@ -
-
- - - - - -
-
+
+
+
+ + + + + +
+
+ + +
+ Set {{ $justEndedSetNumber }} finished + + {{ $setWinnerCode }} won + {{ $finalScoreWinner }} – {{ $finalScoreLoser }} + +
+ Continue to next set +
+
+
+
From 84917454f38575d840fc4cb18f6da40757ed0c3e Mon Sep 17 00:00:00 2001 From: Giulio Troccoli-Allard Date: Sat, 9 May 2026 16:02:55 +0100 Subject: [PATCH 43/63] chore: update packages --- composer.lock | 486 ++++++++++++++++++++++++++++++++++------------ package-lock.json | 382 ++++++++++++++++++------------------ 2 files changed, 552 insertions(+), 316 deletions(-) diff --git a/composer.lock b/composer.lock index 35c77d0..711ceb2 100644 --- a/composer.lock +++ b/composer.lock @@ -1055,16 +1055,16 @@ }, { "name": "laravel/framework", - "version": "v13.6.0", + "version": "v13.8.0", "source": { "type": "git", "url": "https://github.com/laravel/framework.git", - "reference": "416a93ea9c53161e0d4b8a44045f447b65a7d2f1" + "reference": "e7db333a025a1e93ebca7744953069d7719f4bcf" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/framework/zipball/416a93ea9c53161e0d4b8a44045f447b65a7d2f1", - "reference": "416a93ea9c53161e0d4b8a44045f447b65a7d2f1", + "url": "https://api.github.com/repos/laravel/framework/zipball/e7db333a025a1e93ebca7744953069d7719f4bcf", + "reference": "e7db333a025a1e93ebca7744953069d7719f4bcf", "shasum": "" }, "require": { @@ -1105,8 +1105,9 @@ "symfony/http-kernel": "^7.4.0 || ^8.0.0", "symfony/mailer": "^7.4.0 || ^8.0.0", "symfony/mime": "^7.4.0 || ^8.0.0", - "symfony/polyfill-php84": "^1.33", - "symfony/polyfill-php85": "^1.33", + "symfony/polyfill-php84": "^1.36", + "symfony/polyfill-php85": "^1.36", + "symfony/polyfill-php86": "^1.36", "symfony/process": "^7.4.5 || ^8.0.5", "symfony/routing": "^7.4.0 || ^8.0.0", "symfony/uid": "^7.4.0 || ^8.0.0", @@ -1274,7 +1275,7 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2026-04-21T13:32:11+00:00" + "time": "2026-05-05T21:01:14+00:00" }, { "name": "laravel/prompts", @@ -1337,16 +1338,16 @@ }, { "name": "laravel/serializable-closure", - "version": "v2.0.12", + "version": "v2.0.13", "source": { "type": "git", "url": "https://github.com/laravel/serializable-closure.git", - "reference": "a6abb4e54f6fcd3138120b9ad497f0bd146f9919" + "reference": "b566ee0dd251f3c4078bed003a7ce015f5ea6dce" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/a6abb4e54f6fcd3138120b9ad497f0bd146f9919", - "reference": "a6abb4e54f6fcd3138120b9ad497f0bd146f9919", + "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/b566ee0dd251f3c4078bed003a7ce015f5ea6dce", + "reference": "b566ee0dd251f3c4078bed003a7ce015f5ea6dce", "shasum": "" }, "require": { @@ -1394,7 +1395,7 @@ "issues": "https://github.com/laravel/serializable-closure/issues", "source": "https://github.com/laravel/serializable-closure" }, - "time": "2026-04-14T13:33:34+00:00" + "time": "2026-04-16T14:03:50+00:00" }, { "name": "laravel/tinker", @@ -2165,16 +2166,16 @@ }, { "name": "livewire/livewire", - "version": "v4.2.4", + "version": "v4.3.0", "source": { "type": "git", "url": "https://github.com/livewire/livewire.git", - "reference": "7d0bfa46269b1ec186b8cdd38baffee5cc647d10" + "reference": "19ebb1ee4d057debceccf70ff01950e6a6114edc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/livewire/livewire/zipball/7d0bfa46269b1ec186b8cdd38baffee5cc647d10", - "reference": "7d0bfa46269b1ec186b8cdd38baffee5cc647d10", + "url": "https://api.github.com/repos/livewire/livewire/zipball/19ebb1ee4d057debceccf70ff01950e6a6114edc", + "reference": "19ebb1ee4d057debceccf70ff01950e6a6114edc", "shasum": "" }, "require": { @@ -2229,7 +2230,7 @@ "description": "A front-end framework for Laravel.", "support": { "issues": "https://github.com/livewire/livewire/issues", - "source": "https://github.com/livewire/livewire/tree/v4.2.4" + "source": "https://github.com/livewire/livewire/tree/v4.3.0" }, "funding": [ { @@ -2237,7 +2238,7 @@ "type": "github" } ], - "time": "2026-04-02T20:48:35+00:00" + "time": "2026-05-01T00:46:07+00:00" }, { "name": "monolog/monolog", @@ -3711,16 +3712,16 @@ }, { "name": "symfony/console", - "version": "v8.0.8", + "version": "v8.0.9", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "5b66d385dc58f69652e56f78a4184615e3f2b7f7" + "reference": "7113778e2e91f4709cb3194a75dfa9c0d028d94d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/5b66d385dc58f69652e56f78a4184615e3f2b7f7", - "reference": "5b66d385dc58f69652e56f78a4184615e3f2b7f7", + "url": "https://api.github.com/repos/symfony/console/zipball/7113778e2e91f4709cb3194a75dfa9c0d028d94d", + "reference": "7113778e2e91f4709cb3194a75dfa9c0d028d94d", "shasum": "" }, "require": { @@ -3777,7 +3778,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v8.0.8" + "source": "https://github.com/symfony/console/tree/v8.0.9" }, "funding": [ { @@ -3797,20 +3798,20 @@ "type": "tidelift" } ], - "time": "2026-03-30T15:14:47+00:00" + "time": "2026-04-29T15:02:55+00:00" }, { "name": "symfony/css-selector", - "version": "v8.0.8", + "version": "v8.0.9", "source": { "type": "git", "url": "https://github.com/symfony/css-selector.git", - "reference": "8db1c00226a94d8ab6aa89d9224eeee91e2ea2ed" + "reference": "3665cfade90565430909b906394c73c8739e57d0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/css-selector/zipball/8db1c00226a94d8ab6aa89d9224eeee91e2ea2ed", - "reference": "8db1c00226a94d8ab6aa89d9224eeee91e2ea2ed", + "url": "https://api.github.com/repos/symfony/css-selector/zipball/3665cfade90565430909b906394c73c8739e57d0", + "reference": "3665cfade90565430909b906394c73c8739e57d0", "shasum": "" }, "require": { @@ -3846,7 +3847,7 @@ "description": "Converts CSS selectors to XPath expressions", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/css-selector/tree/v8.0.8" + "source": "https://github.com/symfony/css-selector/tree/v8.0.9" }, "funding": [ { @@ -3866,20 +3867,20 @@ "type": "tidelift" } ], - "time": "2026-03-30T15:14:47+00:00" + "time": "2026-04-18T13:51:42+00:00" }, { "name": "symfony/deprecation-contracts", - "version": "v3.6.0", + "version": "v3.7.0", "source": { "type": "git", "url": "https://github.com/symfony/deprecation-contracts.git", - "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62" + "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/63afe740e99a13ba87ec199bb07bbdee937a5b62", - "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/50f59d1f3ca46d41ac911f97a78626b6756af35b", + "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b", "shasum": "" }, "require": { @@ -3892,7 +3893,7 @@ "name": "symfony/contracts" }, "branch-alias": { - "dev-main": "3.6-dev" + "dev-main": "3.7-dev" } }, "autoload": { @@ -3917,7 +3918,7 @@ "description": "A generic function and convention to trigger deprecation notices", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/deprecation-contracts/tree/v3.6.0" + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.0" }, "funding": [ { @@ -3928,12 +3929,16 @@ "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": "2024-09-25T14:21:43+00:00" + "time": "2026-04-13T15:52:40+00:00" }, { "name": "symfony/error-handler", @@ -4018,16 +4023,16 @@ }, { "name": "symfony/event-dispatcher", - "version": "v8.0.8", + "version": "v8.0.9", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher.git", - "reference": "f662acc6ab22a3d6d716dcb44c381c6002940df6" + "reference": "0c3c1a17604c4dbbec4b93fe162c538482096e1f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/f662acc6ab22a3d6d716dcb44c381c6002940df6", - "reference": "f662acc6ab22a3d6d716dcb44c381c6002940df6", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/0c3c1a17604c4dbbec4b93fe162c538482096e1f", + "reference": "0c3c1a17604c4dbbec4b93fe162c538482096e1f", "shasum": "" }, "require": { @@ -4079,7 +4084,7 @@ "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/event-dispatcher/tree/v8.0.8" + "source": "https://github.com/symfony/event-dispatcher/tree/v8.0.9" }, "funding": [ { @@ -4099,20 +4104,20 @@ "type": "tidelift" } ], - "time": "2026-03-30T15:14:47+00:00" + "time": "2026-04-18T13:51:42+00:00" }, { "name": "symfony/event-dispatcher-contracts", - "version": "v3.6.0", + "version": "v3.7.0", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher-contracts.git", - "reference": "59eb412e93815df44f05f342958efa9f46b1e586" + "reference": "ccba7060602b7fed0b03c85bf025257f76d9ef32" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/59eb412e93815df44f05f342958efa9f46b1e586", - "reference": "59eb412e93815df44f05f342958efa9f46b1e586", + "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/ccba7060602b7fed0b03c85bf025257f76d9ef32", + "reference": "ccba7060602b7fed0b03c85bf025257f76d9ef32", "shasum": "" }, "require": { @@ -4126,7 +4131,7 @@ "name": "symfony/contracts" }, "branch-alias": { - "dev-main": "3.6-dev" + "dev-main": "3.7-dev" } }, "autoload": { @@ -4159,7 +4164,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.6.0" + "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.7.0" }, "funding": [ { @@ -4170,12 +4175,16 @@ "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": "2024-09-25T14:21:43+00:00" + "time": "2026-01-05T13:30:16+00:00" }, { "name": "symfony/finder", @@ -4327,16 +4336,16 @@ }, { "name": "symfony/http-kernel", - "version": "v8.0.8", + "version": "v8.0.10", "source": { "type": "git", "url": "https://github.com/symfony/http-kernel.git", - "reference": "1770f6818d83b2fddc12185025b93f39a90cb628" + "reference": "fb3f65b3d4ca2dad31c80d323819a762ca31d6ac" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-kernel/zipball/1770f6818d83b2fddc12185025b93f39a90cb628", - "reference": "1770f6818d83b2fddc12185025b93f39a90cb628", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/fb3f65b3d4ca2dad31c80d323819a762ca31d6ac", + "reference": "fb3f65b3d4ca2dad31c80d323819a762ca31d6ac", "shasum": "" }, "require": { @@ -4407,7 +4416,7 @@ "description": "Provides a structured process for converting a Request into a Response", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-kernel/tree/v8.0.8" + "source": "https://github.com/symfony/http-kernel/tree/v8.0.10" }, "funding": [ { @@ -4427,7 +4436,7 @@ "type": "tidelift" } ], - "time": "2026-03-31T21:14:05+00:00" + "time": "2026-05-06T12:27:31+00:00" }, { "name": "symfony/mailer", @@ -4511,16 +4520,16 @@ }, { "name": "symfony/mime", - "version": "v8.0.8", + "version": "v8.0.9", "source": { "type": "git", "url": "https://github.com/symfony/mime.git", - "reference": "ddff21f14c7ce04b98101b399a9463dce8b0ce66" + "reference": "a9fcb293650c054b62a5b406f4e92e7b711ea333" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mime/zipball/ddff21f14c7ce04b98101b399a9463dce8b0ce66", - "reference": "ddff21f14c7ce04b98101b399a9463dce8b0ce66", + "url": "https://api.github.com/repos/symfony/mime/zipball/a9fcb293650c054b62a5b406f4e92e7b711ea333", + "reference": "a9fcb293650c054b62a5b406f4e92e7b711ea333", "shasum": "" }, "require": { @@ -4573,7 +4582,7 @@ "mime-type" ], "support": { - "source": "https://github.com/symfony/mime/tree/v8.0.8" + "source": "https://github.com/symfony/mime/tree/v8.0.9" }, "funding": [ { @@ -4593,7 +4602,7 @@ "type": "tidelift" } ], - "time": "2026-03-30T15:14:47+00:00" + "time": "2026-04-29T15:02:55+00:00" }, { "name": "symfony/polyfill-ctype", @@ -5261,6 +5270,86 @@ ], "time": "2026-04-26T13:10:57+00:00" }, + { + "name": "symfony/polyfill-php86", + "version": "v1.37.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php86.git", + "reference": "33d8fc5a705481e21fe3a81212b26f9b1f61749c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php86/zipball/33d8fc5a705481e21fe3a81212b26f9b1f61749c", + "reference": "33d8fc5a705481e21fe3a81212b26f9b1f61749c", + "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\\Php86\\": "" + }, + "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.6+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php86/tree/v1.37.0" + }, + "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-04-26T13:13:48+00:00" + }, { "name": "symfony/polyfill-uuid", "version": "v1.37.0", @@ -5411,16 +5500,16 @@ }, { "name": "symfony/routing", - "version": "v8.0.8", + "version": "v8.0.9", "source": { "type": "git", "url": "https://github.com/symfony/routing.git", - "reference": "0de330ec2ea922a7b08ec45615bd51179de7fda4" + "reference": "75d1bd8e5da3424e4db2fc3ff0222cb4d0c73038" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/routing/zipball/0de330ec2ea922a7b08ec45615bd51179de7fda4", - "reference": "0de330ec2ea922a7b08ec45615bd51179de7fda4", + "url": "https://api.github.com/repos/symfony/routing/zipball/75d1bd8e5da3424e4db2fc3ff0222cb4d0c73038", + "reference": "75d1bd8e5da3424e4db2fc3ff0222cb4d0c73038", "shasum": "" }, "require": { @@ -5467,7 +5556,7 @@ "url" ], "support": { - "source": "https://github.com/symfony/routing/tree/v8.0.8" + "source": "https://github.com/symfony/routing/tree/v8.0.9" }, "funding": [ { @@ -5487,20 +5576,20 @@ "type": "tidelift" } ], - "time": "2026-03-30T15:14:47+00:00" + "time": "2026-04-29T15:02:55+00:00" }, { "name": "symfony/service-contracts", - "version": "v3.6.1", + "version": "v3.7.0", "source": { "type": "git", "url": "https://github.com/symfony/service-contracts.git", - "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43" + "reference": "d25d82433a80eba6aa0e6c24b61d7370d99e444a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/service-contracts/zipball/45112560a3ba2d715666a509a0bc9521d10b6c43", - "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/d25d82433a80eba6aa0e6c24b61d7370d99e444a", + "reference": "d25d82433a80eba6aa0e6c24b61d7370d99e444a", "shasum": "" }, "require": { @@ -5518,7 +5607,7 @@ "name": "symfony/contracts" }, "branch-alias": { - "dev-main": "3.6-dev" + "dev-main": "3.7-dev" } }, "autoload": { @@ -5554,7 +5643,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/service-contracts/tree/v3.6.1" + "source": "https://github.com/symfony/service-contracts/tree/v3.7.0" }, "funding": [ { @@ -5574,7 +5663,7 @@ "type": "tidelift" } ], - "time": "2025-07-15T11:30:57+00:00" + "time": "2026-03-28T09:44:51+00:00" }, { "name": "symfony/string", @@ -5668,16 +5757,16 @@ }, { "name": "symfony/translation", - "version": "v8.0.8", + "version": "v8.0.10", "source": { "type": "git", "url": "https://github.com/symfony/translation.git", - "reference": "27c03ae3940de24ba2f71cfdbac824f2aa1fdf2f" + "reference": "f63e9342e12646a57c91ef8a366a4f9d8e557b67" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/translation/zipball/27c03ae3940de24ba2f71cfdbac824f2aa1fdf2f", - "reference": "27c03ae3940de24ba2f71cfdbac824f2aa1fdf2f", + "url": "https://api.github.com/repos/symfony/translation/zipball/f63e9342e12646a57c91ef8a366a4f9d8e557b67", + "reference": "f63e9342e12646a57c91ef8a366a4f9d8e557b67", "shasum": "" }, "require": { @@ -5737,7 +5826,7 @@ "description": "Provides tools to internationalize your application", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/translation/tree/v8.0.8" + "source": "https://github.com/symfony/translation/tree/v8.0.10" }, "funding": [ { @@ -5757,20 +5846,20 @@ "type": "tidelift" } ], - "time": "2026-03-30T15:14:47+00:00" + "time": "2026-05-06T11:30:54+00:00" }, { "name": "symfony/translation-contracts", - "version": "v3.6.1", + "version": "v3.7.0", "source": { "type": "git", "url": "https://github.com/symfony/translation-contracts.git", - "reference": "65a8bc82080447fae78373aa10f8d13b38338977" + "reference": "0ab302977a952b42fd51475c4ebac81f8da0a95d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/65a8bc82080447fae78373aa10f8d13b38338977", - "reference": "65a8bc82080447fae78373aa10f8d13b38338977", + "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/0ab302977a952b42fd51475c4ebac81f8da0a95d", + "reference": "0ab302977a952b42fd51475c4ebac81f8da0a95d", "shasum": "" }, "require": { @@ -5783,7 +5872,7 @@ "name": "symfony/contracts" }, "branch-alias": { - "dev-main": "3.6-dev" + "dev-main": "3.7-dev" } }, "autoload": { @@ -5819,7 +5908,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/translation-contracts/tree/v3.6.1" + "source": "https://github.com/symfony/translation-contracts/tree/v3.7.0" }, "funding": [ { @@ -5839,20 +5928,20 @@ "type": "tidelift" } ], - "time": "2025-07-15T13:41:35+00:00" + "time": "2026-01-05T13:30:16+00:00" }, { "name": "symfony/uid", - "version": "v8.0.8", + "version": "v8.0.9", "source": { "type": "git", "url": "https://github.com/symfony/uid.git", - "reference": "f63fa6096a24147283bce4d29327d285326438e0" + "reference": "4d9d6510bbe88ebb4608b7200d18606cdf80825c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/uid/zipball/f63fa6096a24147283bce4d29327d285326438e0", - "reference": "f63fa6096a24147283bce4d29327d285326438e0", + "url": "https://api.github.com/repos/symfony/uid/zipball/4d9d6510bbe88ebb4608b7200d18606cdf80825c", + "reference": "4d9d6510bbe88ebb4608b7200d18606cdf80825c", "shasum": "" }, "require": { @@ -5897,7 +5986,7 @@ "uuid" ], "support": { - "source": "https://github.com/symfony/uid/tree/v8.0.8" + "source": "https://github.com/symfony/uid/tree/v8.0.9" }, "funding": [ { @@ -5917,7 +6006,7 @@ "type": "tidelift" } ], - "time": "2026-03-30T15:14:47+00:00" + "time": "2026-04-30T16:10:06+00:00" }, { "name": "symfony/var-dumper", @@ -6314,6 +6403,151 @@ ], "time": "2026-03-29T15:46:14+00:00" }, + { + "name": "composer/pcre", + "version": "3.3.2", + "source": { + "type": "git", + "url": "https://github.com/composer/pcre.git", + "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/pcre/zipball/b2bed4734f0cc156ee1fe9c0da2550420d99a21e", + "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0" + }, + "conflict": { + "phpstan/phpstan": "<1.11.10" + }, + "require-dev": { + "phpstan/phpstan": "^1.12 || ^2", + "phpstan/phpstan-strict-rules": "^1 || ^2", + "phpunit/phpunit": "^8 || ^9" + }, + "type": "library", + "extra": { + "phpstan": { + "includes": [ + "extension.neon" + ] + }, + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Composer\\Pcre\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" + } + ], + "description": "PCRE wrapping library that offers type-safe preg_* replacements.", + "keywords": [ + "PCRE", + "preg", + "regex", + "regular expression" + ], + "support": { + "issues": "https://github.com/composer/pcre/issues", + "source": "https://github.com/composer/pcre/tree/3.3.2" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/composer/composer", + "type": "tidelift" + } + ], + "time": "2024-11-12T16:29:46+00:00" + }, + { + "name": "composer/xdebug-handler", + "version": "3.0.5", + "source": { + "type": "git", + "url": "https://github.com/composer/xdebug-handler.git", + "reference": "6c1925561632e83d60a44492e0b344cf48ab85ef" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/xdebug-handler/zipball/6c1925561632e83d60a44492e0b344cf48ab85ef", + "reference": "6c1925561632e83d60a44492e0b344cf48ab85ef", + "shasum": "" + }, + "require": { + "composer/pcre": "^1 || ^2 || ^3", + "php": "^7.2.5 || ^8.0", + "psr/log": "^1 || ^2 || ^3" + }, + "require-dev": { + "phpstan/phpstan": "^1.0", + "phpstan/phpstan-strict-rules": "^1.1", + "phpunit/phpunit": "^8.5 || ^9.6 || ^10.5" + }, + "type": "library", + "autoload": { + "psr-4": { + "Composer\\XdebugHandler\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "John Stevenson", + "email": "john-stevenson@blueyonder.co.uk" + } + ], + "description": "Restarts a process without Xdebug.", + "keywords": [ + "Xdebug", + "performance" + ], + "support": { + "irc": "ircs://irc.libera.chat:6697/composer", + "issues": "https://github.com/composer/xdebug-handler/issues", + "source": "https://github.com/composer/xdebug-handler/tree/3.0.5" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/composer/composer", + "type": "tidelift" + } + ], + "time": "2024-05-06T16:37:16+00:00" + }, { "name": "doctrine/deprecations", "version": "1.1.6", @@ -7185,16 +7419,16 @@ }, { "name": "laravel/sail", - "version": "v1.57.0", + "version": "v1.58.0", "source": { "type": "git", "url": "https://github.com/laravel/sail.git", - "reference": "fa8d057b6e9310380ccbc3a209ed7f927d54f648" + "reference": "2e5e968138ca52ed87d712449697a8364d73b466" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/sail/zipball/fa8d057b6e9310380ccbc3a209ed7f927d54f648", - "reference": "fa8d057b6e9310380ccbc3a209ed7f927d54f648", + "url": "https://api.github.com/repos/laravel/sail/zipball/2e5e968138ca52ed87d712449697a8364d73b466", + "reference": "2e5e968138ca52ed87d712449697a8364d73b466", "shasum": "" }, "require": { @@ -7244,7 +7478,7 @@ "issues": "https://github.com/laravel/sail/issues", "source": "https://github.com/laravel/sail" }, - "time": "2026-04-14T13:32:04+00:00" + "time": "2026-04-27T13:38:34+00:00" }, { "name": "mockery/mockery", @@ -7487,38 +7721,39 @@ }, { "name": "pestphp/pest", - "version": "v4.6.3", + "version": "v4.7.0", "source": { "type": "git", "url": "https://github.com/pestphp/pest.git", - "reference": "bff44562a99d30aa37573995566051b0344f9f8e" + "reference": "2fc75cfcf03c041c804778fa894282234adc3c66" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/pestphp/pest/zipball/bff44562a99d30aa37573995566051b0344f9f8e", - "reference": "bff44562a99d30aa37573995566051b0344f9f8e", + "url": "https://api.github.com/repos/pestphp/pest/zipball/2fc75cfcf03c041c804778fa894282234adc3c66", + "reference": "2fc75cfcf03c041c804778fa894282234adc3c66", "shasum": "" }, "require": { "brianium/paratest": "^7.20.0", - "nunomaduro/collision": "^8.9.3", + "composer/xdebug-handler": "^3.0.5", + "nunomaduro/collision": "^8.9.4", "nunomaduro/termwind": "^2.4.0", "pestphp/pest-plugin": "^4.0.0", "pestphp/pest-plugin-arch": "^4.0.2", "pestphp/pest-plugin-mutate": "^4.0.1", "pestphp/pest-plugin-profanity": "^4.2.1", "php": "^8.3.0", - "phpunit/phpunit": "^12.5.23", + "phpunit/phpunit": "^12.5.24", "symfony/process": "^7.4.8|^8.0.8" }, "conflict": { "filp/whoops": "<2.18.3", - "phpunit/phpunit": ">12.5.23", + "phpunit/phpunit": ">12.5.24", "sebastian/exporter": "<7.0.0", "webmozart/assert": "<1.11.0" }, "require-dev": { - "mrpunyapal/peststan": "^0.2.5", + "mrpunyapal/peststan": "^0.2.9", "pestphp/pest-dev-tools": "^4.1.0", "pestphp/pest-plugin-browser": "^4.3.1", "pestphp/pest-plugin-type-coverage": "^4.0.4", @@ -7549,6 +7784,7 @@ "Pest\\Plugins\\Verbose", "Pest\\Plugins\\Version", "Pest\\Plugins\\Shard", + "Pest\\Plugins\\Tia", "Pest\\Plugins\\Parallel" ] }, @@ -7588,7 +7824,7 @@ ], "support": { "issues": "https://github.com/pestphp/pest/issues", - "source": "https://github.com/pestphp/pest/tree/v4.6.3" + "source": "https://github.com/pestphp/pest/tree/v4.7.0" }, "funding": [ { @@ -7600,7 +7836,7 @@ "type": "github" } ], - "time": "2026-04-18T13:51:25+00:00" + "time": "2026-05-03T16:09:32+00:00" }, { "name": "pestphp/pest-plugin", @@ -8339,11 +8575,11 @@ }, { "name": "phpstan/phpstan", - "version": "2.1.53", + "version": "2.1.54", "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/ef67586798c003274797b288a68b221e4270dca7", - "reference": "ef67586798c003274797b288a68b221e4270dca7", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/8be50c3992107dc837b17da4d140fbbdf9a5c5bd", + "reference": "8be50c3992107dc837b17da4d140fbbdf9a5c5bd", "shasum": "" }, "require": { @@ -8388,7 +8624,7 @@ "type": "github" } ], - "time": "2026-04-28T16:09:00+00:00" + "time": "2026-04-29T13:31:09+00:00" }, { "name": "phpunit/php-code-coverage", @@ -8737,16 +8973,16 @@ }, { "name": "phpunit/phpunit", - "version": "12.5.23", + "version": "12.5.24", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "c54fcf3d6bcb6e96ac2f7e40097dc37b5f139969" + "reference": "d75dd30597caa80e72fad2ef7904601a30ef1046" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/c54fcf3d6bcb6e96ac2f7e40097dc37b5f139969", - "reference": "c54fcf3d6bcb6e96ac2f7e40097dc37b5f139969", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/d75dd30597caa80e72fad2ef7904601a30ef1046", + "reference": "d75dd30597caa80e72fad2ef7904601a30ef1046", "shasum": "" }, "require": { @@ -8815,7 +9051,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.23" + "source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.24" }, "funding": [ { @@ -8823,7 +9059,7 @@ "type": "other" } ], - "time": "2026-04-18T06:12:49+00:00" + "time": "2026-05-01T04:21:04+00:00" }, { "name": "rector/rector", @@ -9836,16 +10072,16 @@ }, { "name": "symfony/yaml", - "version": "v8.0.8", + "version": "v8.0.10", "source": { "type": "git", "url": "https://github.com/symfony/yaml.git", - "reference": "54174ab48c0c0f9e21512b304be17f8150ccf8f1" + "reference": "aa9ee60c41d9b20a2468c41ff0a32e2a7405ac05" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/yaml/zipball/54174ab48c0c0f9e21512b304be17f8150ccf8f1", - "reference": "54174ab48c0c0f9e21512b304be17f8150ccf8f1", + "url": "https://api.github.com/repos/symfony/yaml/zipball/aa9ee60c41d9b20a2468c41ff0a32e2a7405ac05", + "reference": "aa9ee60c41d9b20a2468c41ff0a32e2a7405ac05", "shasum": "" }, "require": { @@ -9887,7 +10123,7 @@ "description": "Loads and dumps YAML files", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/yaml/tree/v8.0.8" + "source": "https://github.com/symfony/yaml/tree/v8.0.10" }, "funding": [ { @@ -9907,7 +10143,7 @@ "type": "tidelift" } ], - "time": "2026-03-30T15:14:47+00:00" + "time": "2026-05-05T08:10:04+00:00" }, { "name": "ta-tikoma/phpunit-architecture-test", diff --git a/package-lock.json b/package-lock.json index cf1950b..87bfd92 100644 --- a/package-lock.json +++ b/package-lock.json @@ -506,9 +506,9 @@ } }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.2.tgz", - "integrity": "sha512-dnlp69efPPg6Uaw2dVqzWRfAWRnYVb1XJ8CyyhIbZeaq4CA5/mLeZ1IEt9QqQxmbdvagjLIm2ZL8BxXv5lH4Yw==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.3.tgz", + "integrity": "sha512-x35CNW/ANXG3hE/EZpRU8MXX1JDN86hBb2wMGAtltkz7pc6cxgjpy1OMMfDosOQ+2hWqIkag/fGok1Yady9nGw==", "cpu": [ "arm" ], @@ -520,9 +520,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.2.tgz", - "integrity": "sha512-OqZTwDRDchGRHHm/hwLOL7uVPB9aUvI0am/eQuWMNyFHf5PSEQmyEeYYheA0EPPKUO/l0uigCp+iaTjoLjVoHg==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.3.tgz", + "integrity": "sha512-xw3xtkDApIOGayehp2+Rz4zimfkaX65r4t47iy+ymQB2G4iJCBBfj0ogVg5jpvjpn8UWn/+q9tprxleYeNp3Hw==", "cpu": [ "arm64" ], @@ -534,9 +534,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.2.tgz", - "integrity": "sha512-UwRE7CGpvSVEQS8gUMBe1uADWjNnVgP3Iusyda1nSRwNDCsRjnGc7w6El6WLQsXmZTbLZx9cecegumcitNfpmA==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.3.tgz", + "integrity": "sha512-vo6Y5Qfpx7/5EaamIwi0WqW2+zfiusVihKatLvtN1VFVy3D13uERk/6gZLU1UiHRL6fDXqj/ELIeVRGnvcTE1g==", "cpu": [ "arm64" ], @@ -548,9 +548,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.2.tgz", - "integrity": "sha512-gjEtURKLCC5VXm1I+2i1u9OhxFsKAQJKTVB8WvDAHF+oZlq0GTVFOlTlO1q3AlCTE/DF32c16ESvfgqR7343/g==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.3.tgz", + "integrity": "sha512-D+0QGcZhBzTN82weOnsSlY7V7+RMmPuF1CkbxyMAGE8+ZHeUjyb76ZiWmBlCu//AQQONvxcqRbwZTajZKqjuOw==", "cpu": [ "x64" ], @@ -562,9 +562,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.2.tgz", - "integrity": "sha512-Bcl6CYDeAgE70cqZaMojOi/eK63h5Me97ZqAQoh77VPjMysA/4ORQBRGo3rRy45x4MzVlU9uZxs8Uwy7ZaKnBw==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.3.tgz", + "integrity": "sha512-6HnvHCT7fDyj6R0Ph7A6x8dQS/S38MClRWeDLqc0MdfWkxjiu1HSDYrdPhqSILzjTIC/pnXbbJbo+ft+gy/9hQ==", "cpu": [ "arm64" ], @@ -576,9 +576,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.2.tgz", - "integrity": "sha512-LU+TPda3mAE2QB0/Hp5VyeKJivpC6+tlOXd1VMoXV/YFMvk/MNk5iXeBfB4MQGRWyOYVJ01625vjkr0Az98OJQ==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.3.tgz", + "integrity": "sha512-KHLgC3WKlUYW3ShFKnnosZDOJ0xjg9zp7au3sIm2bs/tGBeC2ipmvRh/N7JKi0t9Ue20C0dpEshi8WUubg+cnA==", "cpu": [ "x64" ], @@ -590,9 +590,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.2.tgz", - "integrity": "sha512-2QxQrM+KQ7DAW4o22j+XZ6RKdxjLD7BOWTP0Bv0tmjdyhXSsr2Ul1oJDQqh9Zf5qOwTuTc7Ek83mOFaKnodPjg==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.3.tgz", + "integrity": "sha512-DV6fJoxEYWJOvaZIsok7KrYl0tPvga5OZ2yvKHNNYyk/2roMLqQAbGhr78EQ5YhHpnhLKJD3S1WFusAkmUuV5g==", "cpu": [ "arm" ], @@ -604,9 +604,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.2.tgz", - "integrity": "sha512-TbziEu2DVsTEOPif2mKWkMeDMLoYjx95oESa9fkQQK7r/Orta0gnkcDpzwufEcAO2BLBsD7mZkXGFqEdMRRwfw==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.3.tgz", + "integrity": "sha512-mQKoJAzvuOs6F+TZybQO4GOTSMUu7v0WdxEk24krQ/uUxXoPTtHjuaUuPmFhtBcM4K0ons8nrE3JyhTuCFtT/w==", "cpu": [ "arm" ], @@ -618,9 +618,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.2.tgz", - "integrity": "sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.3.tgz", + "integrity": "sha512-Whjj2qoiJ6+OOJMGptTYazaJvjOJm+iKHpXQM1P3LzGjt7Ff++Tp7nH4N8J/BUA7R9IHfDyx4DJIflifwnbmIA==", "cpu": [ "arm64" ], @@ -632,9 +632,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.2.tgz", - "integrity": "sha512-hr26p7e93Rl0Za+JwW7EAnwAvKkehh12BU1Llm9Ykiibg4uIr2rbpxG9WCf56GuvidlTG9KiiQT/TXT1yAWxTA==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.3.tgz", + "integrity": "sha512-4YTNHKqGng5+yiZt3mg77nmyuCfmNfX4fPmyUapBcIk+BdwSwmCWGXOUxhXbBEkFHtoN5boLj/5NON+u5QC9tg==", "cpu": [ "arm64" ], @@ -646,9 +646,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.2.tgz", - "integrity": "sha512-pOjB/uSIyDt+ow3k/RcLvUAOGpysT2phDn7TTUB3n75SlIgZzM6NKAqlErPhoFU+npgY3/n+2HYIQVbF70P9/A==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.3.tgz", + "integrity": "sha512-SU3kNlhkpI4UqlUc2VXPGK9o886ZsSeGfMAX2ba2b8DKmMXq4AL7KUrkSWVbb7koVqx41Yczx6dx5PNargIrEA==", "cpu": [ "loong64" ], @@ -660,9 +660,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.2.tgz", - "integrity": "sha512-2/w+q8jszv9Ww1c+6uJT3OwqhdmGP2/4T17cu8WuwyUuuaCDDJ2ojdyYwZzCxx0GcsZBhzi3HmH+J5pZNXnd+Q==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.3.tgz", + "integrity": "sha512-6lDLl5h4TXpB1mTf2rQWnAk/LcXrx9vBfu/DT5TIPhvMhRWaZ5MxkIc8u4lJAmBo6klTe1ywXIUHFjylW505sg==", "cpu": [ "loong64" ], @@ -674,9 +674,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.2.tgz", - "integrity": "sha512-11+aL5vKheYgczxtPVVRhdptAM2H7fcDR5Gw4/bTcteuZBlH4oP9f5s9zYO9aGZvoGeBpqXI/9TZZihZ609wKw==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.3.tgz", + "integrity": "sha512-BMo8bOw8evlup/8G+cj5xWtPyp93xPdyoSN16Zy90Q2QZ0ZYRhCt6ZJSwbrRzG9HApFabjwj2p25TUPDWrhzqQ==", "cpu": [ "ppc64" ], @@ -688,9 +688,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.2.tgz", - "integrity": "sha512-i16fokAGK46IVZuV8LIIwMdtqhin9hfYkCh8pf8iC3QU3LpwL+1FSFGej+O7l3E/AoknL6Dclh2oTdnRMpTzFQ==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.3.tgz", + "integrity": "sha512-E0L8X1dZN1/Rph+5VPF6Xj2G7JJvMACVXtamTJIDrVI44Y3K+G8gQaMEAavbqCGTa16InptiVrX6eM6pmJ+7qA==", "cpu": [ "ppc64" ], @@ -702,9 +702,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.2.tgz", - "integrity": "sha512-49FkKS6RGQoriDSK/6E2GkAsAuU5kETFCh7pG4yD/ylj9rKhTmO3elsnmBvRD4PgJPds5W2PkhC82aVwmUcJ7A==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.3.tgz", + "integrity": "sha512-oZJ/WHaVfHUiRAtmTAeo3DcevNsVvH8mbvodjZy7D5QKvCefO371SiKRpxoDcCxB3PTRTLayWBkvmDQKTcX/sw==", "cpu": [ "riscv64" ], @@ -716,9 +716,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.2.tgz", - "integrity": "sha512-mjYNkHPfGpUR00DuM1ZZIgs64Hpf4bWcz9Z41+4Q+pgDx73UwWdAYyf6EG/lRFldmdHHzgrYyge5akFUW0D3mQ==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.3.tgz", + "integrity": "sha512-Dhbyh7j9FybM3YaTgaHmVALwA8AkUwTPccyCQ79TG9AJUsMQqgN1DDEZNr4+QUfwiWvLDumW5vdwzoeUF+TNxQ==", "cpu": [ "riscv64" ], @@ -730,9 +730,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.2.tgz", - "integrity": "sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.3.tgz", + "integrity": "sha512-cJd1X5XhHHlltkaypz1UcWLA8AcoIi1aWhsvaWDskD1oz2eKCypnqvTQ8ykMNI0RSmm7NkTdSqSSD7zM0xa6Ig==", "cpu": [ "s390x" ], @@ -744,9 +744,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.2.tgz", - "integrity": "sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.3.tgz", + "integrity": "sha512-DAZDBHQfG2oQuhY7mc6I3/qB4LU2fQCjRvxbDwd/Jdvb9fypP4IJ4qmtu6lNjes6B531AI8cg1aKC2di97bUxA==", "cpu": [ "x64" ], @@ -758,9 +758,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.2.tgz", - "integrity": "sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.3.tgz", + "integrity": "sha512-cRxsE8c13mZOh3vP+wLDxpQBRrOHDIGOWyDL93Sy0Ga8y515fBcC2pjUfFwUe5T7tqvTvWbCpg1URM/AXdWIXA==", "cpu": [ "x64" ], @@ -772,9 +772,9 @@ ] }, "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.2.tgz", - "integrity": "sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.3.tgz", + "integrity": "sha512-QaWcIgRxqEdQdhJqW4DJctsH6HCmo5vHxY0krHSX4jMtOqfzC+dqDGuHM87bu4H8JBeibWx7jFz+h6/4C8wA5Q==", "cpu": [ "x64" ], @@ -786,9 +786,9 @@ ] }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.2.tgz", - "integrity": "sha512-NetAg5iO2uN7eB8zE5qrZ3CSil+7IJt4WDFLcC75Ymywq1VZVD6qJ6EvNLjZ3rEm6gB7XW5JdT60c6MN35Z85Q==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.3.tgz", + "integrity": "sha512-AaXwSvUi3QIPtroAUw1t5yHGIyqKEXwH54WUocFolZhpGDruJcs8c+xPNDRn4XiQsS7MEwnYsHW2l0MBLDMkWg==", "cpu": [ "arm64" ], @@ -800,9 +800,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.2.tgz", - "integrity": "sha512-NCYhOotpgWZ5kdxCZsv6Iudx0wX8980Q/oW4pNFNihpBKsDbEA1zpkfxJGC0yugsUuyDZ7gL37dbzwhR0VI7pQ==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.3.tgz", + "integrity": "sha512-65LAKM/bAWDqKNEelHlcHvm2V+Vfb8C6INFxQXRHCvaVN1rJfwr4NvdP4FyzUaLqWfaCGaadf6UbTm8xJeYfEg==", "cpu": [ "arm64" ], @@ -814,9 +814,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.2.tgz", - "integrity": "sha512-RXsaOqXxfoUBQoOgvmmijVxJnW2IGB0eoMO7F8FAjaj0UTywUO/luSqimWBJn04WNgUkeNhh7fs7pESXajWmkg==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.3.tgz", + "integrity": "sha512-EEM2gyhBF5MFnI6vMKdX1LAosE627RGBzIoGMdLloPZkXrUN0Ckqgr2Qi8+J3zip/8NVVro3/FjB+tjhZUgUHA==", "cpu": [ "ia32" ], @@ -828,9 +828,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.2.tgz", - "integrity": "sha512-qdAzEULD+/hzObedtmV6iBpdL5TIbKVztGiK7O3/KYSf+HIzU257+MX1EXJcyIiDbMAqmbwaufcYPvyRryeZtA==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.3.tgz", + "integrity": "sha512-E5Eb5H/DpxaoXH++Qkv28RcUJboMopmdDUALBczvHMf7hNIxaDZqwY5lK12UK1BHacSmvupoEWGu+n993Z0y1A==", "cpu": [ "x64" ], @@ -842,9 +842,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.2.tgz", - "integrity": "sha512-Nd/SgG27WoA9e+/TdK74KnHz852TLa94ovOYySo/yMPuTmpckK/jIF2jSwS3g7ELSKXK13/cVdmg1Z/DaCWKxA==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.3.tgz", + "integrity": "sha512-hPt/bgL5cE+Qp+/TPHBqptcAgPzgj46mPcg/16zNUmbQk0j+mOEQV/+Lqu8QRtDV3Ek95Q6FeFITpuhl6OTsAA==", "cpu": [ "x64" ], @@ -856,49 +856,49 @@ ] }, "node_modules/@tailwindcss/node": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.4.tgz", - "integrity": "sha512-Ai7+yQPxz3ddrDQzFfBKdHEVBg0w3Zl83jnjuwxnZOsnH9pGn93QHQtpU0p/8rYWxvbFZHneni6p1BSLK4DkGA==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.0.tgz", + "integrity": "sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g==", "dev": true, "license": "MIT", "dependencies": { "@jridgewell/remapping": "^2.3.5", - "enhanced-resolve": "^5.19.0", + "enhanced-resolve": "^5.21.0", "jiti": "^2.6.1", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", - "tailwindcss": "4.2.4" + "tailwindcss": "4.3.0" } }, "node_modules/@tailwindcss/oxide": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.4.tgz", - "integrity": "sha512-9El/iI069DKDSXwTvB9J4BwdO5JhRrOweGaK25taBAvBXyXqJAX+Jqdvs8r8gKpsI/1m0LeJLyQYTf/WLrBT1Q==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.0.tgz", + "integrity": "sha512-F7HZGBeN9I0/AuuJS5PwcD8xayx5ri5GhjYUDBEVYUkexyA/giwbDNjRVrxSezE3T250OU2K/wp/ltWx3UOefg==", "dev": true, "license": "MIT", "engines": { "node": ">= 20" }, "optionalDependencies": { - "@tailwindcss/oxide-android-arm64": "4.2.4", - "@tailwindcss/oxide-darwin-arm64": "4.2.4", - "@tailwindcss/oxide-darwin-x64": "4.2.4", - "@tailwindcss/oxide-freebsd-x64": "4.2.4", - "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.4", - "@tailwindcss/oxide-linux-arm64-gnu": "4.2.4", - "@tailwindcss/oxide-linux-arm64-musl": "4.2.4", - "@tailwindcss/oxide-linux-x64-gnu": "4.2.4", - "@tailwindcss/oxide-linux-x64-musl": "4.2.4", - "@tailwindcss/oxide-wasm32-wasi": "4.2.4", - "@tailwindcss/oxide-win32-arm64-msvc": "4.2.4", - "@tailwindcss/oxide-win32-x64-msvc": "4.2.4" + "@tailwindcss/oxide-android-arm64": "4.3.0", + "@tailwindcss/oxide-darwin-arm64": "4.3.0", + "@tailwindcss/oxide-darwin-x64": "4.3.0", + "@tailwindcss/oxide-freebsd-x64": "4.3.0", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.0", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.0", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.0", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.0", + "@tailwindcss/oxide-linux-x64-musl": "4.3.0", + "@tailwindcss/oxide-wasm32-wasi": "4.3.0", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.0", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.0" } }, "node_modules/@tailwindcss/oxide-android-arm64": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.4.tgz", - "integrity": "sha512-e7MOr1SAn9U8KlZzPi1ZXGZHeC5anY36qjNwmZv9pOJ8E4Q6jmD1vyEHkQFmNOIN7twGPEMXRHmitN4zCMN03g==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.0.tgz", + "integrity": "sha512-TJPiq67tKlLuObP6RkwvVGDoxCMBVtDgKkLfa/uyj7/FyxvQwHS+UOnVrXXgbEsfUaMgiVvC4KbJnRr26ho4Ng==", "cpu": [ "arm64" ], @@ -913,9 +913,9 @@ } }, "node_modules/@tailwindcss/oxide-darwin-arm64": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.4.tgz", - "integrity": "sha512-tSC/Kbqpz/5/o/C2sG7QvOxAKqyd10bq+ypZNf+9Fi2TvbVbv1zNpcEptcsU7DPROaSbVgUXmrzKhurFvo5eDg==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.0.tgz", + "integrity": "sha512-oMN/WZRb+SO37BmUElEgeEWuU8E/HXRkiODxJxLe1UTHVXLrdVSgfaJV7pSlhRGMSOiXLuxTIjfsF3wYvz8cgQ==", "cpu": [ "arm64" ], @@ -930,9 +930,9 @@ } }, "node_modules/@tailwindcss/oxide-darwin-x64": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.4.tgz", - "integrity": "sha512-yPyUXn3yO/ufR6+Kzv0t4fCg2qNr90jxXc5QqBpjlPNd0NqyDXcmQb/6weunH/MEDXW5dhyEi+agTDiqa3WsGg==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.0.tgz", + "integrity": "sha512-N6CUmu4a6bKVADfw77p+iw6Yd9Q3OBhe0veaDX+QazfuVYlQsHfDgxBrsjQ/IW+zywL8mTrNd0SdJT/zgtvMdA==", "cpu": [ "x64" ], @@ -947,9 +947,9 @@ } }, "node_modules/@tailwindcss/oxide-freebsd-x64": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.4.tgz", - "integrity": "sha512-BoMIB4vMQtZsXdGLVc2z+P9DbETkiopogfWZKbWwM8b/1Vinbs4YcUwo+kM/KeLkX3Ygrf4/PsRndKaYhS8Eiw==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.0.tgz", + "integrity": "sha512-zDL5hBkQdH5C6MpqbK3gQAgP80tsMwSI26vjOzjJtNCMUo0lFgOItzHKBIupOZNQxt3ouPH7RPhvNhiTfCe5CQ==", "cpu": [ "x64" ], @@ -964,9 +964,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.4.tgz", - "integrity": "sha512-7pIHBLTHYRAlS7V22JNuTh33yLH4VElwKtB3bwchK/UaKUPpQ0lPQiOWcbm4V3WP2I6fNIJ23vABIvoy2izdwA==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.0.tgz", + "integrity": "sha512-R06HdNi7A7OEoMsf6d4tjZ71RCWnZQPHj2mnotSFURjNLdBC+cIgXQ7l81CqeoiQftjf6OOblxXMInMgN2VzMA==", "cpu": [ "arm" ], @@ -981,9 +981,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.4.tgz", - "integrity": "sha512-+E4wxJ0ZGOzSH325reXTWB48l42i93kQqMvDyz5gqfRzRZ7faNhnmvlV4EPGJU3QJM/3Ab5jhJ5pCRUsKn6OQw==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.0.tgz", + "integrity": "sha512-qTJHELX8jetjhRQHCLilkVLmybpzNQAtaI/gaoVoidn/ufbNDbAo8KlK2J+yPoc8wQxvDxCmh/5lr8nC1+lTbg==", "cpu": [ "arm64" ], @@ -998,9 +998,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm64-musl": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.4.tgz", - "integrity": "sha512-bBADEGAbo4ASnppIziaQJelekCxdMaxisrk+fB7Thit72IBnALp9K6ffA2G4ruj90G9XRS2VQ6q2bCKbfFV82g==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.0.tgz", + "integrity": "sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ==", "cpu": [ "arm64" ], @@ -1015,9 +1015,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-x64-gnu": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.4.tgz", - "integrity": "sha512-7Mx25E4WTfnht0TVRTyC00j3i0M+EeFe7wguMDTlX4mRxafznw0CA8WJkFjWYH5BlgELd1kSjuU2JiPnNZbJDA==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.0.tgz", + "integrity": "sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ==", "cpu": [ "x64" ], @@ -1032,9 +1032,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-x64-musl": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.4.tgz", - "integrity": "sha512-2wwJRF7nyhOR0hhHoChc04xngV3iS+akccHTGtz965FwF0up4b2lOdo6kI1EbDaEXKgvcrFBYcYQQ/rrnWFVfA==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.0.tgz", + "integrity": "sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg==", "cpu": [ "x64" ], @@ -1049,9 +1049,9 @@ } }, "node_modules/@tailwindcss/oxide-wasm32-wasi": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.4.tgz", - "integrity": "sha512-FQsqApeor8Fo6gUEklzmaa9994orJZZDBAlQpK2Mq+DslRKFJeD6AjHpBQ0kZFQohVr8o85PPh8eOy86VlSCmw==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.0.tgz", + "integrity": "sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA==", "bundleDependencies": [ "@napi-rs/wasm-runtime", "@emnapi/core", @@ -1067,10 +1067,10 @@ "license": "MIT", "optional": true, "dependencies": { - "@emnapi/core": "^1.8.1", - "@emnapi/runtime": "^1.8.1", - "@emnapi/wasi-threads": "^1.1.0", - "@napi-rs/wasm-runtime": "^1.1.1", + "@emnapi/core": "^1.10.0", + "@emnapi/runtime": "^1.10.0", + "@emnapi/wasi-threads": "^1.2.1", + "@napi-rs/wasm-runtime": "^1.1.4", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.8.1" }, @@ -1079,9 +1079,9 @@ } }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.4.tgz", - "integrity": "sha512-L9BXqxC4ToVgwMFqj3pmZRqyHEztulpUJzCxUtLjobMCzTPsGt1Fa9enKbOpY2iIyVtaHNeNvAK8ERP/64sqGQ==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.0.tgz", + "integrity": "sha512-Pe+RPVTi1T+qymuuRpcdvwSVZjnll/f7n8gBxMMh3xLTctMDKqpdfGimbMyioqtLhUYZxdJ9wGNhV7MKHvgZsQ==", "cpu": [ "arm64" ], @@ -1096,9 +1096,9 @@ } }, "node_modules/@tailwindcss/oxide-win32-x64-msvc": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.4.tgz", - "integrity": "sha512-ESlKG0EpVJQwRjXDDa9rLvhEAh0mhP1sF7sap9dNZT0yyl9SAG6T7gdP09EH0vIv0UNTlo6jPWyujD6559fZvw==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.0.tgz", + "integrity": "sha512-Mvrf2kXW/yeW/OTezZlCGOirXRcUuLIBx/5Y12BaPM7wJoryG6dfS/NJL8aBPqtTEx/Vm4T4vKzFUcKDT+TKUA==", "cpu": [ "x64" ], @@ -1113,15 +1113,15 @@ } }, "node_modules/@tailwindcss/vite": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.2.4.tgz", - "integrity": "sha512-pCvohwOCspk3ZFn6eJzrrX3g4n2JY73H6MmYC87XfGPyTty4YsCjYTMArRZm/zOI8dIt3+EcrLHAFPe5A4bgtw==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.0.tgz", + "integrity": "sha512-t6J3OrB5Fc0ExuhohouH0fWUGMYL6PTLhW+E7zIk/pdbnJARZDCwjBznFnkh5ynRnIRSI4YjtTH0t6USjJISrw==", "dev": true, "license": "MIT", "dependencies": { - "@tailwindcss/node": "4.2.4", - "@tailwindcss/oxide": "4.2.4", - "tailwindcss": "4.2.4" + "@tailwindcss/node": "4.3.0", + "@tailwindcss/oxide": "4.3.0", + "tailwindcss": "4.3.0" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" @@ -1168,13 +1168,13 @@ "license": "MIT" }, "node_modules/axios": { - "version": "1.15.2", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.2.tgz", - "integrity": "sha512-wLrXxPtcrPTsNlJmKjkPnNPK2Ihe0hn0wGSaTEiHRPxwjvJwT3hKmXF4dpqxmPO9SoNb2FsYXj/xEo0gHN+D5A==", + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.16.0.tgz", + "integrity": "sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w==", "dev": true, "license": "MIT", "dependencies": { - "follow-redirects": "^1.15.11", + "follow-redirects": "^1.16.0", "form-data": "^4.0.5", "proxy-from-env": "^2.1.0" } @@ -1339,9 +1339,9 @@ "license": "MIT" }, "node_modules/enhanced-resolve": { - "version": "5.21.0", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.0.tgz", - "integrity": "sha512-otxSQPw4lkOZWkHpB3zaEQs6gWYEsmX4xQF68ElXC/TWvGxGMSGOvoNbaLXm6/cS/fSfHtsEdw90y20PCd+sCA==", + "version": "5.21.2", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.2.tgz", + "integrity": "sha512-xe9vQb5kReirPUxgQrXA3ihgbCqssmTiM7cOZ+Gzu+VeGWgpV98lLZvp0dl4yriyAePcewxGUs9UpKD8PET9KQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1666,9 +1666,9 @@ } }, "node_modules/jiti": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", - "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", "dev": true, "license": "MIT", "bin": { @@ -2000,9 +2000,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", "dev": true, "funding": [ { @@ -2039,9 +2039,9 @@ } }, "node_modules/postcss": { - "version": "8.5.12", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.12.tgz", - "integrity": "sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==", + "version": "8.5.14", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", + "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", "dev": true, "funding": [ { @@ -2088,9 +2088,9 @@ } }, "node_modules/rollup": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.2.tgz", - "integrity": "sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.3.tgz", + "integrity": "sha512-pAQK9HalE84QSm4Po3EmWIZPd3FnjkShVkiMlz1iligWYkWQ7wHYd1PF/T7QZ5TVSD6uSTon5gBVMSM4JfBV+A==", "dev": true, "license": "MIT", "dependencies": { @@ -2104,31 +2104,31 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.60.2", - "@rollup/rollup-android-arm64": "4.60.2", - "@rollup/rollup-darwin-arm64": "4.60.2", - "@rollup/rollup-darwin-x64": "4.60.2", - "@rollup/rollup-freebsd-arm64": "4.60.2", - "@rollup/rollup-freebsd-x64": "4.60.2", - "@rollup/rollup-linux-arm-gnueabihf": "4.60.2", - "@rollup/rollup-linux-arm-musleabihf": "4.60.2", - "@rollup/rollup-linux-arm64-gnu": "4.60.2", - "@rollup/rollup-linux-arm64-musl": "4.60.2", - "@rollup/rollup-linux-loong64-gnu": "4.60.2", - "@rollup/rollup-linux-loong64-musl": "4.60.2", - "@rollup/rollup-linux-ppc64-gnu": "4.60.2", - "@rollup/rollup-linux-ppc64-musl": "4.60.2", - "@rollup/rollup-linux-riscv64-gnu": "4.60.2", - "@rollup/rollup-linux-riscv64-musl": "4.60.2", - "@rollup/rollup-linux-s390x-gnu": "4.60.2", - "@rollup/rollup-linux-x64-gnu": "4.60.2", - "@rollup/rollup-linux-x64-musl": "4.60.2", - "@rollup/rollup-openbsd-x64": "4.60.2", - "@rollup/rollup-openharmony-arm64": "4.60.2", - "@rollup/rollup-win32-arm64-msvc": "4.60.2", - "@rollup/rollup-win32-ia32-msvc": "4.60.2", - "@rollup/rollup-win32-x64-gnu": "4.60.2", - "@rollup/rollup-win32-x64-msvc": "4.60.2", + "@rollup/rollup-android-arm-eabi": "4.60.3", + "@rollup/rollup-android-arm64": "4.60.3", + "@rollup/rollup-darwin-arm64": "4.60.3", + "@rollup/rollup-darwin-x64": "4.60.3", + "@rollup/rollup-freebsd-arm64": "4.60.3", + "@rollup/rollup-freebsd-x64": "4.60.3", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.3", + "@rollup/rollup-linux-arm-musleabihf": "4.60.3", + "@rollup/rollup-linux-arm64-gnu": "4.60.3", + "@rollup/rollup-linux-arm64-musl": "4.60.3", + "@rollup/rollup-linux-loong64-gnu": "4.60.3", + "@rollup/rollup-linux-loong64-musl": "4.60.3", + "@rollup/rollup-linux-ppc64-gnu": "4.60.3", + "@rollup/rollup-linux-ppc64-musl": "4.60.3", + "@rollup/rollup-linux-riscv64-gnu": "4.60.3", + "@rollup/rollup-linux-riscv64-musl": "4.60.3", + "@rollup/rollup-linux-s390x-gnu": "4.60.3", + "@rollup/rollup-linux-x64-gnu": "4.60.3", + "@rollup/rollup-linux-x64-musl": "4.60.3", + "@rollup/rollup-openbsd-x64": "4.60.3", + "@rollup/rollup-openharmony-arm64": "4.60.3", + "@rollup/rollup-win32-arm64-msvc": "4.60.3", + "@rollup/rollup-win32-ia32-msvc": "4.60.3", + "@rollup/rollup-win32-x64-gnu": "4.60.3", + "@rollup/rollup-win32-x64-msvc": "4.60.3", "fsevents": "~2.3.2" } }, @@ -2210,9 +2210,9 @@ } }, "node_modules/tailwindcss": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.4.tgz", - "integrity": "sha512-HhKppgO81FQof5m6TEnuBWCZGgfRAWbaeOaGT00KOy/Pf/j6oUihdvBpA7ltCeAvZpFhW3j0PTclkxsd4IXYDA==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.0.tgz", + "integrity": "sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q==", "dev": true, "license": "MIT" }, @@ -2265,9 +2265,9 @@ "license": "0BSD" }, "node_modules/vite": { - "version": "7.3.2", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz", - "integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==", + "version": "7.3.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.3.tgz", + "integrity": "sha512-/4XH147Ui7OGTjg3HbdWe5arnZQSbfuRzdr9Ec7TQi5I7R+ir0Rlc9GIvD4v0XZurELqA035KVXJXpR61xhiTA==", "dev": true, "license": "MIT", "dependencies": { From 1fd9384224d84267b279a3b5201c45648bf4edfb Mon Sep 17 00:00:00 2001 From: Giulio Troccoli-Allard Date: Sat, 9 May 2026 16:04:01 +0100 Subject: [PATCH 44/63] chore: fix styling --- app/Livewire/Court.php | 1 - app/Livewire/LineupSubmission.php | 1 - 2 files changed, 2 deletions(-) diff --git a/app/Livewire/Court.php b/app/Livewire/Court.php index 06cd66e..bf69ee2 100644 --- a/app/Livewire/Court.php +++ b/app/Livewire/Court.php @@ -58,7 +58,6 @@ private function courtContext(): array ]; } - /** * @return array */ diff --git a/app/Livewire/LineupSubmission.php b/app/Livewire/LineupSubmission.php index f03bf02..4f6144f 100644 --- a/app/Livewire/LineupSubmission.php +++ b/app/Livewire/LineupSubmission.php @@ -313,5 +313,4 @@ private function defaultLineup(): array 6 => '', ]; } - } From d02908867cf9624b88964fd79a0beac139f0fddf Mon Sep 17 00:00:00 2001 From: Giulio Troccoli-Allard Date: Sun, 10 May 2026 18:52:32 +0100 Subject: [PATCH 45/63] feat: move winner buttons to lineup submission slots below the court Co-Authored-By: Claude Sonnet 4.6 --- app/Livewire/RallyWinnerControls.php | 6 +- resources/views/livewire/court.blade.php | 12 ++++ resources/views/livewire/game.blade.php | 1 - .../livewire/rally-winner-controls.blade.php | 50 ++++++----------- tests/Feature/CourtTest.php | 56 +++++++++++++------ 5 files changed, 72 insertions(+), 53 deletions(-) diff --git a/app/Livewire/RallyWinnerControls.php b/app/Livewire/RallyWinnerControls.php index ba0efbe..83d44fa 100644 --- a/app/Livewire/RallyWinnerControls.php +++ b/app/Livewire/RallyWinnerControls.php @@ -23,11 +23,14 @@ class RallyWinnerControls extends Component #[Reactive] public ?GameState $gameState = null; - public function mount(?int $gameId = null): void + public string $side = 'left'; + + public function mount(?int $gameId = null, string $side = 'left'): void { abort_if($gameId === null, 404); $this->gameId = $gameId; + $this->side = $side; } public function recordRallyWinner(string $team): void @@ -67,6 +70,7 @@ public function render(): View 'leftTeam' => $leftTeam, 'rightTeam' => $rightTeam, 'canRecordRallyWinner' => $state->setInProgress && ! $state->gameEnded, + 'side' => $this->side, ]); } diff --git a/resources/views/livewire/court.blade.php b/resources/views/livewire/court.blade.php index ec27773..e2a0578 100644 --- a/resources/views/livewire/court.blade.php +++ b/resources/views/livewire/court.blade.php @@ -88,6 +88,12 @@ class="absolute {{ $markerPositionClass }} flex h-8 w-8 items-center justify-cen :game-state="$gameState" :key="'lineup-submission-left-'.$leftTeam->value" /> +
@if ($showRosters) @@ -123,6 +129,12 @@ class="absolute {{ $markerPositionClass }} flex h-8 w-8 items-center justify-cen :game-state="$gameState" :key="'lineup-submission-right-'.$rightTeam->value" /> +
diff --git a/resources/views/livewire/game.blade.php b/resources/views/livewire/game.blade.php index e2a64b7..0605a18 100644 --- a/resources/views/livewire/game.blade.php +++ b/resources/views/livewire/game.blade.php @@ -6,7 +6,6 @@ class="relative flex min-h-[680px] w-full max-w-[1536px] items-center justify-ce > -
diff --git a/resources/views/livewire/rally-winner-controls.blade.php b/resources/views/livewire/rally-winner-controls.blade.php index ecc3fa3..9455b02 100644 --- a/resources/views/livewire/rally-winner-controls.blade.php +++ b/resources/views/livewire/rally-winner-controls.blade.php @@ -1,39 +1,21 @@
@if ($canRecordRallyWinner) -
-
- - Winner - + @php $team = $side === 'left' ? $leftTeam : $rightTeam; @endphp + + Winner + - - Winner - -
- - @error('submit') - {{ $message }} - @enderror -
+ @error('submit') + {{ $message }} + @enderror @endif
diff --git a/tests/Feature/CourtTest.php b/tests/Feature/CourtTest.php index 495cf27..afb0161 100644 --- a/tests/Feature/CourtTest.php +++ b/tests/Feature/CourtTest.php @@ -340,61 +340,83 @@ ]); }); -test('court does not render rally winner controls', function (): void { +test('court renders rally winner controls when set is in progress', function (): void { $game = Game::factory()->create(); Livewire::test(Court::class, [ 'gameId' => $game->getKey(), - 'gameState' => gameState([ - 'set_number' => 1, - 'set_in_progress' => true, - ]), + 'gameState' => gameState(['set_number' => 1, 'set_in_progress' => false]), ]) ->assertDontSee('Winner') ->assertDontSeeHtml('data-rally-winner-button="team_a"') ->assertDontSeeHtml('data-rally-winner-button="team_b"'); + + Livewire::test(Court::class, [ + 'gameId' => $game->getKey(), + 'gameState' => gameState(['set_number' => 1, 'set_in_progress' => true]), + ]) + ->assertSee('Winner') + ->assertSeeHtml('data-rally-winner-button="team_a"') + ->assertSeeHtml('data-rally-winner-button="team_b"'); }); -test('rally winner controls show buttons only while a set is in progress and game is not ended', function (): void { +test('rally winner controls show button only while a set is in progress and game is not ended', function (): void { $game = Game::factory()->create(); Livewire::test(RallyWinnerControls::class, [ 'gameId' => $game->getKey(), 'gameState' => gameState(['set_number' => 1, 'set_in_progress' => false]), + 'side' => 'left', ]) ->assertDontSee('Winner') - ->assertDontSeeHtml('data-rally-winner-button="team_a"') - ->assertDontSeeHtml('data-rally-winner-button="team_b"'); + ->assertDontSeeHtml('data-rally-winner-button="team_a"'); Livewire::test(RallyWinnerControls::class, [ 'gameId' => $game->getKey(), 'gameState' => gameState(['set_number' => 1, 'set_in_progress' => true]), + 'side' => 'left', + ]) + ->assertSee('Winner') + ->assertSeeHtml('data-rally-winner-button="team_a"'); + + Livewire::test(RallyWinnerControls::class, [ + 'gameId' => $game->getKey(), + 'gameState' => gameState(['set_number' => 1, 'set_in_progress' => true]), + 'side' => 'right', ]) ->assertSee('Winner') - ->assertSeeHtml('data-rally-winner-button="team_a"') ->assertSeeHtml('data-rally-winner-button="team_b"'); Livewire::test(RallyWinnerControls::class, [ 'gameId' => $game->getKey(), 'gameState' => gameState(['set_number' => 5, 'set_in_progress' => true, 'game_ended' => true]), + 'side' => 'left', ]) ->assertDontSee('Winner') - ->assertDontSeeHtml('data-rally-winner-button="team_a"') - ->assertDontSeeHtml('data-rally-winner-button="team_b"'); + ->assertDontSeeHtml('data-rally-winner-button="team_a"'); }); test('rally winner controls swap sides as soon as sides swap', function (): void { $game = Game::factory()->create(); + $state = gameState([ + 'set_number' => 2, + 'sets_won_team_a' => 1, + 'set_in_progress' => true, + ]); + Livewire::test(RallyWinnerControls::class, [ 'gameId' => $game->getKey(), - 'gameState' => gameState([ - 'set_number' => 2, - 'sets_won_team_a' => 1, - 'set_in_progress' => true, - ]), + 'gameState' => $state, + 'side' => 'left', + ]) + ->assertSeeHtml('data-rally-winner-side-team="left-team_b"'); + + Livewire::test(RallyWinnerControls::class, [ + 'gameId' => $game->getKey(), + 'gameState' => $state, + 'side' => 'right', ]) - ->assertSeeHtml('data-rally-winner-side-team="left-team_b"') ->assertSeeHtml('data-rally-winner-side-team="right-team_a"'); }); From 3f1974588aba6e036af90f71060ac8f604f0e81d Mon Sep 17 00:00:00 2001 From: Giulio Troccoli-Allard Date: Sun, 10 May 2026 20:56:01 +0100 Subject: [PATCH 46/63] feat: add substitution modal with pairing validation Clicking the substitutions card opens a modal showing on-court players (coloured round buttons) and bench players (white badges). Clicking a player fills the corresponding input; if a substitution pair is active, the partner input is auto-filled. Locked players (whose pair has already swapped back) are visually disabled and rejected server-side. JSON data is stored in data-* attributes to avoid double-quote conflicts with the x-data HTML attribute. Co-Authored-By: Claude Sonnet 4.6 --- app/Livewire/TeamRoster.php | 157 +++++++++++++++- .../views/livewire/team-roster.blade.php | 154 ++++++++++++++- tests/Feature/TeamRosterTest.php | 177 ++++++++++++++++++ 3 files changed, 478 insertions(+), 10 deletions(-) diff --git a/app/Livewire/TeamRoster.php b/app/Livewire/TeamRoster.php index 9adeb65..462b3eb 100644 --- a/app/Livewire/TeamRoster.php +++ b/app/Livewire/TeamRoster.php @@ -5,11 +5,14 @@ namespace App\Livewire; use App\Data\GameState\GameState; +use App\Enums\GameEventType; use App\Enums\StaffRole; use App\Enums\TeamAB; use App\Enums\TeamSide; +use App\Events\Payloads\SubstitutionCompletedPayload; use App\Exceptions\InvalidGameEventTransition; use App\Models\Game; +use App\Models\GameEvent; use App\Services\GameSideResolver; use App\Services\ScoresheetDataRepository; use Illuminate\Contracts\View\View; @@ -45,6 +48,10 @@ public function mount( $this->leftSide = $leftSide; } + public string $playerOut = ''; + + public string $playerIn = ''; + public function requestTimeout(): void { $activeGame = $this->activeGame(); @@ -74,16 +81,150 @@ public function requestTimeout(): void $this->dispatch('timeout-recorded', team: $this->team->value, hasTimeoutLeft: $hasTimeoutLeft); } + public function submitSubstitution(): void + { + $this->resetValidation('substitution'); + + $playerOut = (int) $this->playerOut; + $playerIn = (int) $this->playerIn; + + if ($playerOut === 0 || $playerIn === 0) { + $this->addError('substitution', 'Both player numbers are required.'); + + return; + } + + $constraints = $this->substitutionConstraints(); + + if (in_array($playerOut, $constraints['locked'], true)) { + $this->addError('substitution', "Player $playerOut cannot be substituted again this set."); + + return; + } + + if (in_array($playerIn, $constraints['locked'], true)) { + $this->addError('substitution', "Player $playerIn cannot be substituted again this set."); + + return; + } + + if (isset($constraints['partnerFor'][$playerOut]) && $constraints['partnerFor'][$playerOut] !== $playerIn) { + $this->addError('substitution', "Player $playerOut can only be replaced by player {$constraints['partnerFor'][$playerOut]}."); + + return; + } + + if (isset($constraints['partnerFor'][$playerIn]) && $constraints['partnerFor'][$playerIn] !== $playerOut) { + $this->addError('substitution', "Player $playerIn can only return by replacing player {$constraints['partnerFor'][$playerIn]}."); + + return; + } + + $activeGame = $this->activeGame(); + + if ($activeGame === null) { + $this->addError('substitution', 'No active game is available to record the substitution.'); + + return; + } + + try { + $activeGame->recordSubstitution($this->team, $playerOut, $playerIn); + } catch (InvalidGameEventTransition $exception) { + $this->addError('substitution', $exception->getMessage()); + + return; + } + + $this->playerOut = ''; + $this->playerIn = ''; + $this->dispatch('game-event-recorded'); + $this->dispatch('substitution-recorded', team: $this->team->value); + } + + /** + * Returns substitution constraints for the current set. + * + * @return array{locked: array, partnerFor: array} + */ + #[Computed] + public function substitutionConstraints(): array + { + $game = $this->activeGame(); + + if ($game === null) { + return ['locked' => [], 'partnerFor' => []]; + } + + $lastSetStarted = GameEvent::query() + ->where('game_id', $game->getKey()) + ->where('type', GameEventType::SetStarted) + ->latest('id') + ->first(); + + $query = GameEvent::query() + ->where('game_id', $game->getKey()) + ->where('type', GameEventType::SubstitutionCompleted); + + if ($lastSetStarted !== null) { + $query->where('id', '>', $lastSetStarted->getKey()); + } + + $substitutions = $query->orderBy('id')->get(); + + /** @var array $pairs */ + $pairs = []; + $locked = []; + + foreach ($substitutions as $event) { + /** @var SubstitutionCompletedPayload $payload */ + $payload = $event->payload; + + if ($payload->team !== $this->team) { + continue; + } + + $pairKey = implode('-', [min($payload->playerOut, $payload->playerIn), max($payload->playerOut, $payload->playerIn)]); + + if (isset($pairs[$pairKey]) && ! $pairs[$pairKey]['reversed']) { + $pairs[$pairKey]['reversed'] = true; + $locked[] = $payload->playerOut; + $locked[] = $payload->playerIn; + } elseif (! isset($pairs[$pairKey])) { + $pairs[$pairKey] = ['out' => $payload->playerOut, 'in' => $payload->playerIn, 'reversed' => false]; + } + } + + $partnerFor = []; + + foreach ($pairs as $pair) { + if (! $pair['reversed']) { + $partnerFor[$pair['out']] = $pair['in']; + $partnerFor[$pair['in']] = $pair['out']; + } + } + + return ['locked' => array_values(array_unique($locked)), 'partnerFor' => $partnerFor]; + } + public function render(): View { $teamPlayers = $this->teamPlayers(); $rosterPlayerCount = count($teamPlayers); - $players = $this->benchPlayers($teamPlayers); + $benchPlayers = $this->benchPlayers($teamPlayers); $lineupSubmitted = $this->hasLineupBeenSubmitted($this->team); $placeholderCount = $lineupSubmitted ? 0 : max(0, $rosterPlayerCount - 6); + $onCourtNumbers = $this->onCourtRosterNumbers($this->team); + sort($onCourtNumbers); + + $benchNumbers = array_column($benchPlayers, 'number'); + sort($benchNumbers); + + $constraints = $this->substitutionConstraints(); + return view('livewire.team-roster', [ - 'players' => $players, + 'players' => $benchPlayers, 'showPlayerPlaceholders' => $placeholderCount > 0, 'placeholderCount' => $placeholderCount, 'hasRosterPlayers' => $rosterPlayerCount > 0, @@ -94,6 +235,11 @@ public function render(): View 'timeoutsTaken' => $this->timeoutsTaken(), 'substitutionsTaken' => $this->substitutionsTaken(), 'canRequestTimeout' => $this->canRequestTimeout(), + 'canRequestSubstitution' => $this->canRequestSubstitution(), + 'onCourtNumbers' => $onCourtNumbers, + 'benchNumbers' => $benchNumbers, + 'lockedNumbers' => $constraints['locked'], + 'partnerFor' => $constraints['partnerFor'], ]); } @@ -228,6 +374,13 @@ private function hasLineupBeenSubmitted(TeamAB $team): bool return $this->onCourtRosterNumbers($team) !== []; } + private function canRequestSubstitution(): bool + { + $state = $this->gameState ?? GameState::initial(); + + return $state->setInProgress && ! $state->gameEnded && $this->substitutionsTaken() < 6; + } + private function canRequestTimeout(): bool { $state = $this->gameState ?? GameState::initial(); diff --git a/resources/views/livewire/team-roster.blade.php b/resources/views/livewire/team-roster.blade.php index 7ef194a..ca479c5 100644 --- a/resources/views/livewire/team-roster.blade.php +++ b/resources/views/livewire/team-roster.blade.php @@ -161,13 +161,151 @@ class="p-1.5 content-center-safe!" @endif - - Substitutions - {{ $substitutionsTaken }}/6 - + @if ($canRequestSubstitution) + + + Substitutions + {{ $substitutionsTaken }}/6 + + + + + + Substitution + + @if ($onCourtNumbers !== []) +
+ @foreach ($onCourtNumbers as $number) + @php $isLocked = in_array($number, $lockedNumbers); @endphp + + @endforeach +
+ @endif + + + + + + @if ($benchNumbers !== []) +
+ @foreach ($benchNumbers as $number) + @php $isLocked = in_array($number, $lockedNumbers); @endphp + ! $isLocked, + 'cursor-not-allowed border-slate-200 bg-slate-100 text-slate-400 opacity-50' => $isLocked, + ]) + @click="fillPlayerIn({{ $number }})" + > + {{ $number }} + + @endforeach +
+ @endif + + @error('substitution') + {{ $message }} + @enderror + +
+ + + Cancel + + + Confirm + +
+ +
+ @else + + Substitutions + {{ $substitutionsTaken }}/6 + + @endif
diff --git a/tests/Feature/TeamRosterTest.php b/tests/Feature/TeamRosterTest.php index e9753c2..0671f55 100644 --- a/tests/Feature/TeamRosterTest.php +++ b/tests/Feature/TeamRosterTest.php @@ -369,6 +369,183 @@ ->assertNotDispatched('timeout-recorded'); }); +test('substitution card shows modal trigger when a set is in progress and substitutions remain', function (): void { + $game = gameWithActiveSetForTeamRoster(); + + Livewire::test(TeamRoster::class, [ + 'gameId' => $game->getKey(), + 'team' => TeamAB::TeamA, + 'leftSide' => true, + 'gameState' => $game->stateAt(), + ])->assertSeeHtml('data-team-roster-substitutions') + ->assertSeeHtml('substitution-team_a'); +}); + +test('substitution card is not a modal trigger when no set is in progress', function (): void { + $homeTeam = Team::factory()->create(); + $awayTeam = Team::factory()->create(); + $game = Game::factory()->betweenTeams($homeTeam, $awayTeam)->create(); + + Livewire::test(TeamRoster::class, [ + 'gameId' => $game->getKey(), + 'team' => TeamAB::TeamA, + 'leftSide' => true, + ])->assertSeeHtml('data-team-roster-substitutions') + ->assertDontSeeHtml('substitution-team_a'); +}); + +test('substitution card is not a modal trigger when all 6 substitutions are used', function (): void { + $game = gameWithActiveSetForTeamRoster(); + $state = $game->stateAt(); + + Livewire::test(TeamRoster::class, [ + 'gameId' => $game->getKey(), + 'team' => TeamAB::TeamA, + 'leftSide' => true, + 'gameState' => GameState::fromAttributes(array_merge($state->toAttributes(), ['substitutions_team_a' => 6])), + ])->assertSeeHtml('data-team-roster-substitutions') + ->assertDontSeeHtml('substitution-team_a'); +}); + +test('substitution modal shows on-court and bench player numbers', function (): void { + $game = gameWithActiveSetForTeamRoster(); + + Livewire::test(TeamRoster::class, [ + 'gameId' => $game->getKey(), + 'team' => TeamAB::TeamA, + 'leftSide' => true, + 'gameState' => $game->stateAt(), + ])->assertSeeHtml('data-substitution-on-court-number="1"') + ->assertSeeHtml('data-substitution-on-court-number="6"') + ->assertDontSeeHtml('data-substitution-bench-number="1"'); +}); + +test('substitution records event and dispatches events', function (): void { + $game = gameWithActiveSetForTeamRoster(); + + Livewire::test(TeamRoster::class, [ + 'gameId' => $game->getKey(), + 'team' => TeamAB::TeamA, + 'leftSide' => true, + 'gameState' => $game->stateAt(), + ]) + ->set('playerOut', '1') + ->set('playerIn', '7') + ->call('submitSubstitution') + ->assertHasNoErrors() + ->assertDispatched('game-event-recorded') + ->assertDispatched('substitution-recorded', team: 'team_a'); + + $latestEvent = $game->fresh()->events->last(); + expect($latestEvent->type)->toBe(GameEventType::SubstitutionCompleted); +}); + +test('substitution requires both player numbers', function (): void { + $game = gameWithActiveSetForTeamRoster(); + + Livewire::test(TeamRoster::class, [ + 'gameId' => $game->getKey(), + 'team' => TeamAB::TeamA, + 'leftSide' => true, + 'gameState' => $game->stateAt(), + ]) + ->set('playerOut', '') + ->set('playerIn', '') + ->call('submitSubstitution') + ->assertHasErrors('substitution') + ->assertNotDispatched('game-event-recorded'); +}); + +test('substituted player cannot be substituted again in the same set', function (): void { + $game = gameWithActiveSetForTeamRoster(); + $game->recordSubstitution(TeamAB::TeamA, 1, 7); + + Livewire::test(TeamRoster::class, [ + 'gameId' => $game->getKey(), + 'team' => TeamAB::TeamA, + 'leftSide' => true, + 'gameState' => $game->stateAt(), + ]) + ->set('playerOut', '7') + ->set('playerIn', '1') + ->call('submitSubstitution') + ->assertHasNoErrors() + ->assertDispatched('game-event-recorded'); + + Livewire::test(TeamRoster::class, [ + 'gameId' => $game->fresh()->getKey(), + 'team' => TeamAB::TeamA, + 'leftSide' => true, + 'gameState' => $game->fresh()->stateAt(), + ]) + ->set('playerOut', '1') + ->set('playerIn', '7') + ->call('submitSubstitution') + ->assertHasErrors('substitution') + ->assertNotDispatched('game-event-recorded'); +}); + +test('substitution pair can only swap back once', function (): void { + $game = gameWithActiveSetForTeamRoster(); + $game->recordSubstitution(TeamAB::TeamA, 1, 7); + $game->recordSubstitution(TeamAB::TeamA, 7, 1); + + Livewire::test(TeamRoster::class, [ + 'gameId' => $game->getKey(), + 'team' => TeamAB::TeamA, + 'leftSide' => true, + 'gameState' => $game->stateAt(), + ]) + ->set('playerOut', '1') + ->set('playerIn', '7') + ->call('submitSubstitution') + ->assertHasErrors('substitution') + ->assertNotDispatched('game-event-recorded'); +}); + +test('player with active pair constraint can only be replaced by their partner', function (): void { + $game = gameWithActiveSetForTeamRoster(); + $game->recordSubstitution(TeamAB::TeamA, 1, 7); + + Livewire::test(TeamRoster::class, [ + 'gameId' => $game->getKey(), + 'team' => TeamAB::TeamA, + 'leftSide' => true, + 'gameState' => $game->stateAt(), + ]) + ->set('playerOut', '7') + ->set('playerIn', '8') + ->call('submitSubstitution') + ->assertHasErrors('substitution') + ->assertNotDispatched('game-event-recorded'); +}); + +test('substitution constraints reset between sets', function (): void { + $game = gameWithActiveSetForTeamRoster(); + $game->recordSubstitution(TeamAB::TeamA, 1, 7); + $game->recordSubstitution(TeamAB::TeamA, 7, 1); + + for ($i = 0; $i < 25; $i++) { + $game->recordRallyWinner(TeamAB::TeamA); + } + + $game->recordLineup(2, TeamAB::TeamA, [1 => 1, 2 => 2, 3 => 3, 4 => 4, 5 => 5, 6 => 6]); + $game->recordLineup(2, TeamAB::TeamB, [1 => 11, 2 => 12, 3 => 13, 4 => 14, 5 => 15, 6 => 16]); + $game->recordSetStarted(); + + Livewire::test(TeamRoster::class, [ + 'gameId' => $game->getKey(), + 'team' => TeamAB::TeamA, + 'leftSide' => true, + 'gameState' => $game->stateAt(), + ]) + ->set('playerOut', '1') + ->set('playerIn', '7') + ->call('submitSubstitution') + ->assertHasNoErrors() + ->assertDispatched('game-event-recorded'); +}); + function gameWithActiveSetForTeamRoster(): Game { $homeTeam = Team::factory()->create(); From ef050c334976aafe8df98ebc6ce243731aa2d70e Mon Sep 17 00:00:00 2001 From: Giulio Troccoli-Allard Date: Mon, 11 May 2026 18:02:07 +0100 Subject: [PATCH 47/63] feat: show no-substitutions modal when team reaches the 6-sub limit When a set is in progress and a team has used all 6 substitutions, the substitutions card remains clickable and opens a confirmation popup. Confirming shows a dismissible "No substitutions left" modal with a return-to-play button, mirroring the third-timeout flow. Server-side validation in submitSubstitution() also rejects any attempt to submit beyond the limit as a defence-in-depth measure. Co-Authored-By: Claude Sonnet 4.6 --- app/Livewire/TeamRoster.php | 22 ++++++-- .../views/livewire/team-roster.blade.php | 53 +++++++++++++++++++ tests/Feature/TeamRosterTest.php | 25 ++++++++- 3 files changed, 95 insertions(+), 5 deletions(-) diff --git a/app/Livewire/TeamRoster.php b/app/Livewire/TeamRoster.php index 462b3eb..148fa0b 100644 --- a/app/Livewire/TeamRoster.php +++ b/app/Livewire/TeamRoster.php @@ -96,6 +96,12 @@ public function submitSubstitution(): void $constraints = $this->substitutionConstraints(); + if ($constraints['count'] >= 6) { + $this->addError('substitution', 'This team has used all 6 substitutions for this set.'); + + return; + } + if (in_array($playerOut, $constraints['locked'], true)) { $this->addError('substitution', "Player $playerOut cannot be substituted again this set."); @@ -145,7 +151,7 @@ public function submitSubstitution(): void /** * Returns substitution constraints for the current set. * - * @return array{locked: array, partnerFor: array} + * @return array{count: int, locked: array, partnerFor: array} */ #[Computed] public function substitutionConstraints(): array @@ -153,7 +159,7 @@ public function substitutionConstraints(): array $game = $this->activeGame(); if ($game === null) { - return ['locked' => [], 'partnerFor' => []]; + return ['count' => 0, 'locked' => [], 'partnerFor' => []]; } $lastSetStarted = GameEvent::query() @@ -175,6 +181,7 @@ public function substitutionConstraints(): array /** @var array $pairs */ $pairs = []; $locked = []; + $count = 0; foreach ($substitutions as $event) { /** @var SubstitutionCompletedPayload $payload */ @@ -184,6 +191,7 @@ public function substitutionConstraints(): array continue; } + $count++; $pairKey = implode('-', [min($payload->playerOut, $payload->playerIn), max($payload->playerOut, $payload->playerIn)]); if (isset($pairs[$pairKey]) && ! $pairs[$pairKey]['reversed']) { @@ -204,7 +212,7 @@ public function substitutionConstraints(): array } } - return ['locked' => array_values(array_unique($locked)), 'partnerFor' => $partnerFor]; + return ['count' => $count, 'locked' => array_values(array_unique($locked)), 'partnerFor' => $partnerFor]; } public function render(): View @@ -236,6 +244,7 @@ public function render(): View 'substitutionsTaken' => $this->substitutionsTaken(), 'canRequestTimeout' => $this->canRequestTimeout(), 'canRequestSubstitution' => $this->canRequestSubstitution(), + 'canShowSubstitutionFullModal' => $this->canShowSubstitutionFullModal(), 'onCourtNumbers' => $onCourtNumbers, 'benchNumbers' => $benchNumbers, 'lockedNumbers' => $constraints['locked'], @@ -381,6 +390,13 @@ private function canRequestSubstitution(): bool return $state->setInProgress && ! $state->gameEnded && $this->substitutionsTaken() < 6; } + private function canShowSubstitutionFullModal(): bool + { + $state = $this->gameState ?? GameState::initial(); + + return $state->setInProgress && ! $state->gameEnded && $this->substitutionsTaken() >= 6; + } + private function canRequestTimeout(): bool { $state = $this->gameState ?? GameState::initial(); diff --git a/resources/views/livewire/team-roster.blade.php b/resources/views/livewire/team-roster.blade.php index ca479c5..db55344 100644 --- a/resources/views/livewire/team-roster.blade.php +++ b/resources/views/livewire/team-roster.blade.php @@ -297,6 +297,59 @@ class="h-12! w-12!"
+ @elseif ($canShowSubstitutionFullModal) + + + Substitutions + {{ $substitutionsTaken }}/6 + + + + +
+
+ Request Substitution + Confirm that you want to request a substitution for this team. +
+
+ + + Cancel + + + Confirm + +
+
+
+ + +
+ No substitutions left +
+ + Return to play + +
+
+
@else assertDontSeeHtml('substitution-team_a'); }); -test('substitution card is not a modal trigger when all 6 substitutions are used', function (): void { +test('substitution card shows full modal trigger when all 6 substitutions are used', function (): void { $game = gameWithActiveSetForTeamRoster(); $state = $game->stateAt(); @@ -404,7 +404,8 @@ 'leftSide' => true, 'gameState' => GameState::fromAttributes(array_merge($state->toAttributes(), ['substitutions_team_a' => 6])), ])->assertSeeHtml('data-team-roster-substitutions') - ->assertDontSeeHtml('substitution-team_a'); + ->assertSeeHtml('substitution-full-confirm-team_a') + ->assertDontSeeHtml('name="substitution-team_a"'); }); test('substitution modal shows on-court and bench player numbers', function (): void { @@ -456,6 +457,26 @@ ->assertNotDispatched('game-event-recorded'); }); +test('substitution is rejected when team has used all 6 substitutions', function (): void { + $game = gameWithActiveSetForTeamRoster(); + + for ($i = 0; $i < 6; $i++) { + $game->recordSubstitution(TeamAB::TeamA, $i + 1, $i + 7); + } + + Livewire::test(TeamRoster::class, [ + 'gameId' => $game->getKey(), + 'team' => TeamAB::TeamA, + 'leftSide' => true, + 'gameState' => $game->stateAt(), + ]) + ->set('playerOut', '1') + ->set('playerIn', '7') + ->call('submitSubstitution') + ->assertHasErrors('substitution') + ->assertNotDispatched('game-event-recorded'); +}); + test('substituted player cannot be substituted again in the same set', function (): void { $game = gameWithActiveSetForTeamRoster(); $game->recordSubstitution(TeamAB::TeamA, 1, 7); From 0b27cdb02ca1a08b2cb4f6a8e3a393f35e5c307d Mon Sep 17 00:00:00 2001 From: Giulio Troccoli-Allard Date: Mon, 11 May 2026 18:05:09 +0100 Subject: [PATCH 48/63] feat: make timeout countdown duration configurable via GAME_TIMEOUT_DURATION Defaults to 30 seconds. Override in .env to shorten during development. Co-Authored-By: Claude Sonnet 4.6 --- .env.example | 2 ++ app/Livewire/TeamRoster.php | 1 + config/game.php | 19 +++++++++++++++++++ .../views/livewire/team-roster.blade.php | 7 ++++--- 4 files changed, 26 insertions(+), 3 deletions(-) create mode 100644 config/game.php diff --git a/.env.example b/.env.example index c0660ea..7e629dd 100644 --- a/.env.example +++ b/.env.example @@ -63,3 +63,5 @@ AWS_BUCKET= AWS_USE_PATH_STYLE_ENDPOINT=false VITE_APP_NAME="${APP_NAME}" + +GAME_TIMEOUT_DURATION=30 diff --git a/app/Livewire/TeamRoster.php b/app/Livewire/TeamRoster.php index 148fa0b..2604741 100644 --- a/app/Livewire/TeamRoster.php +++ b/app/Livewire/TeamRoster.php @@ -243,6 +243,7 @@ public function render(): View 'timeoutsTaken' => $this->timeoutsTaken(), 'substitutionsTaken' => $this->substitutionsTaken(), 'canRequestTimeout' => $this->canRequestTimeout(), + 'timeoutDuration' => config('game.timeout_duration'), 'canRequestSubstitution' => $this->canRequestSubstitution(), 'canShowSubstitutionFullModal' => $this->canShowSubstitutionFullModal(), 'onCourtNumbers' => $onCourtNumbers, diff --git a/config/game.php b/config/game.php new file mode 100644 index 0000000..4e8866b --- /dev/null +++ b/config/game.php @@ -0,0 +1,19 @@ + (int) env('GAME_TIMEOUT_DURATION', 30), + +]; diff --git a/resources/views/livewire/team-roster.blade.php b/resources/views/livewire/team-roster.blade.php index db55344..636e745 100644 --- a/resources/views/livewire/team-roster.blade.php +++ b/resources/views/livewire/team-roster.blade.php @@ -61,11 +61,12 @@ class="flex h-8 w-8 items-center justify-center rounded-full border border-slate x-data="{ countdownOpen: false, phase: 'countdown', - seconds: 30, + duration: {{ $timeoutDuration }}, + seconds: {{ $timeoutDuration }}, timer: null, start() { this.phase = 'countdown'; - this.seconds = 30; + this.seconds = this.duration; this.timer = setInterval(() => { if (--this.seconds <= 0) { clearInterval(this.timer); @@ -76,7 +77,7 @@ class="flex h-8 w-8 items-center justify-center rounded-full border border-slate dismiss() { clearInterval(this.timer); this.phase = 'countdown'; - this.seconds = 30; + this.seconds = this.duration; this.countdownOpen = false; $flux.modal('request-timeout-{{ $team->value }}-countdown').close(); } From 7a40df9fb107490c1bba7439ec69903c53ce3a19 Mon Sep 17 00:00:00 2001 From: Giulio Troccoli-Allard Date: Mon, 11 May 2026 19:27:04 +0100 Subject: [PATCH 49/63] feat: enhance the ControlledGameSeeder seeder so that the game reaches the fifth set --- database/seeders/ControlledGameSeeder.php | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/database/seeders/ControlledGameSeeder.php b/database/seeders/ControlledGameSeeder.php index c4faae0..5d3dc17 100644 --- a/database/seeders/ControlledGameSeeder.php +++ b/database/seeders/ControlledGameSeeder.php @@ -6,6 +6,8 @@ use App\Enums\OfficialRole; use App\Enums\StaffRole; +use App\Enums\TeamAB; +use App\Enums\TeamSide; use App\Models\Championship; use App\Models\Game; use App\Models\Official; @@ -13,13 +15,10 @@ use App\Models\Staff; use App\Models\Team; use Carbon\CarbonImmutable; -use Illuminate\Database\Console\Seeds\WithoutModelEvents; use Illuminate\Database\Seeder; class ControlledGameSeeder extends Seeder { - use WithoutModelEvents; - private const string HOME_TEAM_NAME = 'Dev Home Team'; private const string AWAY_TEAM_NAME = 'Dev Away Team'; @@ -122,5 +121,23 @@ public function run(): void foreach ($awayTeam->staff()->orderBy('id')->get() as $index => $staff) { $game->addStaff($staff, $awayStaffRoles[$index]); } + + $game->recordToss(TeamSide::Home, TeamAB::TeamA); + + $homeLineup = [1 => 1, 2 => 2, 3 => 3, 4 => 4, 5 => 5, 6 => 6]; + $awayLineup = [1 => 20, 2 => 21, 3 => 22, 4 => 23, 5 => 24, 6 => 25]; + + $setWinners = [TeamAB::TeamA, TeamAB::TeamB, TeamAB::TeamA, TeamAB::TeamB]; + + foreach ($setWinners as $index => $winner) { + $setNumber = $index + 1; + $game->recordLineup($setNumber, TeamAB::TeamA, $homeLineup); + $game->recordLineup($setNumber, TeamAB::TeamB, $awayLineup); + $game->recordSetStarted(); + + for ($i = 0; $i < 25; $i++) { + $game->recordRallyWinner($winner); + } + } } } From 734b9646131ec4a558ac4d21cb381ee7136ef90e Mon Sep 17 00:00:00 2001 From: Giulio Troccoli-Allard Date: Tue, 12 May 2026 18:35:11 +0100 Subject: [PATCH 50/63] Handle fifth set toss side selection --- app/Data/GameState/GameState.php | 6 ++ app/Events/Payloads/TossCompletedPayload.php | 5 +- app/Livewire/Court.php | 15 ++--- app/Livewire/LineupSubmission.php | 16 +++-- app/Livewire/RallyWinnerControls.php | 5 +- app/Livewire/Scoreboard.php | 5 +- app/Livewire/StartSetSubmission.php | 3 +- app/Livewire/TossResultSubmission.php | 62 ++++++++++++++--- app/Models/Concerns/RecordsToss.php | 3 +- app/Models/GameStateSnapshot.php | 2 + app/Services/GameSideResolver.php | 37 +++++++++++ .../GameState/GameEventRuleValidator.php | 38 +++++++++-- app/Services/GameState/GameStateProjector.php | 27 +++++++- ...eft_team_to_game_state_snapshots_table.php | 28 ++++++++ resources/views/livewire/court.blade.php | 2 + .../livewire/toss-result-submission.blade.php | 20 +++--- tests/Feature/CourtTest.php | 27 ++++++++ tests/Feature/GameEventTest.php | 22 +++++++ tests/Feature/GameStateProjectionTest.php | 37 +++++++++++ tests/Feature/LineupSubmissionTest.php | 49 +++++++++++++- tests/Feature/StartSetSubmissionTest.php | 18 +++++ tests/Feature/TossResultSubmissionTest.php | 66 +++++++++++++++++++ 22 files changed, 443 insertions(+), 50 deletions(-) create mode 100644 database/migrations/2026_05_12_171528_add_fifth_set_left_team_to_game_state_snapshots_table.php diff --git a/app/Data/GameState/GameState.php b/app/Data/GameState/GameState.php index 36d7227..eb8993b 100644 --- a/app/Data/GameState/GameState.php +++ b/app/Data/GameState/GameState.php @@ -28,6 +28,7 @@ public function __construct( public int $substitutionsTeamA = 0, public int $substitutionsTeamB = 0, public ?TeamSide $teamASide = null, + public ?TeamAB $fifthSetLeftTeam = null, public ?TeamAB $servingTeam = null, public bool $setInProgress = false, public bool $gameEnded = false, @@ -51,6 +52,7 @@ public static function fromSnapshot(GameStateSnapshot $snapshot): self substitutionsTeamA: $snapshot->substitutions_team_a, substitutionsTeamB: $snapshot->substitutions_team_b, teamASide: $snapshot->team_a_side, + fifthSetLeftTeam: $snapshot->fifth_set_left_team, servingTeam: $snapshot->serving_team, setInProgress: $snapshot->set_in_progress, gameEnded: $snapshot->game_ended, @@ -80,6 +82,9 @@ public static function fromAttributes(array $attributes): self teamASide: is_string($attributes['team_a_side'] ?? null) ? TeamSide::tryFrom($attributes['team_a_side']) : null, + fifthSetLeftTeam: is_string($attributes['fifth_set_left_team'] ?? null) + ? TeamAB::tryFrom($attributes['fifth_set_left_team']) + : null, servingTeam: is_string($attributes['serving_team'] ?? null) ? TeamAB::tryFrom($attributes['serving_team']) : null, @@ -116,6 +121,7 @@ public function toAttributes(): array 'substitutions_team_a' => $this->substitutionsTeamA, 'substitutions_team_b' => $this->substitutionsTeamB, 'team_a_side' => $this->teamASide?->value, + 'fifth_set_left_team' => $this->fifthSetLeftTeam?->value, 'serving_team' => $this->servingTeam?->value, 'rotation_team_a' => $this->rotationTeamA, 'rotation_team_b' => $this->rotationTeamB, diff --git a/app/Events/Payloads/TossCompletedPayload.php b/app/Events/Payloads/TossCompletedPayload.php index e8c776e..cb90850 100644 --- a/app/Events/Payloads/TossCompletedPayload.php +++ b/app/Events/Payloads/TossCompletedPayload.php @@ -12,6 +12,7 @@ public function __construct( public TeamSide $teamA, public TeamAB $serving, + public TeamAB $leftTeam = TeamAB::TeamA, ) {} /** @param array $data */ @@ -20,15 +21,17 @@ public static function fromArray(array $data): static return new self( teamA: TeamSide::from($data['team_a']), serving: TeamAB::from($data['serving']), + leftTeam: TeamAB::from($data['left_team'] ?? TeamAB::TeamA->value), ); } - /** @return array{team_a: string, serving: string} */ + /** @return array{team_a: string, serving: string, left_team: string} */ public function toArray(): array { return [ 'team_a' => $this->teamA->value, 'serving' => $this->serving->value, + 'left_team' => $this->leftTeam->value, ]; } } diff --git a/app/Livewire/Court.php b/app/Livewire/Court.php index bf69ee2..e806076 100644 --- a/app/Livewire/Court.php +++ b/app/Livewire/Court.php @@ -43,15 +43,15 @@ public function render(): View private function courtContext(): array { $game = $this->activeGame(); - $completedSetCount = $this->completedSetCount(); - $leftTeam = $this->gameSideResolver()->teamOnLeft($completedSetCount); - $rightTeam = $this->gameSideResolver()->teamOnRight($completedSetCount); + $state = $this->resolvedGameState(); + $leftTeam = $this->gameSideResolver()->teamOnLeftForState($state); + $rightTeam = $this->gameSideResolver()->teamOnRightForState($state); $showRosters = $game !== null && $this->gameSideResolver()->hasRecordedToss($game); return [ 'leftTeam' => $leftTeam, 'rightTeam' => $rightTeam, - 'servingTeam' => $this->resolvedGameState()->servingTeam, + 'servingTeam' => $state->servingTeam, 'showRosters' => $showRosters, 'leftRotation' => $this->rotationForTeam($leftTeam), 'rightRotation' => $this->rotationForTeam($rightTeam), @@ -73,13 +73,6 @@ private function resolvedGameState(): GameState return $this->gameState ?? GameState::initial(); } - private function completedSetCount(): int - { - $state = $this->resolvedGameState(); - - return $state->setsWonTeamA + $state->setsWonTeamB; - } - private function activeGame(): ?Game { if ($this->gameId === null) { diff --git a/app/Livewire/LineupSubmission.php b/app/Livewire/LineupSubmission.php index 4f6144f..610ceb1 100644 --- a/app/Livewire/LineupSubmission.php +++ b/app/Livewire/LineupSubmission.php @@ -9,6 +9,7 @@ use App\Enums\TeamSide; use App\Exceptions\InvalidGameEventTransition; use App\Models\Game; +use App\Services\GameSideResolver; use Flux\Flux; use Illuminate\Contracts\View\View; use Illuminate\Support\Facades\Validator; @@ -24,26 +25,31 @@ class LineupSubmission extends Component #[Locked] public int $gameId; + #[Reactive] public TeamAB $team = TeamAB::TeamA; #[Reactive] public ?GameState $gameState = null; + #[Reactive] + public string $courtSide = 'left'; + /** @var array */ public array $lineup = []; - public function mount(TeamAB $team, ?int $gameId = null): void + public function mount(TeamAB $team, ?int $gameId = null, string $courtSide = 'left'): void { abort_if(is_null($gameId), 404); $this->team = $team; $this->gameId = $gameId; + $this->courtSide = $courtSide; $this->lineup = $this->defaultLineup(); } public function modalName(): string { - return 'submit-lineup-'.$this->team->value; + return sprintf('submit-lineup-%s-%s', $this->team->value, $this->courtSide); } public function modalHeading(): string @@ -239,7 +245,7 @@ private function validateLineup(): bool private function canSubmitLineup(): bool { - if (! $this->hasSubmittedToss()) { + if (! $this->hasRequiredToss()) { return false; } @@ -261,11 +267,11 @@ private function hasSubmittedLineupForUpcomingSet(): bool return $lineup !== []; } - private function hasSubmittedToss(): bool + private function hasRequiredToss(): bool { $state = $this->resolvedGameState(); - return $state->teamASide !== null && $state->servingTeam !== null; + return app(GameSideResolver::class)->hasRequiredToss($state); } private function upcomingSetNumber(): int diff --git a/app/Livewire/RallyWinnerControls.php b/app/Livewire/RallyWinnerControls.php index 83d44fa..fd780a3 100644 --- a/app/Livewire/RallyWinnerControls.php +++ b/app/Livewire/RallyWinnerControls.php @@ -62,9 +62,8 @@ public function recordRallyWinner(string $team): void public function render(): View { $state = $this->gameState ?? GameState::initial(); - $completedSetCount = $state->setsWonTeamA + $state->setsWonTeamB; - $leftTeam = $this->gameSideResolver()->teamOnLeft($completedSetCount); - $rightTeam = $this->gameSideResolver()->teamOnRight($completedSetCount); + $leftTeam = $this->gameSideResolver()->teamOnLeftForState($state); + $rightTeam = $this->gameSideResolver()->teamOnRightForState($state); return view('livewire.rally-winner-controls', [ 'leftTeam' => $leftTeam, diff --git a/app/Livewire/Scoreboard.php b/app/Livewire/Scoreboard.php index 1db8b43..74f3c9f 100644 --- a/app/Livewire/Scoreboard.php +++ b/app/Livewire/Scoreboard.php @@ -27,9 +27,8 @@ class Scoreboard extends Component public function render(): View { $scoreboardState = $this->gameState ?? GameState::initial(); - $completedSets = $scoreboardState->setsWonTeamA + $scoreboardState->setsWonTeamB; - $leftTeam = $this->gameSideResolver()->teamOnLeft($completedSets); - $rightTeam = $this->gameSideResolver()->teamOnRight($completedSets); + $leftTeam = $this->gameSideResolver()->teamOnLeftForState($scoreboardState); + $rightTeam = $this->gameSideResolver()->teamOnRightForState($scoreboardState); $teamCodes = $this->teamCountryCodes(); return view('livewire.scoreboard', [ diff --git a/app/Livewire/StartSetSubmission.php b/app/Livewire/StartSetSubmission.php index 77282c7..f0d9433 100644 --- a/app/Livewire/StartSetSubmission.php +++ b/app/Livewire/StartSetSubmission.php @@ -8,6 +8,7 @@ use App\Enums\TeamAB; use App\Exceptions\InvalidGameEventTransition; use App\Models\Game; +use App\Services\GameSideResolver; use Illuminate\Contracts\View\View; use Livewire\Attributes\Computed; use Livewire\Attributes\Locked; @@ -104,7 +105,7 @@ public function activeGameState(): GameState private function hasSubmittedToss(GameState $state): bool { - return $state->teamASide !== null && $state->servingTeam !== null; + return app(GameSideResolver::class)->hasRequiredToss($state); } private function bothLineupsSubmittedForUpcomingSet(GameState $state): bool diff --git a/app/Livewire/TossResultSubmission.php b/app/Livewire/TossResultSubmission.php index b444ada..05562c1 100644 --- a/app/Livewire/TossResultSubmission.php +++ b/app/Livewire/TossResultSubmission.php @@ -9,6 +9,7 @@ use App\Enums\TeamSide; use App\Exceptions\InvalidGameEventTransition; use App\Models\Game; +use App\Services\GameSideResolver; use Flux\Flux; use Illuminate\Contracts\View\View; use Illuminate\Validation\Rule; @@ -27,6 +28,8 @@ class TossResultSubmission extends Component public string $teamA = TeamSide::Home->value; + public string $left = TeamSide::Home->value; + public string $serving = TeamSide::Home->value; public function mount(?int $gameId = null): void @@ -39,8 +42,10 @@ public function mount(?int $gameId = null): void */ protected function rules(): array { + $sideField = $this->isFifthSetToss() ? 'left' : 'teamA'; + return [ - 'teamA' => ['required', Rule::enum(TeamSide::class)], + $sideField => ['required', Rule::enum(TeamSide::class)], 'serving' => ['required', Rule::enum(TeamSide::class)], ]; } @@ -52,6 +57,7 @@ protected function messages(): array { return [ 'teamA.required' => 'Select whether Team A is home or away.', + 'left.required' => 'Select which team will start on the left.', 'serving.required' => 'Select whether the home or away team serves first.', ]; } @@ -75,13 +81,32 @@ public function submit(): void } try { - $teamASide = TeamSide::from($this->teamA); - $servingSide = TeamSide::from($this->serving); - - $activeGame->recordToss( - teamA: $teamASide, - serving: $servingSide === $teamASide ? TeamAB::TeamA : TeamAB::TeamB, - ); + if ($this->isFifthSetToss()) { + $teamASide = $this->resolvedGameState($activeGame)->teamASide; + + if ($teamASide === null) { + $this->addError('submit', 'The initial toss must be recorded before the fifth set toss.'); + + return; + } + + $leftSide = TeamSide::from($this->left); + $servingSide = TeamSide::from($this->serving); + + $activeGame->recordToss( + teamA: $teamASide, + serving: $this->teamForSide($teamASide, $servingSide), + leftTeam: $this->teamForSide($teamASide, $leftSide), + ); + } else { + $teamASide = TeamSide::from($this->teamA); + $servingSide = TeamSide::from($this->serving); + + $activeGame->recordToss( + teamA: $teamASide, + serving: $this->teamForSide($teamASide, $servingSide), + ); + } } catch (InvalidGameEventTransition $exception) { $this->addError('submit', $exception->getMessage()); @@ -93,6 +118,7 @@ public function submit(): void $this->resetValidation(); $this->teamA = TeamSide::Home->value; + $this->left = TeamSide::Home->value; $this->serving = TeamSide::Home->value; } @@ -102,6 +128,7 @@ public function render(): View return view('livewire.toss-result-submission', [ 'hasSubmittedToss' => $this->hasSubmittedToss($activeGame), + 'isFifthSetToss' => $this->isFifthSetToss(), 'homeTeamCode' => $activeGame?->homeTeam->country_code ?? 'Home Team', 'awayTeamCode' => $activeGame?->awayTeam->country_code ?? 'Away Team', ]); @@ -111,7 +138,7 @@ private function hasSubmittedToss(?Game $activeGame = null): bool { $state = $this->resolvedGameState($activeGame); - return $state->teamASide !== null && $state->servingTeam !== null; + return $this->gameSideResolver()->hasRequiredToss($state); } private function resolvedGameState(?Game $activeGame = null): GameState @@ -140,4 +167,21 @@ private function activeGame(): ?Game ->whereKey($this->gameId) ->first(); } + + private function isFifthSetToss(): bool + { + return $this->gameSideResolver()->requiresFifthSetToss($this->resolvedGameState()); + } + + private function teamForSide(TeamSide $teamASide, TeamSide $selectedSide): TeamAB + { + return $selectedSide === $teamASide + ? TeamAB::TeamA + : TeamAB::TeamB; + } + + private function gameSideResolver(): GameSideResolver + { + return app(GameSideResolver::class); + } } diff --git a/app/Models/Concerns/RecordsToss.php b/app/Models/Concerns/RecordsToss.php index 959a1d1..72a8a81 100644 --- a/app/Models/Concerns/RecordsToss.php +++ b/app/Models/Concerns/RecordsToss.php @@ -16,7 +16,7 @@ */ trait RecordsToss { - public function recordToss(TeamSide $teamA, TeamAB $serving): void + public function recordToss(TeamSide $teamA, TeamAB $serving, ?TeamAB $leftTeam = null): void { app(GameEventRuleValidator::class)->assertCanRecordToss($this); @@ -25,6 +25,7 @@ public function recordToss(TeamSide $teamA, TeamAB $serving): void 'payload' => new TossCompletedPayload( teamA: $teamA, serving: $serving, + leftTeam: $leftTeam ?? TeamAB::TeamA, ), ]); } diff --git a/app/Models/GameStateSnapshot.php b/app/Models/GameStateSnapshot.php index 5f4a7c2..521cf7e 100644 --- a/app/Models/GameStateSnapshot.php +++ b/app/Models/GameStateSnapshot.php @@ -23,6 +23,7 @@ * @property int $substitutions_team_a * @property int $substitutions_team_b * @property TeamSide|null $team_a_side + * @property TeamAB|null $fifth_set_left_team * @property TeamAB|null $serving_team * @property array $rotation_team_a * @property array $rotation_team_b @@ -53,6 +54,7 @@ protected function casts(): array 'substitutions_team_a' => 'integer', 'substitutions_team_b' => 'integer', 'team_a_side' => TeamSide::class, + 'fifth_set_left_team' => TeamAB::class, 'serving_team' => TeamAB::class, 'rotation_team_a' => 'array', 'rotation_team_b' => 'array', diff --git a/app/Services/GameSideResolver.php b/app/Services/GameSideResolver.php index 22e4902..2d68ebd 100644 --- a/app/Services/GameSideResolver.php +++ b/app/Services/GameSideResolver.php @@ -4,6 +4,7 @@ namespace App\Services; +use App\Data\GameState\GameState; use App\Enums\TeamAB; use App\Enums\TeamSide; use App\Models\Game; @@ -49,11 +50,25 @@ public function teamOnLeft(int $completedSets): TeamAB : TeamAB::TeamB; } + public function teamOnLeftForState(GameState $state): TeamAB + { + if (($this->requiresFifthSetToss($state) || $state->setNumber >= 5) && $state->fifthSetLeftTeam !== null) { + return $state->fifthSetLeftTeam; + } + + return $this->teamOnLeft($state->setsWonTeamA + $state->setsWonTeamB); + } + public function teamOnRight(int $completedSets): TeamAB { return $this->oppositeTeam($this->teamOnLeft($completedSets)); } + public function teamOnRightForState(GameState $state): TeamAB + { + return $this->oppositeTeam($this->teamOnLeftForState($state)); + } + public function oppositeTeam(TeamAB $team): TeamAB { return $team === TeamAB::TeamA @@ -74,4 +89,26 @@ private function sideForTeamFromTeamASide(TeamSide $teamASide, TeamAB $team): Te ? $teamASide : $this->oppositeSide($teamASide); } + + public function hasRequiredToss(GameState $state): bool + { + if ($state->teamASide === null || $state->servingTeam === null) { + return false; + } + + if (! $this->requiresFifthSetToss($state)) { + return true; + } + + return $state->fifthSetLeftTeam !== null; + } + + public function requiresFifthSetToss(GameState $state): bool + { + return ! $state->gameEnded + && ! $state->setInProgress + && $state->setNumber === 4 + && $state->setsWonTeamA === 2 + && $state->setsWonTeamB === 2; + } } diff --git a/app/Services/GameState/GameEventRuleValidator.php b/app/Services/GameState/GameEventRuleValidator.php index b875745..ecc4bb2 100644 --- a/app/Services/GameState/GameEventRuleValidator.php +++ b/app/Services/GameState/GameEventRuleValidator.php @@ -4,6 +4,7 @@ namespace App\Services\GameState; +use App\Data\GameState\GameState; use App\Enums\GameEventType; use App\Enums\TeamAB; use App\Exceptions\InvalidGameEventTransition; @@ -19,17 +20,24 @@ public function assertCanRecordToss(Game $game): void { $state = $game->stateAt(); - if ($state->gameEnded || $state->setNumber > 0 || $this->hasRecordedToss($game)) { - $this->fail('The toss can only be recorded once before the first set starts.'); + if (! $this->hasRecordedToss($game)) { + if ($state->gameEnded || $state->setNumber > 0) { + $this->fail('The toss can only be recorded before the first set starts or before the fifth set.'); + } + + return; } + if (! $this->requiresFifthSetToss($state) || $state->fifthSetLeftTeam !== null) { + $this->fail('The toss can only be recorded before the first set starts or before the fifth set.'); + } } public function assertCanRecordSetStarted(Game $game): void { $state = $game->stateAt(); - if (! $this->hasRecordedToss($game)) { + if (! $this->hasRequiredToss($state)) { $this->fail('A set cannot start before the toss has been recorded.'); } @@ -54,7 +62,7 @@ public function assertCanRecordLineup(Game $game, int $set): void { $state = $game->stateAt(); - if (! $this->hasRecordedToss($game)) { + if (! $this->hasRequiredToss($state)) { $this->fail('A lineup cannot be submitted before the toss has been recorded.'); } @@ -139,6 +147,28 @@ private function hasRecordedToss(Game $game): bool ->exists(); } + private function hasRequiredToss(GameState $state): bool + { + if ($state->teamASide === null || $state->servingTeam === null) { + return false; + } + + if (! $this->requiresFifthSetToss($state)) { + return true; + } + + return $state->fifthSetLeftTeam !== null; + } + + private function requiresFifthSetToss(GameState $state): bool + { + return ! $state->gameEnded + && ! $state->setInProgress + && $state->setNumber === 4 + && $state->setsWonTeamA === 2 + && $state->setsWonTeamB === 2; + } + private function hasSubmittedLineupForSet(Game $game, TeamAB $team, int $setNumber): bool { return $game->events() diff --git a/app/Services/GameState/GameStateProjector.php b/app/Services/GameState/GameStateProjector.php index fc68afb..acd0731 100644 --- a/app/Services/GameState/GameStateProjector.php +++ b/app/Services/GameState/GameStateProjector.php @@ -73,6 +73,10 @@ private function applyTossCompleted(GameState $state, GameEvent $event): GameSta $state->teamASide = $payload->teamA; $state->servingTeam = $payload->serving; + if ($this->requiresFifthSetToss($state)) { + $state->fifthSetLeftTeam = $payload->leftTeam; + } + return $state; } @@ -145,7 +149,11 @@ private function applySetStarted(GameState $state, GameEvent $event): GameState { $state->setNumber = max(1, $state->setNumber + 1); $state->resetCurrentSetCounters(); - $state->servingTeam = $this->servingTeamForSet($event->game_id, $state->setNumber) ?? $state->servingTeam; + + if ($state->setNumber !== 5 || $state->fifthSetLeftTeam === null) { + $state->servingTeam = $this->servingTeamForSet($event->game_id, $state->setNumber) ?? $state->servingTeam; + } + $state->setInProgress = true; return $state; @@ -160,7 +168,13 @@ private function applySetEnded(GameState $state, GameEvent $event): GameState } $nextSetNumber = max(1, $state->setNumber + 1); - $state->servingTeam = $this->servingTeamForSet($event->game_id, $nextSetNumber) ?? $state->servingTeam; + + if ($nextSetNumber === 5 && $state->setsWonTeamA === 2 && $state->setsWonTeamB === 2) { + $state->servingTeam = null; + $state->fifthSetLeftTeam = null; + } else { + $state->servingTeam = $this->servingTeamForSet($event->game_id, $nextSetNumber) ?? $state->servingTeam; + } $state->scoreTeamA = 0; $state->scoreTeamB = 0; @@ -275,4 +289,13 @@ private function oppositeTeam(TeamAB $team): TeamAB ? TeamAB::TeamB : TeamAB::TeamA; } + + private function requiresFifthSetToss(GameState $state): bool + { + return ! $state->gameEnded + && ! $state->setInProgress + && $state->setNumber === 4 + && $state->setsWonTeamA === 2 + && $state->setsWonTeamB === 2; + } } diff --git a/database/migrations/2026_05_12_171528_add_fifth_set_left_team_to_game_state_snapshots_table.php b/database/migrations/2026_05_12_171528_add_fifth_set_left_team_to_game_state_snapshots_table.php new file mode 100644 index 0000000..e2e0017 --- /dev/null +++ b/database/migrations/2026_05_12_171528_add_fifth_set_left_team_to_game_state_snapshots_table.php @@ -0,0 +1,28 @@ +string('fifth_set_left_team')->nullable()->after('team_a_side'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('game_state_snapshots', function (Blueprint $table) { + $table->dropColumn('fifth_set_left_team'); + }); + } +}; diff --git a/resources/views/livewire/court.blade.php b/resources/views/livewire/court.blade.php index e2a0578..9e8814c 100644 --- a/resources/views/livewire/court.blade.php +++ b/resources/views/livewire/court.blade.php @@ -86,6 +86,7 @@ class="absolute {{ $markerPositionClass }} flex h-8 w-8 items-center justify-cen :team="$leftTeam" :game-id="$gameId" :game-state="$gameState" + court-side="left" :key="'lineup-submission-left-'.$leftTeam->value" /> - Submit Toss Result + {{ $isFifthSetToss ? 'Submit Fifth Set Toss' : 'Submit Toss Result' }}
- Submit Toss Result + {{ $isFifthSetToss ? 'Submit Fifth Set Toss' : 'Submit Toss Result' }} - Select which side is Team A and which team serves first. + {{ $isFifthSetToss + ? 'Select which team starts on the left and which team serves first.' + : 'Select which side is Team A and which team serves first.' }}
- @error('teamA') + @error($isFifthSetToss ? 'left' : 'teamA') {{ $message }} @enderror @@ -51,7 +53,9 @@ Cancel - Save Toss Result + + {{ $isFifthSetToss ? 'Save Fifth Set Toss' : 'Save Toss Result' }} +
diff --git a/tests/Feature/CourtTest.php b/tests/Feature/CourtTest.php index afb0161..32455dd 100644 --- a/tests/Feature/CourtTest.php +++ b/tests/Feature/CourtTest.php @@ -340,6 +340,33 @@ ]); }); +test('court uses the fifth set toss to place the correct team on the left before lineup submission', function (): void { + $game = gameWithNumberedRosters(); + $game->recordToss(TeamSide::Home, TeamAB::TeamA); + + Livewire::test(Court::class, [ + 'gameId' => $game->getKey(), + 'gameState' => gameState([ + 'set_number' => 4, + 'sets_won_team_a' => 2, + 'sets_won_team_b' => 2, + 'fifth_set_left_team' => TeamAB::TeamB->value, + 'serving_team' => TeamAB::TeamB->value, + 'set_in_progress' => false, + ]), + ]) + ->assertSeeInOrder([ + 'submit-lineup-team_b', + 'submit-lineup-team_a', + ]) + ->assertSeeInOrder([ + '2', + '9', + '3', + '12', + ]); +}); + test('court renders rally winner controls when set is in progress', function (): void { $game = Game::factory()->create(); diff --git a/tests/Feature/GameEventTest.php b/tests/Feature/GameEventTest.php index 764a7f9..cd3d3a5 100644 --- a/tests/Feature/GameEventTest.php +++ b/tests/Feature/GameEventTest.php @@ -88,9 +88,31 @@ function submitLineupsForSet(Game $game, int $set): void expect($event->type)->toBe(GameEventType::TossCompleted) ->and($event->payload)->toBeInstanceOf(TossCompletedPayload::class) ->and($event->payload->teamA)->toBe(TeamSide::Home) + ->and($event->payload->leftTeam)->toBe(TeamAB::TeamA) ->and($event->payload->serving)->toBe(TeamAB::TeamA); }); +test('a fifth set toss keeps team a and team b fixed while changing the left side', function (): void { + $game = Game::factory()->create(); + + ensureStartingLineupRoster($game); + $game->recordToss(TeamSide::Home, TeamAB::TeamA); + + foreach ([TeamAB::TeamA, TeamAB::TeamB, TeamAB::TeamA, TeamAB::TeamB] as $setWinner) { + submitLineupsForSet($game, $game->stateAt()->setNumber + 1); + $game->recordSetStarted(); + winSet($game, $setWinner); + } + + $game->recordToss(TeamSide::Home, TeamAB::TeamB, TeamAB::TeamB); + + $event = $game->events->last(); + + expect($event->payload->teamA)->toBe(TeamSide::Home) + ->and($event->payload->leftTeam)->toBe(TeamAB::TeamB) + ->and($event->payload->serving)->toBe(TeamAB::TeamB); +}); + test('team b is derived as the other team when team a is the away team', function (): void { $homeTeam = Team::factory()->create(); $awayTeam = Team::factory()->create(); diff --git a/tests/Feature/GameStateProjectionTest.php b/tests/Feature/GameStateProjectionTest.php index 392b057..f6fd19e 100644 --- a/tests/Feature/GameStateProjectionTest.php +++ b/tests/Feature/GameStateProjectionTest.php @@ -142,6 +142,43 @@ ->and($state->scoreTeamB)->toBe(0); }); +test('fifth set toss clears the automatic rotation and then drives left side and serving', function (): void { + $homeTeam = Team::factory()->create(); + $awayTeam = Team::factory()->create(); + $game = Game::factory()->betweenTeams($homeTeam, $awayTeam)->create(); + ensureRostersForProjection($game, $homeTeam, $awayTeam); + + $game->recordToss(TeamSide::Home, TeamAB::TeamA); + + foreach ([TeamAB::TeamA, TeamAB::TeamB, TeamAB::TeamA, TeamAB::TeamB] as $setWinner) { + submitProjectionLineupsForSet($game, $game->stateAt()->setNumber + 1); + $game->recordSetStarted(); + + for ($index = 0; $index < 25; $index++) { + $game->recordRallyWinner($setWinner); + } + } + + $stateBeforeFifthSetToss = $game->fresh()->stateAt(); + + expect($stateBeforeFifthSetToss->setNumber)->toBe(4) + ->and($stateBeforeFifthSetToss->setsWonTeamA)->toBe(2) + ->and($stateBeforeFifthSetToss->setsWonTeamB)->toBe(2) + ->and($stateBeforeFifthSetToss->servingTeam)->toBeNull() + ->and($stateBeforeFifthSetToss->fifthSetLeftTeam)->toBeNull(); + + $game->recordToss(TeamSide::Home, TeamAB::TeamB, TeamAB::TeamB); + submitProjectionLineupsForSet($game, 5); + $game->recordSetStarted(); + + $stateAfterFifthSetStart = $game->fresh()->stateAt(); + + expect($stateAfterFifthSetStart->setNumber)->toBe(5) + ->and($stateAfterFifthSetStart->setInProgress)->toBeTrue() + ->and($stateAfterFifthSetStart->servingTeam)->toBe(TeamAB::TeamB) + ->and($stateAfterFifthSetStart->fifthSetLeftTeam)->toBe(TeamAB::TeamB); +}); + test('serving remains on the same court side after a set ends and sides swap', function (): void { $homeTeam = Team::factory()->create(); $awayTeam = Team::factory()->create(); diff --git a/tests/Feature/LineupSubmissionTest.php b/tests/Feature/LineupSubmissionTest.php index 0af9946..0affa99 100644 --- a/tests/Feature/LineupSubmissionTest.php +++ b/tests/Feature/LineupSubmissionTest.php @@ -89,7 +89,7 @@ function prepareGameForLineupSubmission(): Game Livewire::test(LineupSubmission::class, ['team' => TeamAB::TeamA, 'gameId' => $game->getKey()]) ->assertSee('Submit Lineup') ->assertSee('Team A Lineup') - ->assertSeeHtml('submit-lineup-team_a') + ->assertSeeHtml('submit-lineup-team_a-left') ->assertSeeHtml('name="lineup[1]"') ->assertSeeHtml('autofocus') ->assertSeeHtml('name="lineup[6]"') @@ -107,7 +107,7 @@ function prepareGameForLineupSubmission(): Game Livewire::test(LineupSubmission::class, ['team' => TeamAB::TeamB, 'gameId' => $game->getKey()]) ->assertSee('Submit Lineup') ->assertSee('Team B Lineup') - ->assertSeeHtml('submit-lineup-team_b') + ->assertSeeHtml('submit-lineup-team_b-left') ->assertSeeHtml('name="lineup[1]"') ->assertSeeHtml('name="lineup[6]"') ->assertSeeHtml('data-lineup-roster-numbers') @@ -125,6 +125,14 @@ function prepareGameForLineupSubmission(): Game ->toThrow(ViewException::class); }); +test('lineup submission modal name includes the court side to avoid stale modal reuse', function (): void { + $game = prepareGameForLineupSubmission(); + + Livewire::test(LineupSubmission::class, ['team' => TeamAB::TeamB, 'gameId' => $game->getKey(), 'courtSide' => 'right']) + ->assertSeeHtml('submit-lineup-team_b-right') + ->assertDontSeeHtml('submit-lineup-team_b-left'); +}); + test('lineup submission records an event and dispatches a refresh event', function (): void { $game = prepareGameForLineupSubmission(); @@ -234,6 +242,18 @@ function prepareGameForLineupSubmission(): Game ->assertSee('Team B Lineup'); }); +test('lineup submission is hidden before the fifth set toss is submitted', function (): void { + $game = tiedLineupGameReadyForFifthSet(); + + Livewire::test(LineupSubmission::class, [ + 'team' => TeamAB::TeamA, + 'gameId' => $game->getKey(), + 'gameState' => $game->stateAt(), + ]) + ->assertDontSee('Submit Lineup') + ->assertDontSee('Team A Lineup'); +}); + test('lineup submission visibility follows snapshot state without querying lineup events', function (): void { $game = Game::factory()->create(); @@ -278,3 +298,28 @@ function prepareGameForLineupSubmission(): Game ->assertSee('Submit Lineup') ->assertSee('Team B Lineup'); }); + +function tiedLineupGameReadyForFifthSet(): Game +{ + $game = prepareGameForLineupSubmission(); + + foreach ([TeamAB::TeamA, TeamAB::TeamB, TeamAB::TeamA, TeamAB::TeamB] as $setWinner) { + $set = $game->stateAt()->setNumber + 1; + $game->recordLineup($set, TeamAB::TeamA, validLineupPositions()); + $game->recordLineup($set, TeamAB::TeamB, [ + 1 => 11, + 2 => 12, + 3 => 13, + 4 => 14, + 5 => 15, + 6 => 16, + ]); + $game->recordSetStarted(); + + for ($index = 0; $index < 25; $index++) { + $game->recordRallyWinner($setWinner); + } + } + + return $game->fresh(); +} diff --git a/tests/Feature/StartSetSubmissionTest.php b/tests/Feature/StartSetSubmissionTest.php index be39a60..5e2f194 100644 --- a/tests/Feature/StartSetSubmissionTest.php +++ b/tests/Feature/StartSetSubmissionTest.php @@ -105,6 +105,24 @@ ->assertSee('Start Set 1'); }); +test('start set button stays hidden before the fifth set toss is submitted', function (): void { + $game = gameReadyToStartSet(); + + foreach ([TeamAB::TeamA, TeamAB::TeamB, TeamAB::TeamA, TeamAB::TeamB] as $setWinner) { + $set = $game->stateAt()->setNumber + 1; + $game->recordLineup($set, TeamAB::TeamA, lineupPositionsForNumbers(1)); + $game->recordLineup($set, TeamAB::TeamB, lineupPositionsForNumbers(11)); + $game->recordSetStarted(); + + for ($index = 0; $index < 25; $index++) { + $game->recordRallyWinner($setWinner); + } + } + + Livewire::test(StartSetSubmission::class, ['gameId' => $game->getKey(), 'gameState' => $game->stateAt()]) + ->assertDontSee('Start Set 5'); +}); + /** * @return array */ diff --git a/tests/Feature/TossResultSubmissionTest.php b/tests/Feature/TossResultSubmissionTest.php index bf032f5..b0c81d7 100644 --- a/tests/Feature/TossResultSubmissionTest.php +++ b/tests/Feature/TossResultSubmissionTest.php @@ -10,6 +10,7 @@ use App\Models\Game; use App\Models\GameEvent; use App\Models\GameStateSnapshot; +use App\Models\Player; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Carbon; use Livewire\Livewire; @@ -30,9 +31,32 @@ expect($event)->not->toBeNull() ->and($event->type)->toBe(GameEventType::TossCompleted) ->and($event->payload->teamA)->toBe(TeamSide::Away) + ->and($event->payload->leftTeam)->toBe(TeamAB::TeamA) ->and($event->payload->serving)->toBe(TeamAB::TeamB); }); +test('submitting fifth set toss keeps team assignment and records left and serving teams', function (): void { + $game = tiedGameReadyForFifthSetToss(); + + Livewire::test(TossResultSubmission::class, ['gameId' => $game->getKey()]) + ->assertSee('Submit Fifth Set Toss') + ->set('left', TeamSide::Away->value) + ->set('serving', TeamSide::Away->value) + ->call('submit') + ->assertHasNoErrors(); + + $event = $game->fresh()->events->last(); + $state = $game->fresh()->stateAt(); + + expect($event)->not->toBeNull() + ->and($event->type)->toBe(GameEventType::TossCompleted) + ->and($event->payload->teamA)->toBe(TeamSide::Home) + ->and($event->payload->leftTeam)->toBe(TeamAB::TeamB) + ->and($event->payload->serving)->toBe(TeamAB::TeamB) + ->and($state->fifthSetLeftTeam)->toBe(TeamAB::TeamB) + ->and($state->servingTeam)->toBe(TeamAB::TeamB); +}); + test('submitting toss result fails when there is no active game', function (): void { Livewire::test(TossResultSubmission::class) ->call('submit') @@ -121,3 +145,45 @@ ->assertDontSee('Submit Toss Result') ->assertDontSee('Save Toss Result'); }); + +function tiedGameReadyForFifthSetToss(): Game +{ + $game = Game::factory()->create(); + + for ($index = 0; $index < 6; $index++) { + $homePlayer = Player::factory()->for($game->homeTeam)->create(); + $awayPlayer = Player::factory()->for($game->awayTeam)->create(); + $game->addPlayer($homePlayer, number: $index + 1); + $game->addPlayer($awayPlayer, number: $index + 11); + } + + $game->recordToss(TeamSide::Home, TeamAB::TeamA); + + foreach ([TeamAB::TeamA, TeamAB::TeamB, TeamAB::TeamA, TeamAB::TeamB] as $setWinner) { + $set = $game->stateAt()->setNumber + 1; + $game->recordLineup($set, TeamAB::TeamA, tossResultLineupPositions(1)); + $game->recordLineup($set, TeamAB::TeamB, tossResultLineupPositions(11)); + $game->recordSetStarted(); + + for ($rally = 0; $rally < 25; $rally++) { + $game->recordRallyWinner($setWinner); + } + } + + return $game->fresh(); +} + +/** + * @return array + */ +function tossResultLineupPositions(int $start): array +{ + return [ + 1 => $start, + 2 => $start + 1, + 3 => $start + 2, + 4 => $start + 3, + 5 => $start + 4, + 6 => $start + 5, + ]; +} From 3df50faac995fcb5198e6f348dc435cbeac31b7c Mon Sep 17 00:00:00 2001 From: Giulio Troccoli-Allard Date: Tue, 12 May 2026 18:59:55 +0100 Subject: [PATCH 51/63] Add between-set countdown flow --- app/Livewire/StartSetSubmission.php | 80 ++++++++++- .../GameState/GameEventRuleValidator.php | 41 ++++++ config/game.php | 12 ++ database/seeders/ControlledGameSeeder.php | 13 ++ .../livewire/start-set-submission.blade.php | 56 +++++++- tests/Feature/ControlledGameSeederTest.php | 10 ++ tests/Feature/CourtTest.php | 4 + tests/Feature/GameEventTest.php | 4 + tests/Feature/GameStateProjectionTest.php | 4 + tests/Feature/LineupSubmissionTest.php | 4 + tests/Feature/StartSetSubmissionTest.php | 133 +++++++++++++++++- tests/Feature/TossResultSubmissionTest.php | 4 + 12 files changed, 353 insertions(+), 12 deletions(-) diff --git a/app/Livewire/StartSetSubmission.php b/app/Livewire/StartSetSubmission.php index f0d9433..636e5a2 100644 --- a/app/Livewire/StartSetSubmission.php +++ b/app/Livewire/StartSetSubmission.php @@ -5,10 +5,13 @@ namespace App\Livewire; use App\Data\GameState\GameState; +use App\Enums\GameEventType; use App\Enums\TeamAB; use App\Exceptions\InvalidGameEventTransition; use App\Models\Game; +use App\Models\GameEvent; use App\Services\GameSideResolver; +use Carbon\CarbonImmutable; use Illuminate\Contracts\View\View; use Livewire\Attributes\Computed; use Livewire\Attributes\Locked; @@ -41,7 +44,13 @@ public function startSet(): void return; } - if (! $this->canStartSet()) { + if ($this->setBreakIsInProgress()) { + $this->addError('startSet', 'The interval between sets has not elapsed yet.'); + + return; + } + + if (! $this->hasStartSetPrerequisites()) { $this->addError('startSet', 'Both team lineups must be submitted before starting the set.'); return; @@ -60,13 +69,26 @@ public function startSet(): void public function render(): View { + $setBreakRemainingSeconds = $this->setBreakRemainingSeconds(); + $upcomingSetNumber = $this->upcomingSetNumber(); + return view('livewire.start-set-submission', [ 'canStartSet' => $this->canStartSet(), - 'upcomingSetNumber' => $this->upcomingSetNumber(), + 'showSetBreakCountdown' => $setBreakRemainingSeconds > 0, + 'setBreakRemainingSeconds' => $setBreakRemainingSeconds, + 'setBreakCountdownLabel' => $this->formatDuration($setBreakRemainingSeconds), + 'showStartGameButton' => $this->canStartSet() && $upcomingSetNumber === 1, + 'shouldAutoStartSet' => $this->canStartSet() && $upcomingSetNumber > 1, + 'upcomingSetNumber' => $upcomingSetNumber, ]); } private function canStartSet(): bool + { + return $this->hasStartSetPrerequisites() && ! $this->setBreakIsInProgress(); + } + + private function hasStartSetPrerequisites(): bool { if ($this->activeGame() === null) { return false; @@ -128,6 +150,60 @@ private function upcomingSetNumber(): int return $this->activeGameState()->setNumber + 1; } + private function setBreakIsInProgress(): bool + { + return $this->setBreakRemainingSeconds() > 0; + } + + private function setBreakRemainingSeconds(): int + { + $activeGame = $this->activeGame(); + $state = $this->activeGameState(); + + if ($activeGame === null || $state->setNumber === 0 || $state->gameEnded) { + return 0; + } + + $lastSetEndedAt = $this->lastSetEndedAt($activeGame); + + if ($lastSetEndedAt === null) { + return 0; + } + + $remainingSeconds = now()->diffInSeconds( + $lastSetEndedAt->addSeconds($this->betweenSetsDuration()), + false, + ); + + return max(0, (int) $remainingSeconds); + } + + private function lastSetEndedAt(Game $game): ?CarbonImmutable + { + /** @var GameEvent|null $setEndedEvent */ + $setEndedEvent = $game->events() + ->reorder() + ->where('type', GameEventType::SetEnded) + ->orderByDesc('created_at') + ->orderByDesc('id') + ->first(); + + return $setEndedEvent?->created_at; + } + + private function betweenSetsDuration(): int + { + return max(0, (int) config('game.between_sets_duration', 180)); + } + + private function formatDuration(int $seconds): string + { + $minutes = intdiv($seconds, 60); + $remainingSeconds = $seconds % 60; + + return sprintf('%02d:%02d', $minutes, $remainingSeconds); + } + private function resolvedGameState(): GameState { return $this->gameState ?? GameState::initial(); diff --git a/app/Services/GameState/GameEventRuleValidator.php b/app/Services/GameState/GameEventRuleValidator.php index ecc4bb2..c5f292b 100644 --- a/app/Services/GameState/GameEventRuleValidator.php +++ b/app/Services/GameState/GameEventRuleValidator.php @@ -9,6 +9,8 @@ use App\Enums\TeamAB; use App\Exceptions\InvalidGameEventTransition; use App\Models\Game; +use App\Models\GameEvent; +use Carbon\CarbonImmutable; class GameEventRuleValidator { @@ -49,6 +51,10 @@ public function assertCanRecordSetStarted(Game $game): void $this->fail('A set is already in progress.'); } + if ($this->setBreakIsInProgress($game, $state)) { + $this->fail('The interval between sets has not elapsed yet.'); + } + $upcomingSet = $state->setNumber + 1; $hasTeamALineup = $this->hasSubmittedLineupForSet($game, TeamAB::TeamA, $upcomingSet); $hasTeamBLineup = $this->hasSubmittedLineupForSet($game, TeamAB::TeamB, $upcomingSet); @@ -178,6 +184,41 @@ private function hasSubmittedLineupForSet(Game $game, TeamAB $team, int $setNumb ->exists(); } + private function setBreakIsInProgress(Game $game, GameState $state): bool + { + if ($state->setNumber === 0 || $state->gameEnded) { + return false; + } + + $lastSetEndedAt = $this->lastSetEndedAt($game); + + if ($lastSetEndedAt === null) { + return false; + } + + return $lastSetEndedAt + ->addSeconds($this->betweenSetsDuration()) + ->isFuture(); + } + + private function lastSetEndedAt(Game $game): ?CarbonImmutable + { + /** @var GameEvent|null $setEndedEvent */ + $setEndedEvent = $game->events() + ->reorder() + ->where('type', GameEventType::SetEnded) + ->orderByDesc('created_at') + ->orderByDesc('id') + ->first(); + + return $setEndedEvent?->created_at; + } + + private function betweenSetsDuration(): int + { + return max(0, (int) config('game.between_sets_duration', 180)); + } + private function fail(string $message): never { throw new InvalidGameEventTransition($message); diff --git a/config/game.php b/config/game.php index 4e8866b..fa58954 100644 --- a/config/game.php +++ b/config/game.php @@ -4,6 +4,18 @@ return [ + /* + |-------------------------------------------------------------------------- + | Between Sets Duration + |-------------------------------------------------------------------------- + | + | The number of seconds that must elapse between the end of one set and + | the start of the next. This value also drives the on-screen countdown. + | + */ + + 'between_sets_duration' => (int) env('GAME_BETWEEN_SETS_DURATION', 180), + /* |-------------------------------------------------------------------------- | Timeout Duration diff --git a/database/seeders/ControlledGameSeeder.php b/database/seeders/ControlledGameSeeder.php index 5d3dc17..b569074 100644 --- a/database/seeders/ControlledGameSeeder.php +++ b/database/seeders/ControlledGameSeeder.php @@ -16,6 +16,7 @@ use App\Models\Team; use Carbon\CarbonImmutable; use Illuminate\Database\Seeder; +use Illuminate\Support\Facades\Config; class ControlledGameSeeder extends Seeder { @@ -27,6 +28,18 @@ class ControlledGameSeeder extends Seeder * Run the database seeds. */ public function run(): void + { + $betweenSetsDuration = Config::integer('game.between_sets_duration'); + Config::set('game.between_sets_duration', 0); + + try { + $this->seedControlledGame(); + } finally { + Config::set('game.between_sets_duration', $betweenSetsDuration); + } + } + + private function seedControlledGame(): void { $championship = Championship::factory()->named('Dev Controlled Championship')->create(); diff --git a/resources/views/livewire/start-set-submission.blade.php b/resources/views/livewire/start-set-submission.blade.php index f92acb3..f22db26 100644 --- a/resources/views/livewire/start-set-submission.blade.php +++ b/resources/views/livewire/start-set-submission.blade.php @@ -1,19 +1,69 @@
- @if ($canStartSet) + @if ($showSetBreakCountdown) +
+
+ + Next set in {{ $setBreakCountdownLabel }} + +
+ + @error('startSet') + {{ $message }} + @enderror +
+ @elseif ($showStartGameButton)
- Start Set {{ $upcomingSetNumber }} + Start Game @error('startSet') {{ $message }} @enderror
+ @elseif ($shouldAutoStartSet) +
@endif
diff --git a/tests/Feature/ControlledGameSeederTest.php b/tests/Feature/ControlledGameSeederTest.php index 718ca9a..1eb7d6e 100644 --- a/tests/Feature/ControlledGameSeederTest.php +++ b/tests/Feature/ControlledGameSeederTest.php @@ -13,6 +13,8 @@ uses(RefreshDatabase::class); test('controlled game seeder creates predictable teams and game rosters', function (): void { + config()->set('game.between_sets_duration', 180); + $this->seed(ControlledGameSeeder::class); $game = Game::query()->with(['homeTeam', 'awayTeam', 'players', 'staff', 'officials'])->sole(); @@ -74,3 +76,11 @@ expect($game->officials)->toHaveCount(count(OfficialRole::cases())); }); + +test('controlled game seeder restores the configured between-sets interval after seeding', function (): void { + config()->set('game.between_sets_duration', 180); + + $this->seed(ControlledGameSeeder::class); + + expect(config('game.between_sets_duration'))->toBe(180); +}); diff --git a/tests/Feature/CourtTest.php b/tests/Feature/CourtTest.php index 32455dd..9984e77 100644 --- a/tests/Feature/CourtTest.php +++ b/tests/Feature/CourtTest.php @@ -17,6 +17,10 @@ uses(RefreshDatabase::class); +beforeEach(function (): void { + config()->set('game.between_sets_duration', 0); +}); + test('court does not show player lists before toss is submitted', function (): void { $game = gameWithNumberedRosters(); diff --git a/tests/Feature/GameEventTest.php b/tests/Feature/GameEventTest.php index cd3d3a5..9d1b928 100644 --- a/tests/Feature/GameEventTest.php +++ b/tests/Feature/GameEventTest.php @@ -22,6 +22,10 @@ uses(RefreshDatabase::class); +beforeEach(function (): void { + config()->set('game.between_sets_duration', 0); +}); + function prepareActiveSet(Game $game): void { $game->recordToss(TeamSide::Home, TeamAB::TeamA); diff --git a/tests/Feature/GameStateProjectionTest.php b/tests/Feature/GameStateProjectionTest.php index f6fd19e..44fd7ab 100644 --- a/tests/Feature/GameStateProjectionTest.php +++ b/tests/Feature/GameStateProjectionTest.php @@ -16,6 +16,10 @@ uses(RefreshDatabase::class); +beforeEach(function (): void { + config()->set('game.between_sets_duration', 0); +}); + test('state snapshots are projected as game events are recorded', function (): void { $homeTeam = Team::factory()->create(); $awayTeam = Team::factory()->create(); diff --git a/tests/Feature/LineupSubmissionTest.php b/tests/Feature/LineupSubmissionTest.php index 0affa99..3da0a9f 100644 --- a/tests/Feature/LineupSubmissionTest.php +++ b/tests/Feature/LineupSubmissionTest.php @@ -21,6 +21,10 @@ uses(RefreshDatabase::class); +beforeEach(function (): void { + config()->set('game.between_sets_duration', 0); +}); + /** * @return array */ diff --git a/tests/Feature/StartSetSubmissionTest.php b/tests/Feature/StartSetSubmissionTest.php index 5e2f194..2c6c859 100644 --- a/tests/Feature/StartSetSubmissionTest.php +++ b/tests/Feature/StartSetSubmissionTest.php @@ -18,31 +18,62 @@ uses(RefreshDatabase::class); +beforeEach(function (): void { + config()->set('game.between_sets_duration', 0); +}); + test('start set button is visible only when both team lineups are submitted for the upcoming set', function (): void { $game = gameReadyToStartSet(); $game->recordLineup(1, TeamAB::TeamA, lineupPositionsForNumbers(1)); Livewire::test(StartSetSubmission::class, ['gameId' => $game->getKey(), 'gameState' => $game->stateAt()]) - ->assertDontSee('Start Set 1'); + ->assertDontSee('Start Game'); $game->recordLineup(1, TeamAB::TeamB, lineupPositionsForNumbers(11)); Livewire::test(StartSetSubmission::class, ['gameId' => $game->getKey(), 'gameState' => $game->stateAt()]) - ->assertSee('Start Set 1'); + ->assertSee('Start Game'); +}); + +test('start set shows a break countdown after a set ends even when the next lineups are already submitted', function (): void { + config()->set('game.between_sets_duration', 180); + + $game = gameReadyToStartSet(); + $game->recordLineup(1, TeamAB::TeamA, lineupPositionsForNumbers(1)); + $game->recordLineup(1, TeamAB::TeamB, lineupPositionsForNumbers(11)); + + $this->freezeSecond(function () use ($game): void { + $game->recordSetStarted(); + + for ($index = 0; $index < 25; $index++) { + $game->recordRallyWinner(TeamAB::TeamA); + } + + $game->recordLineup(2, TeamAB::TeamA, lineupPositionsForNumbers(1)); + $game->recordLineup(2, TeamAB::TeamB, lineupPositionsForNumbers(11)); + + Livewire::test(StartSetSubmission::class, ['gameId' => $game->getKey(), 'gameState' => $game->stateAt()]) + ->assertDontSee('Start Set 2') + ->assertSee('Next set in') + ->assertSee('03:00') + ->assertSeeHtml('data-set-break-countdown'); + }); }); test('start set button records set started event and dispatches refresh event', function (): void { + config()->set('game.between_sets_duration', 0); + $game = gameReadyToStartSet(); $game->recordLineup(1, TeamAB::TeamA, lineupPositionsForNumbers(1)); $game->recordLineup(1, TeamAB::TeamB, lineupPositionsForNumbers(11)); Livewire::test(StartSetSubmission::class, ['gameId' => $game->getKey(), 'gameState' => $game->stateAt()]) - ->assertSee('Start Set 1') + ->assertSee('Start Game') ->call('startSet') ->assertHasNoErrors() ->assertDispatched('game-event-recorded') - ->assertDontSee('Start Set 1'); + ->assertDontSee('Start Game'); $freshGame = $game->fresh(); $latestEvent = $freshGame->events->last(); @@ -53,7 +84,35 @@ ->and($freshGame->stateAt()->setInProgress)->toBeTrue(); }); -test('start set button label uses the next dynamic set number', function (): void { +test('start set cannot be recorded before the break countdown elapses', function (): void { + config()->set('game.between_sets_duration', 180); + + $game = gameReadyToStartSet(); + $game->recordLineup(1, TeamAB::TeamA, lineupPositionsForNumbers(1)); + $game->recordLineup(1, TeamAB::TeamB, lineupPositionsForNumbers(11)); + + $this->freezeSecond(function () use ($game): void { + $game->recordSetStarted(); + + for ($index = 0; $index < 25; $index++) { + $game->recordRallyWinner(TeamAB::TeamA); + } + + $game->recordLineup(2, TeamAB::TeamA, lineupPositionsForNumbers(1)); + $game->recordLineup(2, TeamAB::TeamB, lineupPositionsForNumbers(11)); + + Livewire::test(StartSetSubmission::class, ['gameId' => $game->getKey(), 'gameState' => $game->stateAt()]) + ->call('startSet') + ->assertHasErrors(['startSet']); + + expect($game->fresh()->stateAt()->setInProgress)->toBeFalse() + ->and($game->fresh()->stateAt()->setNumber)->toBe(1); + }); +}); + +test('start set button is only shown for the opening set', function (): void { + config()->set('game.between_sets_duration', 0); + $game = gameReadyToStartSet(); $game->recordLineup(1, TeamAB::TeamA, lineupPositionsForNumbers(1)); $game->recordLineup(1, TeamAB::TeamB, lineupPositionsForNumbers(11)); @@ -67,10 +126,39 @@ $game->recordLineup(2, TeamAB::TeamB, lineupPositionsForNumbers(11)); Livewire::test(StartSetSubmission::class, ['gameId' => $game->getKey(), 'gameState' => $game->stateAt()]) - ->assertSee('Start Set 2'); + ->assertDontSee('Start Game') + ->assertSeeHtml('x-init="$wire.startSet()"'); +}); + +test('start set button becomes visible after the break countdown elapses', function (): void { + config()->set('game.between_sets_duration', 180); + + $game = gameReadyToStartSet(); + $game->recordLineup(1, TeamAB::TeamA, lineupPositionsForNumbers(1)); + $game->recordLineup(1, TeamAB::TeamB, lineupPositionsForNumbers(11)); + + $this->freezeSecond(function () use ($game): void { + $game->recordSetStarted(); + + for ($index = 0; $index < 25; $index++) { + $game->recordRallyWinner(TeamAB::TeamA); + } + + $game->recordLineup(2, TeamAB::TeamA, lineupPositionsForNumbers(1)); + $game->recordLineup(2, TeamAB::TeamB, lineupPositionsForNumbers(11)); + + $this->travel(3)->minutes(); + + Livewire::test(StartSetSubmission::class, ['gameId' => $game->getKey(), 'gameState' => $game->stateAt()]) + ->assertDontSee('Start Game') + ->assertDontSee('Next set in') + ->assertSeeHtml('x-init="$wire.startSet()"'); + }); }); test('start set button visibility follows snapshot state without querying lineup events', function (): void { + config()->set('game.between_sets_duration', 0); + $game = Game::factory()->create(); $stateEvent = GameEvent::withoutEvents(fn (): GameEvent => GameEvent::query()->create([ @@ -102,10 +190,12 @@ ]); Livewire::test(StartSetSubmission::class, ['gameId' => $game->getKey()]) - ->assertSee('Start Set 1'); + ->assertSee('Start Game'); }); test('start set button stays hidden before the fifth set toss is submitted', function (): void { + config()->set('game.between_sets_duration', 0); + $game = gameReadyToStartSet(); foreach ([TeamAB::TeamA, TeamAB::TeamB, TeamAB::TeamA, TeamAB::TeamB] as $setWinner) { @@ -123,6 +213,35 @@ ->assertDontSee('Start Set 5'); }); +test('start set shows the break countdown before the fifth set toss is submitted', function (): void { + config()->set('game.between_sets_duration', 0); + + $game = gameReadyToStartSet(); + + foreach ([TeamAB::TeamA, TeamAB::TeamB, TeamAB::TeamA, TeamAB::TeamB] as $setWinner) { + $set = $game->stateAt()->setNumber + 1; + $game->recordLineup($set, TeamAB::TeamA, lineupPositionsForNumbers(1)); + $game->recordLineup($set, TeamAB::TeamB, lineupPositionsForNumbers(11)); + + $this->freezeSecond(function () use ($game, $setWinner): void { + $game->recordSetStarted(); + + for ($index = 0; $index < 25; $index++) { + $game->recordRallyWinner($setWinner); + } + }); + } + + config()->set('game.between_sets_duration', 180); + + $this->freezeSecond(function () use ($game): void { + Livewire::test(StartSetSubmission::class, ['gameId' => $game->getKey(), 'gameState' => $game->stateAt()]) + ->assertDontSee('Start Game') + ->assertSee('Next set in') + ->assertSeeHtml('data-set-break-countdown'); + }); +}); + /** * @return array */ diff --git a/tests/Feature/TossResultSubmissionTest.php b/tests/Feature/TossResultSubmissionTest.php index b0c81d7..2dbe1ce 100644 --- a/tests/Feature/TossResultSubmissionTest.php +++ b/tests/Feature/TossResultSubmissionTest.php @@ -17,6 +17,10 @@ uses(RefreshDatabase::class); +beforeEach(function (): void { + config()->set('game.between_sets_duration', 0); +}); + test('submitting toss result creates a toss completed event', function (): void { $game = Game::factory()->create(); From 6d1c19b7ca413afa6bbe704616873b026ad2daa7 Mon Sep 17 00:00:00 2001 From: Giulio Troccoli-Allard Date: Wed, 13 May 2026 17:54:18 +0100 Subject: [PATCH 52/63] Handle fifth-set side change --- app/Data/GameState/GameState.php | 4 ++ app/Enums/GameEventType.php | 1 + .../Payloads/CourtSidesSwappedPayload.php | 20 +++++++ app/Livewire/Game.php | 26 +++++++++ app/Models/Concerns/RecordsCourtSideSwap.php | 26 +++++++++ app/Models/Game.php | 2 + app/Models/GameEvent.php | 2 + app/Models/GameStateSnapshot.php | 2 + app/Services/GameSideResolver.php | 9 +++ .../GameState/GameEventRuleValidator.php | 21 +++++++ app/Services/GameState/GameStateProjector.php | 18 ++++++ ..._swapped_to_game_state_snapshots_table.php | 28 +++++++++ resources/views/livewire/game.blade.php | 16 ++++++ tests/Feature/CourtTest.php | 24 ++++++++ tests/Feature/GameComponentStateTest.php | 4 ++ tests/Feature/GameEventReactorTest.php | 5 ++ tests/Feature/GameEventTest.php | 57 +++++++++++++++++++ tests/Feature/GamePageTest.php | 56 +++++++++++++++++- tests/Feature/GameStateProjectionTest.php | 37 ++++++++++++ tests/Feature/ScoreboardTest.php | 37 ++++++++++++ tests/Feature/TeamRosterTest.php | 4 ++ 21 files changed, 398 insertions(+), 1 deletion(-) create mode 100644 app/Events/Payloads/CourtSidesSwappedPayload.php create mode 100644 app/Models/Concerns/RecordsCourtSideSwap.php create mode 100644 database/migrations/2026_05_13_164126_add_fifth_set_side_swapped_to_game_state_snapshots_table.php diff --git a/app/Data/GameState/GameState.php b/app/Data/GameState/GameState.php index eb8993b..47aceba 100644 --- a/app/Data/GameState/GameState.php +++ b/app/Data/GameState/GameState.php @@ -29,6 +29,7 @@ public function __construct( public int $substitutionsTeamB = 0, public ?TeamSide $teamASide = null, public ?TeamAB $fifthSetLeftTeam = null, + public bool $fifthSetSideSwapped = false, public ?TeamAB $servingTeam = null, public bool $setInProgress = false, public bool $gameEnded = false, @@ -53,6 +54,7 @@ public static function fromSnapshot(GameStateSnapshot $snapshot): self substitutionsTeamB: $snapshot->substitutions_team_b, teamASide: $snapshot->team_a_side, fifthSetLeftTeam: $snapshot->fifth_set_left_team, + fifthSetSideSwapped: $snapshot->fifth_set_side_swapped, servingTeam: $snapshot->serving_team, setInProgress: $snapshot->set_in_progress, gameEnded: $snapshot->game_ended, @@ -85,6 +87,7 @@ public static function fromAttributes(array $attributes): self fifthSetLeftTeam: is_string($attributes['fifth_set_left_team'] ?? null) ? TeamAB::tryFrom($attributes['fifth_set_left_team']) : null, + fifthSetSideSwapped: (bool) ($attributes['fifth_set_side_swapped'] ?? false), servingTeam: is_string($attributes['serving_team'] ?? null) ? TeamAB::tryFrom($attributes['serving_team']) : null, @@ -122,6 +125,7 @@ public function toAttributes(): array 'substitutions_team_b' => $this->substitutionsTeamB, 'team_a_side' => $this->teamASide?->value, 'fifth_set_left_team' => $this->fifthSetLeftTeam?->value, + 'fifth_set_side_swapped' => $this->fifthSetSideSwapped, 'serving_team' => $this->servingTeam?->value, 'rotation_team_a' => $this->rotationTeamA, 'rotation_team_b' => $this->rotationTeamB, diff --git a/app/Enums/GameEventType.php b/app/Enums/GameEventType.php index 964e465..9c088ec 100644 --- a/app/Enums/GameEventType.php +++ b/app/Enums/GameEventType.php @@ -9,6 +9,7 @@ enum GameEventType: string case TossCompleted = 'toss_completed'; case LineupSubmitted = 'lineup_submitted'; case RallyEnded = 'rally_ended'; + case CourtSidesSwapped = 'court_sides_swapped'; case SubstitutionCompleted = 'substitution_completed'; case TimeOutRequested = 'time_out_requested'; case SetStarted = 'set_started'; diff --git a/app/Events/Payloads/CourtSidesSwappedPayload.php b/app/Events/Payloads/CourtSidesSwappedPayload.php new file mode 100644 index 0000000..8e0545b --- /dev/null +++ b/app/Events/Payloads/CourtSidesSwappedPayload.php @@ -0,0 +1,20 @@ + $data */ + public static function fromArray(array $data): static + { + return new self; + } + + /** @return array */ + public function toArray(): array + { + return []; + } +} diff --git a/app/Livewire/Game.php b/app/Livewire/Game.php index c5be9f9..3279892 100644 --- a/app/Livewire/Game.php +++ b/app/Livewire/Game.php @@ -28,6 +28,8 @@ class Game extends Component public ?int $finalScoreLoser = null; + public bool $showFifthSetSideChangePrompt = false; + public function mount(GameModel $game): void { $this->gameId = $game->getKey(); @@ -42,9 +44,11 @@ public function synchronizeGameContext(): void $previousSetsWonTeamA = isset($this->gameState) ? $this->gameState->setsWonTeamA : 0; $previousScoreTeamA = isset($this->gameState) ? $this->gameState->scoreTeamA : 0; $previousScoreTeamB = isset($this->gameState) ? $this->gameState->scoreTeamB : 0; + $previousShowFifthSetSideChangePrompt = $this->showFifthSetSideChangePrompt; $game = GameModel::query()->findSole($this->gameId); $this->gameState = $game->stateAt(); + $this->showFifthSetSideChangePrompt = $this->gameSideResolver()->shouldPromptForFifthSetSideSwap($this->gameState); if ($wasSetInProgress && ! $this->gameState->setInProgress && ! $this->gameState->gameEnded) { $winnerIsTeamA = $this->gameState->setsWonTeamA > $previousSetsWonTeamA; @@ -55,6 +59,14 @@ public function synchronizeGameContext(): void $this->finalScoreLoser = $winnerIsTeamA ? $previousScoreTeamB : $previousScoreTeamA; Flux::modal('set-ended')->show(); } + + if ($this->showFifthSetSideChangePrompt) { + if (! $previousShowFifthSetSideChangePrompt) { + Flux::modal('fifth-set-side-change')->show(); + } + } else { + Flux::modal('fifth-set-side-change')->close(); + } } public function acknowledgeSetEnd(): void @@ -66,6 +78,14 @@ public function acknowledgeSetEnd(): void Flux::modal('set-ended')->close(); } + public function acknowledgeFifthSetSideChange(): void + { + $game = GameModel::query()->findSole($this->gameId); + $game->recordCourtSidesSwapped(); + $this->synchronizeGameContext(); + $this->dispatch('game-event-recorded'); + } + public function render(): View { return view('livewire.game', [ @@ -73,6 +93,7 @@ public function render(): View 'setWinnerCode' => $this->setWinnerCode, 'finalScoreWinner' => $this->finalScoreWinner, 'finalScoreLoser' => $this->finalScoreLoser, + 'showFifthSetSideChangePrompt' => $this->showFifthSetSideChangePrompt, ]); } @@ -84,4 +105,9 @@ private function countryCodeForTeam(GameModel $game, TeamAB $team): string ? $game->homeTeam->country_code : $game->awayTeam->country_code; } + + private function gameSideResolver(): GameSideResolver + { + return app(GameSideResolver::class); + } } diff --git a/app/Models/Concerns/RecordsCourtSideSwap.php b/app/Models/Concerns/RecordsCourtSideSwap.php new file mode 100644 index 0000000..1123147 --- /dev/null +++ b/app/Models/Concerns/RecordsCourtSideSwap.php @@ -0,0 +1,26 @@ +assertCanRecordCourtSidesSwapped($this); + + $this->events()->create([ + 'type' => GameEventType::CourtSidesSwapped, + 'payload' => new CourtSidesSwappedPayload, + ]); + } +} diff --git a/app/Models/Game.php b/app/Models/Game.php index 1466af2..7cf6c55 100644 --- a/app/Models/Game.php +++ b/app/Models/Game.php @@ -7,6 +7,7 @@ use App\Data\GameState\GameState; use App\Enums\OfficialRole; use App\Enums\StaffRole; +use App\Models\Concerns\RecordsCourtSideSwap; use App\Models\Concerns\RecordsEndOfGame; use App\Models\Concerns\RecordsEndOfRally; use App\Models\Concerns\RecordsEndOfSet; @@ -56,6 +57,7 @@ class Game extends Model /** @use HasFactory */ use HasFactory; + use RecordsCourtSideSwap; use RecordsEndOfGame; use RecordsEndOfRally; use RecordsEndOfSet; diff --git a/app/Models/GameEvent.php b/app/Models/GameEvent.php index 7b71031..a76d539 100644 --- a/app/Models/GameEvent.php +++ b/app/Models/GameEvent.php @@ -5,6 +5,7 @@ namespace App\Models; use App\Enums\GameEventType; +use App\Events\Payloads\CourtSidesSwappedPayload; use App\Events\Payloads\GameEndedPayload; use App\Events\Payloads\GameEventPayload; use App\Events\Payloads\LineupSubmittedPayload; @@ -64,6 +65,7 @@ public function payload(): Attribute GameEventType::TossCompleted => TossCompletedPayload::fromArray($data), GameEventType::LineupSubmitted => LineupSubmittedPayload::fromArray($data), GameEventType::RallyEnded => RallyEndedPayload::fromArray($data), + GameEventType::CourtSidesSwapped => CourtSidesSwappedPayload::fromArray($data), GameEventType::SubstitutionCompleted => SubstitutionCompletedPayload::fromArray($data), GameEventType::TimeOutRequested => TimeOutRequestedPayload::fromArray($data), GameEventType::SetStarted => SetStartedPayload::fromArray($data), diff --git a/app/Models/GameStateSnapshot.php b/app/Models/GameStateSnapshot.php index 521cf7e..aa2c8c3 100644 --- a/app/Models/GameStateSnapshot.php +++ b/app/Models/GameStateSnapshot.php @@ -24,6 +24,7 @@ * @property int $substitutions_team_b * @property TeamSide|null $team_a_side * @property TeamAB|null $fifth_set_left_team + * @property bool $fifth_set_side_swapped * @property TeamAB|null $serving_team * @property array $rotation_team_a * @property array $rotation_team_b @@ -55,6 +56,7 @@ protected function casts(): array 'substitutions_team_b' => 'integer', 'team_a_side' => TeamSide::class, 'fifth_set_left_team' => TeamAB::class, + 'fifth_set_side_swapped' => 'boolean', 'serving_team' => TeamAB::class, 'rotation_team_a' => 'array', 'rotation_team_b' => 'array', diff --git a/app/Services/GameSideResolver.php b/app/Services/GameSideResolver.php index 2d68ebd..9ea7217 100644 --- a/app/Services/GameSideResolver.php +++ b/app/Services/GameSideResolver.php @@ -111,4 +111,13 @@ public function requiresFifthSetToss(GameState $state): bool && $state->setsWonTeamA === 2 && $state->setsWonTeamB === 2; } + + public function shouldPromptForFifthSetSideSwap(GameState $state): bool + { + return $state->setInProgress + && $state->setNumber === 5 + && ! $state->gameEnded + && ! $state->fifthSetSideSwapped + && max($state->scoreTeamA, $state->scoreTeamB) >= 8; + } } diff --git a/app/Services/GameState/GameEventRuleValidator.php b/app/Services/GameState/GameEventRuleValidator.php index c5f292b..eb39dc1 100644 --- a/app/Services/GameState/GameEventRuleValidator.php +++ b/app/Services/GameState/GameEventRuleValidator.php @@ -96,6 +96,27 @@ public function assertCanRecordRally(Game $game): void } } + public function assertCanRecordCourtSidesSwapped(Game $game): void + { + $state = $game->stateAt(); + + if ($state->gameEnded || ! $state->setInProgress || $state->setNumber !== 5) { + $this->fail('Court sides can only be swapped during the fifth set.'); + } + + if ($state->fifthSetLeftTeam === null) { + $this->fail('Court sides cannot be swapped before the fifth set toss has been recorded.'); + } + + if ($state->fifthSetSideSwapped) { + $this->fail('Court sides have already been swapped in the fifth set.'); + } + + if (max($state->scoreTeamA, $state->scoreTeamB) < 8) { + $this->fail('Court sides can only be swapped once a team reaches 8 points in the fifth set.'); + } + } + public function assertCanRecordSubstitution(Game $game): void { $state = $game->stateAt(); diff --git a/app/Services/GameState/GameStateProjector.php b/app/Services/GameState/GameStateProjector.php index acd0731..2748e88 100644 --- a/app/Services/GameState/GameStateProjector.php +++ b/app/Services/GameState/GameStateProjector.php @@ -7,6 +7,7 @@ use App\Data\GameState\GameState; use App\Enums\GameEventType; use App\Enums\TeamAB; +use App\Events\Payloads\CourtSidesSwappedPayload; use App\Events\Payloads\LineupSubmittedPayload; use App\Events\Payloads\RallyEndedPayload; use App\Events\Payloads\SubstitutionCompletedPayload; @@ -27,6 +28,7 @@ public function project(GameState $state, GameEvent $event): GameState GameEventType::TossCompleted => $this->applyTossCompleted($state, $event), GameEventType::LineupSubmitted => $this->applyLineupSubmitted($state, $event), GameEventType::RallyEnded => $this->applyRallyEnded($state, $event), + GameEventType::CourtSidesSwapped => $this->applyCourtSidesSwapped($state, $event), GameEventType::SubstitutionCompleted => $this->applySubstitutionCompleted($state, $event), GameEventType::TimeOutRequested => $this->applyTimeOutRequested($state, $event), GameEventType::SetStarted => $this->applySetStarted($state, $event), @@ -75,11 +77,26 @@ private function applyTossCompleted(GameState $state, GameEvent $event): GameSta if ($this->requiresFifthSetToss($state)) { $state->fifthSetLeftTeam = $payload->leftTeam; + $state->fifthSetSideSwapped = false; } return $state; } + private function applyCourtSidesSwapped(GameState $state, GameEvent $event): GameState + { + /** @var CourtSidesSwappedPayload $payload */ + $payload = $event->payload; + + if ($state->fifthSetLeftTeam !== null) { + $state->fifthSetLeftTeam = $this->oppositeTeam($state->fifthSetLeftTeam); + } + + $state->fifthSetSideSwapped = true; + + return $state; + } + private function applyLineupSubmitted(GameState $state, GameEvent $event): GameState { /** @var LineupSubmittedPayload $payload */ @@ -172,6 +189,7 @@ private function applySetEnded(GameState $state, GameEvent $event): GameState if ($nextSetNumber === 5 && $state->setsWonTeamA === 2 && $state->setsWonTeamB === 2) { $state->servingTeam = null; $state->fifthSetLeftTeam = null; + $state->fifthSetSideSwapped = false; } else { $state->servingTeam = $this->servingTeamForSet($event->game_id, $nextSetNumber) ?? $state->servingTeam; } diff --git a/database/migrations/2026_05_13_164126_add_fifth_set_side_swapped_to_game_state_snapshots_table.php b/database/migrations/2026_05_13_164126_add_fifth_set_side_swapped_to_game_state_snapshots_table.php new file mode 100644 index 0000000..39768c5 --- /dev/null +++ b/database/migrations/2026_05_13_164126_add_fifth_set_side_swapped_to_game_state_snapshots_table.php @@ -0,0 +1,28 @@ +boolean('fifth_set_side_swapped')->default(false)->after('fifth_set_left_team'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('game_state_snapshots', function (Blueprint $table) { + $table->dropColumn('fifth_set_side_swapped'); + }); + } +}; diff --git a/resources/views/livewire/game.blade.php b/resources/views/livewire/game.blade.php index 0605a18..de671cb 100644 --- a/resources/views/livewire/game.blade.php +++ b/resources/views/livewire/game.blade.php @@ -23,4 +23,20 @@ class="relative flex min-h-[680px] w-full max-w-[1536px] items-center justify-ce
+ + +
+ Teams to change court +
+ Dismiss +
+
+
diff --git a/tests/Feature/CourtTest.php b/tests/Feature/CourtTest.php index 9984e77..3032a9d 100644 --- a/tests/Feature/CourtTest.php +++ b/tests/Feature/CourtTest.php @@ -371,6 +371,30 @@ ]); }); +test('court swaps sides in the fifth set after the 8-point side change', function (): void { + $game = gameWithNumberedRosters(); + $game->recordToss(TeamSide::Home, TeamAB::TeamA); + + Livewire::test(Court::class, [ + 'gameId' => $game->getKey(), + 'gameState' => gameState([ + 'set_number' => 5, + 'sets_won_team_a' => 2, + 'sets_won_team_b' => 2, + 'fifth_set_left_team' => TeamAB::TeamB->value, + 'fifth_set_side_swapped' => true, + 'serving_team' => TeamAB::TeamA->value, + 'rotation_team_a' => [1 => 12], + 'rotation_team_b' => [1 => 9], + 'set_in_progress' => true, + ]), + ]) + ->assertSeeHtml('data-court-marker="left-team_b-1"') + ->assertSeeHtml('data-court-marker="right-team_a-1"') + ->assertSeeHtml('data-court-serving-player="1"') + ->assertSeeHtml('-right-10 top-[14%]'); +}); + test('court renders rally winner controls when set is in progress', function (): void { $game = Game::factory()->create(); diff --git a/tests/Feature/GameComponentStateTest.php b/tests/Feature/GameComponentStateTest.php index 9b6e05d..1324e37 100644 --- a/tests/Feature/GameComponentStateTest.php +++ b/tests/Feature/GameComponentStateTest.php @@ -13,6 +13,10 @@ uses(RefreshDatabase::class); +beforeEach(function (): void { + config()->set('game.between_sets_duration', 0); +}); + test('game component hydrates state from the passed game', function (): void { $game = GameModel::factory()->create(); $game->recordToss(TeamSide::Away, TeamAB::TeamB); diff --git a/tests/Feature/GameEventReactorTest.php b/tests/Feature/GameEventReactorTest.php index a1006d4..fc631f6 100644 --- a/tests/Feature/GameEventReactorTest.php +++ b/tests/Feature/GameEventReactorTest.php @@ -10,6 +10,10 @@ uses(RefreshDatabase::class); +beforeEach(function (): void { + config()->set('game.between_sets_duration', 0); +}); + test('in sets one to four the set auto-ends at 25 points with a two-point lead', function (): void { $homeTeam = Team::factory()->create(); $awayTeam = Team::factory()->create(); @@ -56,6 +60,7 @@ } } + $game->recordToss(TeamSide::Home, TeamAB::TeamA, TeamAB::TeamA); submitReactorLineupsForSet($game, 5); $game->recordSetStarted(); // 5th set diff --git a/tests/Feature/GameEventTest.php b/tests/Feature/GameEventTest.php index 9d1b928..3f16093 100644 --- a/tests/Feature/GameEventTest.php +++ b/tests/Feature/GameEventTest.php @@ -3,6 +3,7 @@ use App\Enums\GameEventType; use App\Enums\TeamAB; use App\Enums\TeamSide; +use App\Events\Payloads\CourtSidesSwappedPayload; use App\Events\Payloads\GameEndedPayload; use App\Events\Payloads\LineupSubmittedPayload; use App\Events\Payloads\RallyEndedPayload; @@ -117,6 +118,62 @@ function submitLineupsForSet(Game $game, int $set): void ->and($event->payload->serving)->toBe(TeamAB::TeamB); }); +test('court sides can be swapped once a team reaches 8 points in the fifth set', function (): void { + $game = Game::factory()->create(); + + ensureStartingLineupRoster($game); + $game->recordToss(TeamSide::Home, TeamAB::TeamA); + + foreach ([TeamAB::TeamA, TeamAB::TeamB, TeamAB::TeamA, TeamAB::TeamB] as $setWinner) { + submitLineupsForSet($game, $game->stateAt()->setNumber + 1); + $game->recordSetStarted(); + winSet($game, $setWinner); + } + + $game->recordToss(TeamSide::Home, TeamAB::TeamB, TeamAB::TeamA); + submitLineupsForSet($game, 5); + $game->recordSetStarted(); + + for ($index = 0; $index < 8; $index++) { + $game->recordRallyWinner(TeamAB::TeamA); + } + + $game->recordCourtSidesSwapped(); + + $event = $game->fresh()->events->last(); + $state = $game->fresh()->stateAt(); + + expect($event->type)->toBe(GameEventType::CourtSidesSwapped) + ->and($event->payload)->toBeInstanceOf(CourtSidesSwappedPayload::class) + ->and($state->fifthSetLeftTeam)->toBe(TeamAB::TeamB) + ->and($state->fifthSetSideSwapped)->toBeTrue() + ->and($state->servingTeam)->toBe(TeamAB::TeamA); +}); + +test('court sides cannot be swapped before a team reaches 8 points in the fifth set', function (): void { + $game = Game::factory()->create(); + + ensureStartingLineupRoster($game); + $game->recordToss(TeamSide::Home, TeamAB::TeamA); + + foreach ([TeamAB::TeamA, TeamAB::TeamB, TeamAB::TeamA, TeamAB::TeamB] as $setWinner) { + submitLineupsForSet($game, $game->stateAt()->setNumber + 1); + $game->recordSetStarted(); + winSet($game, $setWinner); + } + + $game->recordToss(TeamSide::Home, TeamAB::TeamB, TeamAB::TeamA); + submitLineupsForSet($game, 5); + $game->recordSetStarted(); + + for ($index = 0; $index < 7; $index++) { + $game->recordRallyWinner(TeamAB::TeamA); + } + + expect(fn () => $game->recordCourtSidesSwapped()) + ->toThrow(InvalidGameEventTransition::class, 'Court sides can only be swapped once a team reaches 8 points in the fifth set.'); +}); + test('team b is derived as the other team when team a is the away team', function (): void { $homeTeam = Team::factory()->create(); $awayTeam = Team::factory()->create(); diff --git a/tests/Feature/GamePageTest.php b/tests/Feature/GamePageTest.php index 0c6a2ee..d641b3d 100644 --- a/tests/Feature/GamePageTest.php +++ b/tests/Feature/GamePageTest.php @@ -13,6 +13,10 @@ uses(RefreshDatabase::class); +beforeEach(function (): void { + config()->set('game.between_sets_duration', 0); +}); + test('the game page returns a successful response and renders the fixed canvas with the court', function (): void { $game = GameModel::factory()->create(); @@ -56,5 +60,55 @@ $response = $this->get(route('game', ['game' => $game])); $response->assertSuccessful() - ->assertSee('Start Set 1'); + ->assertSee('Start Game'); +}); + +test('the game component prompts for the fifth set side change at 8 points and swaps sides when dismissed', function (): void { + $homeTeam = Team::factory()->create(); + $awayTeam = Team::factory()->create(); + $game = GameModel::factory()->betweenTeams($homeTeam, $awayTeam)->create(); + + $homePlayers = Player::factory()->for($homeTeam)->count(6)->create(); + foreach ($homePlayers as $index => $player) { + $game->addPlayer($player, number: $index + 1); + } + + $awayPlayers = Player::factory()->for($awayTeam)->count(6)->create(); + foreach ($awayPlayers as $index => $player) { + $game->addPlayer($player, number: $index + 11); + } + + $game->recordToss(TeamSide::Home, TeamAB::TeamA); + + foreach ([TeamAB::TeamA, TeamAB::TeamB, TeamAB::TeamA, TeamAB::TeamB] as $winner) { + $set = $game->stateAt()->setNumber + 1; + $game->recordLineup($set, TeamAB::TeamA, [1 => 1, 2 => 2, 3 => 3, 4 => 4, 5 => 5, 6 => 6]); + $game->recordLineup($set, TeamAB::TeamB, [1 => 11, 2 => 12, 3 => 13, 4 => 14, 5 => 15, 6 => 16]); + $game->recordSetStarted(); + + for ($index = 0; $index < 25; $index++) { + $game->recordRallyWinner($winner); + } + } + + $game->recordToss(TeamSide::Home, TeamAB::TeamA, TeamAB::TeamA); + $game->recordLineup(5, TeamAB::TeamA, [1 => 1, 2 => 2, 3 => 3, 4 => 4, 5 => 5, 6 => 6]); + $game->recordLineup(5, TeamAB::TeamB, [1 => 11, 2 => 12, 3 => 13, 4 => 14, 5 => 15, 6 => 16]); + $game->recordSetStarted(); + + for ($index = 0; $index < 8; $index++) { + $game->recordRallyWinner(TeamAB::TeamA); + } + + Livewire::test(Game::class, ['game' => $game->fresh()]) + ->assertSet('showFifthSetSideChangePrompt', true) + ->assertSee('Teams to change court') + ->assertSeeHtml('x-on:keydown.escape.window.capture.prevent.stop=""') + ->assertSeeHtml('x-on:cancel.prevent.stop=""') + ->call('acknowledgeFifthSetSideChange') + ->assertDispatched('game-event-recorded') + ->assertSet('showFifthSetSideChangePrompt', false); + + expect($game->fresh()->stateAt()->fifthSetLeftTeam)->toBe(TeamAB::TeamB) + ->and($game->fresh()->stateAt()->servingTeam)->toBe(TeamAB::TeamA); }); diff --git a/tests/Feature/GameStateProjectionTest.php b/tests/Feature/GameStateProjectionTest.php index 44fd7ab..bc2a968 100644 --- a/tests/Feature/GameStateProjectionTest.php +++ b/tests/Feature/GameStateProjectionTest.php @@ -183,6 +183,43 @@ ->and($stateAfterFifthSetStart->fifthSetLeftTeam)->toBe(TeamAB::TeamB); }); +test('fifth set side swap updates the left team while keeping the serving team unchanged', function (): void { + $homeTeam = Team::factory()->create(); + $awayTeam = Team::factory()->create(); + $game = Game::factory()->betweenTeams($homeTeam, $awayTeam)->create(); + ensureRostersForProjection($game, $homeTeam, $awayTeam); + + $game->recordToss(TeamSide::Home, TeamAB::TeamA); + + foreach ([TeamAB::TeamA, TeamAB::TeamB, TeamAB::TeamA, TeamAB::TeamB] as $setWinner) { + submitProjectionLineupsForSet($game, $game->stateAt()->setNumber + 1); + $game->recordSetStarted(); + + for ($index = 0; $index < 25; $index++) { + $game->recordRallyWinner($setWinner); + } + } + + $game->recordToss(TeamSide::Home, TeamAB::TeamA, TeamAB::TeamA); + submitProjectionLineupsForSet($game, 5); + $game->recordSetStarted(); + + for ($index = 0; $index < 8; $index++) { + $game->recordRallyWinner(TeamAB::TeamA); + } + + $game->recordCourtSidesSwapped(); + + $state = $game->fresh()->stateAt(); + + expect($state->setNumber)->toBe(5) + ->and($state->fifthSetLeftTeam)->toBe(TeamAB::TeamB) + ->and($state->fifthSetSideSwapped)->toBeTrue() + ->and($state->servingTeam)->toBe(TeamAB::TeamA) + ->and($state->scoreTeamA)->toBe(8) + ->and($state->scoreTeamB)->toBe(0); +}); + test('serving remains on the same court side after a set ends and sides swap', function (): void { $homeTeam = Team::factory()->create(); $awayTeam = Team::factory()->create(); diff --git a/tests/Feature/ScoreboardTest.php b/tests/Feature/ScoreboardTest.php index 0bedd67..8dc4c68 100644 --- a/tests/Feature/ScoreboardTest.php +++ b/tests/Feature/ScoreboardTest.php @@ -144,6 +144,43 @@ ]); }); +test('scoreboard swaps teams after the fifth set court change', function (): void { + $game = gameWithDistinctTeamCountryCodes(); + $game->recordToss(TeamSide::Home, TeamAB::TeamA); + + $teamACode = $game->homeTeam->country_code; + $teamBCode = $game->awayTeam->country_code; + + Livewire::test(Scoreboard::class, [ + 'gameId' => $game->getKey(), + 'gameState' => GameState::fromAttributes([ + 'set_number' => 5, + 'sets_won_team_a' => 2, + 'sets_won_team_b' => 2, + 'score_team_a' => 8, + 'score_team_b' => 7, + 'fifth_set_left_team' => TeamAB::TeamB->value, + 'fifth_set_side_swapped' => true, + ]), + ]) + ->assertSeeHtml('data-scoreboard-left-team="team_b"') + ->assertSeeHtml('data-scoreboard-right-team="team_a"') + ->assertSeeInOrder([ + $teamBCode, + 'Sets', + $teamACode, + '2', + ':', + '2', + $teamBCode, + 'Points', + $teamACode, + '7', + ':', + '8', + ]); +}); + function gameWithDistinctTeamCountryCodes(): Game { $homeTeam = Team::factory()->create(); diff --git a/tests/Feature/TeamRosterTest.php b/tests/Feature/TeamRosterTest.php index 4e22f4a..4e73f42 100644 --- a/tests/Feature/TeamRosterTest.php +++ b/tests/Feature/TeamRosterTest.php @@ -17,6 +17,10 @@ uses(RefreshDatabase::class); +beforeEach(function (): void { + config()->set('game.between_sets_duration', 0); +}); + test('team roster shows fallback text when there are no players', function (): void { $homeTeam = Team::factory()->create(); $awayTeam = Team::factory()->create(); From 899274b30765b1cf8b2cafe02c0130b507752242 Mon Sep 17 00:00:00 2001 From: Giulio Troccoli-Allard Date: Wed, 13 May 2026 18:05:53 +0100 Subject: [PATCH 53/63] Fix roster modal interactions --- .../views/livewire/team-roster.blade.php | 58 +++++++++++-------- tests/Feature/TeamRosterTest.php | 2 +- 2 files changed, 35 insertions(+), 25 deletions(-) diff --git a/resources/views/livewire/team-roster.blade.php b/resources/views/livewire/team-roster.blade.php index 636e745..9ac9d84 100644 --- a/resources/views/livewire/team-roster.blade.php +++ b/resources/views/livewire/team-roster.blade.php @@ -103,7 +103,12 @@ class="cursor-pointer p-1.5 content-center-safe! hover:bg-zinc-100 dark:hover:bg - +
Request Timeout @@ -112,7 +117,7 @@ class="cursor-pointer p-1.5 content-center-safe! hover:bg-zinc-100 dark:hover:bg @error('timeout') {{ $message }} @enderror -
+
Cancel @@ -163,19 +168,20 @@ class="p-1.5 content-center-safe!" @endif @if ($canRequestSubstitution) - - - Substitutions - {{ $substitutionsTaken }}/6 - - + + Substitutions + {{ $substitutionsTaken }}/6 + {{ $message }} @enderror -
+
Cancel @@ -299,18 +305,22 @@ class="h-12! w-12!" @elseif ($canShowSubstitutionFullModal) - - - Substitutions - {{ $substitutionsTaken }}/6 - - + + Substitutions + {{ $substitutionsTaken }}/6 + - +
Request Substitution diff --git a/tests/Feature/TeamRosterTest.php b/tests/Feature/TeamRosterTest.php index 4e73f42..18d561a 100644 --- a/tests/Feature/TeamRosterTest.php +++ b/tests/Feature/TeamRosterTest.php @@ -408,7 +408,7 @@ 'leftSide' => true, 'gameState' => GameState::fromAttributes(array_merge($state->toAttributes(), ['substitutions_team_a' => 6])), ])->assertSeeHtml('data-team-roster-substitutions') - ->assertSeeHtml('substitution-full-confirm-team_a') + ->assertSeeHtml("\$flux.modal('substitution-full-confirm-team_a').show()") ->assertDontSeeHtml('name="substitution-team_a"'); }); From 1f90ef8276004368d10895f7f6b9a03714b76e84 Mon Sep 17 00:00:00 2001 From: Giulio Troccoli-Allard Date: Fri, 22 May 2026 17:44:35 +0100 Subject: [PATCH 54/63] feat: add improper request for third time-out --- app/Data/GameState/GameState.php | 10 ++++++ app/Enums/GameEventType.php | 1 + app/Enums/ImproperRequestType.php | 10 ++++++ .../ImproperRequestRecordedPayload.php | 34 +++++++++++++++++++ app/Livewire/TeamRoster.php | 23 ++++++++----- .../Concerns/RecordsImproperRequest.php | 31 +++++++++++++++++ app/Models/Game.php | 2 ++ app/Models/GameEvent.php | 2 ++ app/Models/GameStateSnapshot.php | 4 +++ .../GameState/GameEventRuleValidator.php | 9 +++++ app/Services/GameState/GameStateProjector.php | 18 ++++++++++ ...requests_to_game_state_snapshots_table.php | 32 +++++++++++++++++ .../views/livewire/team-roster.blade.php | 11 ++++-- tests/Feature/GameEventTest.php | 17 ++++++++++ tests/Feature/GameStateProjectionTest.php | 4 +++ tests/Feature/TeamRosterTest.php | 17 ++++++---- 16 files changed, 208 insertions(+), 17 deletions(-) create mode 100644 app/Enums/ImproperRequestType.php create mode 100644 app/Events/Payloads/ImproperRequestRecordedPayload.php create mode 100644 app/Models/Concerns/RecordsImproperRequest.php create mode 100644 database/migrations/2026_05_22_163747_add_improper_requests_to_game_state_snapshots_table.php diff --git a/app/Data/GameState/GameState.php b/app/Data/GameState/GameState.php index 47aceba..fdd00a4 100644 --- a/app/Data/GameState/GameState.php +++ b/app/Data/GameState/GameState.php @@ -27,6 +27,8 @@ public function __construct( public int $timeoutsTeamB = 0, public int $substitutionsTeamA = 0, public int $substitutionsTeamB = 0, + public int $improperRequestsTeamA = 0, + public int $improperRequestsTeamB = 0, public ?TeamSide $teamASide = null, public ?TeamAB $fifthSetLeftTeam = null, public bool $fifthSetSideSwapped = false, @@ -52,6 +54,8 @@ public static function fromSnapshot(GameStateSnapshot $snapshot): self timeoutsTeamB: $snapshot->timeouts_team_b, substitutionsTeamA: $snapshot->substitutions_team_a, substitutionsTeamB: $snapshot->substitutions_team_b, + improperRequestsTeamA: $snapshot->improper_requests_team_a, + improperRequestsTeamB: $snapshot->improper_requests_team_b, teamASide: $snapshot->team_a_side, fifthSetLeftTeam: $snapshot->fifth_set_left_team, fifthSetSideSwapped: $snapshot->fifth_set_side_swapped, @@ -81,6 +85,8 @@ public static function fromAttributes(array $attributes): self timeoutsTeamB: self::toInteger($attributes['timeouts_team_b'] ?? 0), substitutionsTeamA: self::toInteger($attributes['substitutions_team_a'] ?? 0), substitutionsTeamB: self::toInteger($attributes['substitutions_team_b'] ?? 0), + improperRequestsTeamA: self::toInteger($attributes['improper_requests_team_a'] ?? 0), + improperRequestsTeamB: self::toInteger($attributes['improper_requests_team_b'] ?? 0), teamASide: is_string($attributes['team_a_side'] ?? null) ? TeamSide::tryFrom($attributes['team_a_side']) : null, @@ -123,6 +129,8 @@ public function toAttributes(): array 'timeouts_team_b' => $this->timeoutsTeamB, 'substitutions_team_a' => $this->substitutionsTeamA, 'substitutions_team_b' => $this->substitutionsTeamB, + 'improper_requests_team_a' => $this->improperRequestsTeamA, + 'improper_requests_team_b' => $this->improperRequestsTeamB, 'team_a_side' => $this->teamASide?->value, 'fifth_set_left_team' => $this->fifthSetLeftTeam?->value, 'fifth_set_side_swapped' => $this->fifthSetSideSwapped, @@ -155,6 +163,8 @@ public function resetCurrentSetCounters(): void $this->timeoutsTeamB = 0; $this->substitutionsTeamA = 0; $this->substitutionsTeamB = 0; + $this->improperRequestsTeamA = 0; + $this->improperRequestsTeamB = 0; } /** diff --git a/app/Enums/GameEventType.php b/app/Enums/GameEventType.php index 9c088ec..f05e7e0 100644 --- a/app/Enums/GameEventType.php +++ b/app/Enums/GameEventType.php @@ -12,6 +12,7 @@ enum GameEventType: string case CourtSidesSwapped = 'court_sides_swapped'; case SubstitutionCompleted = 'substitution_completed'; case TimeOutRequested = 'time_out_requested'; + case ImproperRequestRecorded = 'improper_request_recorded'; case SetStarted = 'set_started'; case SetEnded = 'set_ended'; case GameEnded = 'game_ended'; diff --git a/app/Enums/ImproperRequestType.php b/app/Enums/ImproperRequestType.php new file mode 100644 index 0000000..eed3a5a --- /dev/null +++ b/app/Enums/ImproperRequestType.php @@ -0,0 +1,10 @@ + $data */ + public static function fromArray(array $data): static + { + return new self( + team: TeamAB::from($data['team']), + requestType: ImproperRequestType::from($data['request_type']), + ); + } + + /** @return array{team: string, request_type: string} */ + public function toArray(): array + { + return [ + 'team' => $this->team->value, + 'request_type' => $this->requestType->value, + ]; + } +} diff --git a/app/Livewire/TeamRoster.php b/app/Livewire/TeamRoster.php index 2604741..d65cb2f 100644 --- a/app/Livewire/TeamRoster.php +++ b/app/Livewire/TeamRoster.php @@ -6,6 +6,7 @@ use App\Data\GameState\GameState; use App\Enums\GameEventType; +use App\Enums\ImproperRequestType; use App\Enums\StaffRole; use App\Enums\TeamAB; use App\Enums\TeamSide; @@ -66,19 +67,25 @@ public function requestTimeout(): void $timeoutsTaken = $this->team === TeamAB::TeamA ? $state->timeoutsTeamA : $state->timeoutsTeamB; $hasTimeoutLeft = $timeoutsTaken < 2; - if ($hasTimeoutLeft) { - try { + try { + if ($hasTimeoutLeft) { $activeGame->recordTimeOut($this->team); - } catch (InvalidGameEventTransition $exception) { - $this->addError('timeout', $exception->getMessage()); - - return; + } else { + $activeGame->recordImproperRequest($this->team, ImproperRequestType::Timeout); } + } catch (InvalidGameEventTransition $exception) { + $this->addError('timeout', $exception->getMessage()); - $this->dispatch('game-event-recorded'); + return; } - $this->dispatch('timeout-recorded', team: $this->team->value, hasTimeoutLeft: $hasTimeoutLeft); + $this->dispatch('game-event-recorded'); + $this->dispatch( + 'timeout-recorded', + team: $this->team->value, + hasTimeoutLeft: $hasTimeoutLeft, + improperRequest: ! $hasTimeoutLeft, + ); } public function submitSubstitution(): void diff --git a/app/Models/Concerns/RecordsImproperRequest.php b/app/Models/Concerns/RecordsImproperRequest.php new file mode 100644 index 0000000..125ad64 --- /dev/null +++ b/app/Models/Concerns/RecordsImproperRequest.php @@ -0,0 +1,31 @@ +assertCanRecordImproperRequest($this); + + $this->events()->create([ + 'type' => GameEventType::ImproperRequestRecorded, + 'payload' => new ImproperRequestRecordedPayload( + team: $team, + requestType: $requestType, + ), + ]); + } +} diff --git a/app/Models/Game.php b/app/Models/Game.php index 7cf6c55..ac30d75 100644 --- a/app/Models/Game.php +++ b/app/Models/Game.php @@ -11,6 +11,7 @@ use App\Models\Concerns\RecordsEndOfGame; use App\Models\Concerns\RecordsEndOfRally; use App\Models\Concerns\RecordsEndOfSet; +use App\Models\Concerns\RecordsImproperRequest; use App\Models\Concerns\RecordsLineup; use App\Models\Concerns\RecordsStartOfSet; use App\Models\Concerns\RecordsSubstitution; @@ -61,6 +62,7 @@ class Game extends Model use RecordsEndOfGame; use RecordsEndOfRally; use RecordsEndOfSet; + use RecordsImproperRequest; use RecordsLineup; use RecordsStartOfSet; use RecordsSubstitution; diff --git a/app/Models/GameEvent.php b/app/Models/GameEvent.php index a76d539..fb74165 100644 --- a/app/Models/GameEvent.php +++ b/app/Models/GameEvent.php @@ -8,6 +8,7 @@ use App\Events\Payloads\CourtSidesSwappedPayload; use App\Events\Payloads\GameEndedPayload; use App\Events\Payloads\GameEventPayload; +use App\Events\Payloads\ImproperRequestRecordedPayload; use App\Events\Payloads\LineupSubmittedPayload; use App\Events\Payloads\RallyEndedPayload; use App\Events\Payloads\SetEndedPayload; @@ -68,6 +69,7 @@ public function payload(): Attribute GameEventType::CourtSidesSwapped => CourtSidesSwappedPayload::fromArray($data), GameEventType::SubstitutionCompleted => SubstitutionCompletedPayload::fromArray($data), GameEventType::TimeOutRequested => TimeOutRequestedPayload::fromArray($data), + GameEventType::ImproperRequestRecorded => ImproperRequestRecordedPayload::fromArray($data), GameEventType::SetStarted => SetStartedPayload::fromArray($data), GameEventType::SetEnded => SetEndedPayload::fromArray($data), GameEventType::GameEnded => GameEndedPayload::fromArray($data), diff --git a/app/Models/GameStateSnapshot.php b/app/Models/GameStateSnapshot.php index aa2c8c3..fadb343 100644 --- a/app/Models/GameStateSnapshot.php +++ b/app/Models/GameStateSnapshot.php @@ -22,6 +22,8 @@ * @property int $timeouts_team_b * @property int $substitutions_team_a * @property int $substitutions_team_b + * @property int $improper_requests_team_a + * @property int $improper_requests_team_b * @property TeamSide|null $team_a_side * @property TeamAB|null $fifth_set_left_team * @property bool $fifth_set_side_swapped @@ -54,6 +56,8 @@ protected function casts(): array 'timeouts_team_b' => 'integer', 'substitutions_team_a' => 'integer', 'substitutions_team_b' => 'integer', + 'improper_requests_team_a' => 'integer', + 'improper_requests_team_b' => 'integer', 'team_a_side' => TeamSide::class, 'fifth_set_left_team' => TeamAB::class, 'fifth_set_side_swapped' => 'boolean', diff --git a/app/Services/GameState/GameEventRuleValidator.php b/app/Services/GameState/GameEventRuleValidator.php index eb39dc1..df2abf9 100644 --- a/app/Services/GameState/GameEventRuleValidator.php +++ b/app/Services/GameState/GameEventRuleValidator.php @@ -135,6 +135,15 @@ public function assertCanRecordTimeOut(Game $game): void } } + public function assertCanRecordImproperRequest(Game $game): void + { + $state = $game->stateAt(); + + if ($state->gameEnded || ! $state->setInProgress) { + $this->fail('An improper request can only be recorded while a set is in progress.'); + } + } + public function assertCanRecordSetEnded(Game $game): void { $state = $game->stateAt(); diff --git a/app/Services/GameState/GameStateProjector.php b/app/Services/GameState/GameStateProjector.php index 2748e88..6b91b58 100644 --- a/app/Services/GameState/GameStateProjector.php +++ b/app/Services/GameState/GameStateProjector.php @@ -8,6 +8,7 @@ use App\Enums\GameEventType; use App\Enums\TeamAB; use App\Events\Payloads\CourtSidesSwappedPayload; +use App\Events\Payloads\ImproperRequestRecordedPayload; use App\Events\Payloads\LineupSubmittedPayload; use App\Events\Payloads\RallyEndedPayload; use App\Events\Payloads\SubstitutionCompletedPayload; @@ -31,6 +32,7 @@ public function project(GameState $state, GameEvent $event): GameState GameEventType::CourtSidesSwapped => $this->applyCourtSidesSwapped($state, $event), GameEventType::SubstitutionCompleted => $this->applySubstitutionCompleted($state, $event), GameEventType::TimeOutRequested => $this->applyTimeOutRequested($state, $event), + GameEventType::ImproperRequestRecorded => $this->applyImproperRequestRecorded($state, $event), GameEventType::SetStarted => $this->applySetStarted($state, $event), GameEventType::SetEnded => $this->applySetEnded($state, $event), GameEventType::GameEnded => $this->applyGameEnded($state), @@ -162,6 +164,20 @@ private function applyTimeOutRequested(GameState $state, GameEvent $event): Game return $state; } + private function applyImproperRequestRecorded(GameState $state, GameEvent $event): GameState + { + /** @var ImproperRequestRecordedPayload $payload */ + $payload = $event->payload; + + if ($payload->team === TeamAB::TeamA) { + $state->improperRequestsTeamA++; + } else { + $state->improperRequestsTeamB++; + } + + return $state; + } + private function applySetStarted(GameState $state, GameEvent $event): GameState { $state->setNumber = max(1, $state->setNumber + 1); @@ -198,6 +214,8 @@ private function applySetEnded(GameState $state, GameEvent $event): GameState $state->scoreTeamB = 0; $state->timeoutsTeamA = 0; $state->timeoutsTeamB = 0; + $state->improperRequestsTeamA = 0; + $state->improperRequestsTeamB = 0; $state->rotationTeamA = []; $state->rotationTeamB = []; $state->setInProgress = false; diff --git a/database/migrations/2026_05_22_163747_add_improper_requests_to_game_state_snapshots_table.php b/database/migrations/2026_05_22_163747_add_improper_requests_to_game_state_snapshots_table.php new file mode 100644 index 0000000..5389718 --- /dev/null +++ b/database/migrations/2026_05_22_163747_add_improper_requests_to_game_state_snapshots_table.php @@ -0,0 +1,32 @@ +unsignedTinyInteger('improper_requests_team_a')->default(0)->after('substitutions_team_b'); + $table->unsignedTinyInteger('improper_requests_team_b')->default(0)->after('improper_requests_team_a'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('game_state_snapshots', function (Blueprint $table) { + $table->dropColumn([ + 'improper_requests_team_a', + 'improper_requests_team_b', + ]); + }); + } +}; diff --git a/resources/views/livewire/team-roster.blade.php b/resources/views/livewire/team-roster.blade.php index 9ac9d84..50d4565 100644 --- a/resources/views/livewire/team-roster.blade.php +++ b/resources/views/livewire/team-roster.blade.php @@ -87,7 +87,7 @@ class="flex h-8 w-8 items-center justify-center rounded-full border border-slate $flux.modal('request-timeout-{{ $team->value }}-confirm').close(); $flux.modal('request-timeout-{{ $team->value }}-countdown').show(); countdownOpen = true; - $event.detail.hasTimeoutLeft ? start() : (phase = 'notimeout'); + $event.detail.hasTimeoutLeft ? start() : (phase = 'improper'); } " x-on:keydown.escape.window="if (countdownOpen) $event.preventDefault()" @@ -148,8 +148,13 @@ class="min-w-[20rem]"
-
- No timeout left +
+
+ Improper request + + This team has already used both time-outs in this set, so this request constitutes an improper request. + +
Return to play
diff --git a/tests/Feature/GameEventTest.php b/tests/Feature/GameEventTest.php index 3f16093..79aead0 100644 --- a/tests/Feature/GameEventTest.php +++ b/tests/Feature/GameEventTest.php @@ -1,10 +1,12 @@ [TeamAB::TeamB], ]); +test('an improper request can be recorded with the correct type and payload', function (): void { + $homeTeam = Team::factory()->create(); + $awayTeam = Team::factory()->create(); + $game = Game::factory()->betweenTeams($homeTeam, $awayTeam)->create(); + + prepareActiveSet($game); + $game->recordImproperRequest(TeamAB::TeamA, ImproperRequestType::Timeout); + + $event = $game->events->last(); + expect($event->type)->toBe(GameEventType::ImproperRequestRecorded) + ->and($event->payload)->toBeInstanceOf(ImproperRequestRecordedPayload::class) + ->and($event->payload->team)->toBe(TeamAB::TeamA) + ->and($event->payload->requestType)->toBe(ImproperRequestType::Timeout); +}); + test('timeout counts reset to zero when a set ends', function (): void { $homeTeam = Team::factory()->create(); $awayTeam = Team::factory()->create(); diff --git a/tests/Feature/GameStateProjectionTest.php b/tests/Feature/GameStateProjectionTest.php index bc2a968..fddf717 100644 --- a/tests/Feature/GameStateProjectionTest.php +++ b/tests/Feature/GameStateProjectionTest.php @@ -2,6 +2,7 @@ use App\Data\GameState\GameState; use App\Enums\GameEventType; +use App\Enums\ImproperRequestType; use App\Enums\TeamAB; use App\Enums\TeamSide; use App\Events\Payloads\SetStartedPayload; @@ -61,6 +62,7 @@ $game->recordRallyWinner(TeamAB::TeamA); $game->recordRallyWinner(TeamAB::TeamB); $game->recordTimeOut(TeamAB::TeamB); + $game->recordImproperRequest(TeamAB::TeamB, ImproperRequestType::Timeout); $game->recordSubstitution( TeamAB::TeamA, playerOut: 1, @@ -79,6 +81,8 @@ ->and($state->timeoutsTeamB)->toBe(1) ->and($state->substitutionsTeamA)->toBe(1) ->and($state->substitutionsTeamB)->toBe(0) + ->and($state->improperRequestsTeamA)->toBe(0) + ->and($state->improperRequestsTeamB)->toBe(1) ->and($state->teamASide)->toBe(TeamSide::Home) ->and($state->servingTeam)->toBe(TeamAB::TeamB) ->and($state->rotationTeamA[1])->toBe(7) diff --git a/tests/Feature/TeamRosterTest.php b/tests/Feature/TeamRosterTest.php index 18d561a..6e7ea71 100644 --- a/tests/Feature/TeamRosterTest.php +++ b/tests/Feature/TeamRosterTest.php @@ -296,14 +296,13 @@ 'gameState' => $game->stateAt(), ]) ->call('requestTimeout') - ->assertDispatched('timeout-recorded', team: 'team_a', hasTimeoutLeft: true); + ->assertDispatched('timeout-recorded', team: 'team_a', hasTimeoutLeft: true, improperRequest: false); }); -test('requesting a timeout dispatches hasTimeoutLeft false and does not record an event when no timeouts remain', function (): void { +test('requesting a timeout records an improper request when no timeouts remain', function (): void { $game = gameWithActiveSetForTeamRoster(); $game->recordTimeOut(TeamAB::TeamA); $game->recordTimeOut(TeamAB::TeamA); - $eventCountBefore = $game->events()->count(); Livewire::test(TeamRoster::class, [ 'gameId' => $game->getKey(), @@ -312,10 +311,16 @@ 'gameState' => $game->stateAt(), ]) ->call('requestTimeout') - ->assertDispatched('timeout-recorded', team: 'team_a', hasTimeoutLeft: false) - ->assertNotDispatched('game-event-recorded'); + ->assertHasNoErrors() + ->assertDispatched('game-event-recorded') + ->assertDispatched('timeout-recorded', team: 'team_a', hasTimeoutLeft: false, improperRequest: true); + + $freshGame = $game->fresh(); + $latestEvent = $freshGame->events->last(); - expect($game->events()->count())->toBe($eventCountBefore); + expect($latestEvent->type)->toBe(GameEventType::ImproperRequestRecorded) + ->and($freshGame->stateAt()->timeoutsTeamA)->toBe(2) + ->and($freshGame->stateAt()->improperRequestsTeamA)->toBe(1); }); test('timeout card becomes interactive again for a new set after two timeouts in the previous set', function (): void { From f57745e346c89409ed3805fcd7a307703abd0fa9 Mon Sep 17 00:00:00 2001 From: Giulio Troccoli-Allard Date: Fri, 22 May 2026 17:48:52 +0100 Subject: [PATCH 55/63] feat: add improper request for a seventh substitution --- app/Enums/ImproperRequestType.php | 1 + app/Livewire/TeamRoster.php | 28 +++++++++++++++++ .../views/livewire/team-roster.blade.php | 15 ++++++++-- tests/Feature/GameEventTest.php | 15 ++++++++++ tests/Feature/TeamRosterTest.php | 30 +++++++++++++++++++ 5 files changed, 87 insertions(+), 2 deletions(-) diff --git a/app/Enums/ImproperRequestType.php b/app/Enums/ImproperRequestType.php index eed3a5a..dc4665b 100644 --- a/app/Enums/ImproperRequestType.php +++ b/app/Enums/ImproperRequestType.php @@ -7,4 +7,5 @@ enum ImproperRequestType: string { case Timeout = 'timeout'; + case Substitution = 'substitution'; } diff --git a/app/Livewire/TeamRoster.php b/app/Livewire/TeamRoster.php index d65cb2f..114f1e3 100644 --- a/app/Livewire/TeamRoster.php +++ b/app/Livewire/TeamRoster.php @@ -155,6 +155,34 @@ public function submitSubstitution(): void $this->dispatch('substitution-recorded', team: $this->team->value); } + public function requestSubstitutionWhenFull(): void + { + $activeGame = $this->activeGame(); + + if ($activeGame === null) { + $this->addError('substitution', 'No active game is available to record the substitution.'); + + return; + } + + if ($this->substitutionConstraints()['count'] < 6) { + $this->addError('substitution', 'This team still has substitutions available.'); + + return; + } + + try { + $activeGame->recordImproperRequest($this->team, ImproperRequestType::Substitution); + } catch (InvalidGameEventTransition $exception) { + $this->addError('substitution', $exception->getMessage()); + + return; + } + + $this->dispatch('game-event-recorded'); + $this->dispatch('substitution-improper-request-recorded', team: $this->team->value); + } + /** * Returns substitution constraints for the current set. * diff --git a/resources/views/livewire/team-roster.blade.php b/resources/views/livewire/team-roster.blade.php index 50d4565..06edb1a 100644 --- a/resources/views/livewire/team-roster.blade.php +++ b/resources/views/livewire/team-roster.blade.php @@ -325,6 +325,12 @@ class="cursor-pointer p-1.5 content-center-safe! hover:bg-zinc-100 dark:hover:bg :dismissible="false" :closable="false" class="min-w-[20rem]" + x-on:substitution-improper-request-recorded.window=" + if ($event.detail.team === '{{ $team->value }}') { + $flux.modal('substitution-full-confirm-{{ $team->value }}').close(); + $flux.modal('substitution-full-{{ $team->value }}').show(); + } + " >
@@ -339,7 +345,7 @@ class="min-w-[20rem]" Confirm @@ -354,7 +360,12 @@ class="min-w-[20rem]" class="min-w-[20rem]" >
- No substitutions left +
+ Improper request + + This team has already used all six substitutions in this set, so this request constitutes an improper request. + +
and($event->payload->requestType)->toBe(ImproperRequestType::Timeout); }); +test('an improper substitution request stores the request type', function (): void { + $homeTeam = Team::factory()->create(); + $awayTeam = Team::factory()->create(); + $game = Game::factory()->betweenTeams($homeTeam, $awayTeam)->create(); + + prepareActiveSet($game); + $game->recordImproperRequest(TeamAB::TeamA, ImproperRequestType::Substitution); + + $event = $game->events->last(); + expect($event->type)->toBe(GameEventType::ImproperRequestRecorded) + ->and($event->payload)->toBeInstanceOf(ImproperRequestRecordedPayload::class) + ->and($event->payload->team)->toBe(TeamAB::TeamA) + ->and($event->payload->requestType)->toBe(ImproperRequestType::Substitution); +}); + test('timeout counts reset to zero when a set ends', function (): void { $homeTeam = Team::factory()->create(); $awayTeam = Team::factory()->create(); diff --git a/tests/Feature/TeamRosterTest.php b/tests/Feature/TeamRosterTest.php index 6e7ea71..be123eb 100644 --- a/tests/Feature/TeamRosterTest.php +++ b/tests/Feature/TeamRosterTest.php @@ -4,6 +4,7 @@ use App\Data\GameState\GameState; use App\Enums\GameEventType; +use App\Enums\ImproperRequestType; use App\Enums\StaffRole; use App\Enums\TeamAB; use App\Enums\TeamSide; @@ -417,6 +418,35 @@ ->assertDontSeeHtml('name="substitution-team_a"'); }); +test('requesting a substitution records an improper request when all substitutions are used', function (): void { + $game = gameWithActiveSetForTeamRoster(); + $game->recordSubstitution(TeamAB::TeamA, 1, 7); + $game->recordSubstitution(TeamAB::TeamA, 7, 1); + $game->recordSubstitution(TeamAB::TeamA, 2, 8); + $game->recordSubstitution(TeamAB::TeamA, 8, 2); + $game->recordSubstitution(TeamAB::TeamA, 3, 9); + $game->recordSubstitution(TeamAB::TeamA, 9, 3); + + Livewire::test(TeamRoster::class, [ + 'gameId' => $game->getKey(), + 'team' => TeamAB::TeamA, + 'leftSide' => true, + 'gameState' => $game->stateAt(), + ]) + ->call('requestSubstitutionWhenFull') + ->assertHasNoErrors() + ->assertDispatched('game-event-recorded') + ->assertDispatched('substitution-improper-request-recorded', team: 'team_a'); + + $freshGame = $game->fresh(); + $latestEvent = $freshGame->events->last(); + + expect($latestEvent->type)->toBe(GameEventType::ImproperRequestRecorded) + ->and($latestEvent->payload->requestType)->toBe(ImproperRequestType::Substitution) + ->and($freshGame->stateAt()->substitutionsTeamA)->toBe(6) + ->and($freshGame->stateAt()->improperRequestsTeamA)->toBe(1); +}); + test('substitution modal shows on-court and bench player numbers', function (): void { $game = gameWithActiveSetForTeamRoster(); From 7b0b6f50bdc9840620e58c6eeb1596e8f7876a05 Mon Sep 17 00:00:00 2001 From: Giulio Troccoli-Allard Date: Fri, 22 May 2026 18:05:45 +0100 Subject: [PATCH 56/63] feat: add delay warning and delay penalty --- app/Data/GameState/GameState.php | 18 +- app/Enums/GameEventType.php | 2 + .../Payloads/DelayPenaltyRecordedPayload.php | 37 ++++ .../Payloads/DelayWarningRecordedPayload.php | 34 ++++ app/Livewire/TeamRoster.php | 15 +- .../Concerns/RecordsImproperRequest.php | 58 +++++- app/Models/GameEvent.php | 4 + app/Models/GameStateSnapshot.php | 8 + app/Services/GameState/GameEventReactor.php | 4 +- app/Services/GameState/GameStateProjector.php | 64 ++++-- ...warnings_to_game_state_snapshots_table.php | 32 +++ ...enalties_to_game_state_snapshots_table.php | 32 +++ .../views/livewire/team-roster.blade.php | 187 ++++++++++++------ tests/Feature/GameEventTest.php | 77 ++++++++ tests/Feature/TeamRosterTest.php | 138 ++++++++++++- 15 files changed, 618 insertions(+), 92 deletions(-) create mode 100644 app/Events/Payloads/DelayPenaltyRecordedPayload.php create mode 100644 app/Events/Payloads/DelayWarningRecordedPayload.php create mode 100644 database/migrations/2026_05_22_165203_add_delay_warnings_to_game_state_snapshots_table.php create mode 100644 database/migrations/2026_05_22_170040_add_delay_penalties_to_game_state_snapshots_table.php diff --git a/app/Data/GameState/GameState.php b/app/Data/GameState/GameState.php index fdd00a4..e1318aa 100644 --- a/app/Data/GameState/GameState.php +++ b/app/Data/GameState/GameState.php @@ -29,6 +29,10 @@ public function __construct( public int $substitutionsTeamB = 0, public int $improperRequestsTeamA = 0, public int $improperRequestsTeamB = 0, + public int $delayWarningsTeamA = 0, + public int $delayWarningsTeamB = 0, + public int $delayPenaltiesTeamA = 0, + public int $delayPenaltiesTeamB = 0, public ?TeamSide $teamASide = null, public ?TeamAB $fifthSetLeftTeam = null, public bool $fifthSetSideSwapped = false, @@ -56,6 +60,10 @@ public static function fromSnapshot(GameStateSnapshot $snapshot): self substitutionsTeamB: $snapshot->substitutions_team_b, improperRequestsTeamA: $snapshot->improper_requests_team_a, improperRequestsTeamB: $snapshot->improper_requests_team_b, + delayWarningsTeamA: $snapshot->delay_warnings_team_a, + delayWarningsTeamB: $snapshot->delay_warnings_team_b, + delayPenaltiesTeamA: $snapshot->delay_penalties_team_a, + delayPenaltiesTeamB: $snapshot->delay_penalties_team_b, teamASide: $snapshot->team_a_side, fifthSetLeftTeam: $snapshot->fifth_set_left_team, fifthSetSideSwapped: $snapshot->fifth_set_side_swapped, @@ -87,6 +95,10 @@ public static function fromAttributes(array $attributes): self substitutionsTeamB: self::toInteger($attributes['substitutions_team_b'] ?? 0), improperRequestsTeamA: self::toInteger($attributes['improper_requests_team_a'] ?? 0), improperRequestsTeamB: self::toInteger($attributes['improper_requests_team_b'] ?? 0), + delayWarningsTeamA: self::toInteger($attributes['delay_warnings_team_a'] ?? 0), + delayWarningsTeamB: self::toInteger($attributes['delay_warnings_team_b'] ?? 0), + delayPenaltiesTeamA: self::toInteger($attributes['delay_penalties_team_a'] ?? 0), + delayPenaltiesTeamB: self::toInteger($attributes['delay_penalties_team_b'] ?? 0), teamASide: is_string($attributes['team_a_side'] ?? null) ? TeamSide::tryFrom($attributes['team_a_side']) : null, @@ -131,6 +143,10 @@ public function toAttributes(): array 'substitutions_team_b' => $this->substitutionsTeamB, 'improper_requests_team_a' => $this->improperRequestsTeamA, 'improper_requests_team_b' => $this->improperRequestsTeamB, + 'delay_warnings_team_a' => $this->delayWarningsTeamA, + 'delay_warnings_team_b' => $this->delayWarningsTeamB, + 'delay_penalties_team_a' => $this->delayPenaltiesTeamA, + 'delay_penalties_team_b' => $this->delayPenaltiesTeamB, 'team_a_side' => $this->teamASide?->value, 'fifth_set_left_team' => $this->fifthSetLeftTeam?->value, 'fifth_set_side_swapped' => $this->fifthSetSideSwapped, @@ -163,8 +179,6 @@ public function resetCurrentSetCounters(): void $this->timeoutsTeamB = 0; $this->substitutionsTeamA = 0; $this->substitutionsTeamB = 0; - $this->improperRequestsTeamA = 0; - $this->improperRequestsTeamB = 0; } /** diff --git a/app/Enums/GameEventType.php b/app/Enums/GameEventType.php index f05e7e0..aab4c4c 100644 --- a/app/Enums/GameEventType.php +++ b/app/Enums/GameEventType.php @@ -13,6 +13,8 @@ enum GameEventType: string case SubstitutionCompleted = 'substitution_completed'; case TimeOutRequested = 'time_out_requested'; case ImproperRequestRecorded = 'improper_request_recorded'; + case DelayWarningRecorded = 'delay_warning_recorded'; + case DelayPenaltyRecorded = 'delay_penalty_recorded'; case SetStarted = 'set_started'; case SetEnded = 'set_ended'; case GameEnded = 'game_ended'; diff --git a/app/Events/Payloads/DelayPenaltyRecordedPayload.php b/app/Events/Payloads/DelayPenaltyRecordedPayload.php new file mode 100644 index 0000000..56e0c57 --- /dev/null +++ b/app/Events/Payloads/DelayPenaltyRecordedPayload.php @@ -0,0 +1,37 @@ + $data */ + public static function fromArray(array $data): static + { + return new self( + team: TeamAB::from($data['team']), + awardedTeam: TeamAB::from($data['awarded_team']), + requestType: ImproperRequestType::from($data['request_type']), + ); + } + + /** @return array{team: string, awarded_team: string, request_type: string} */ + public function toArray(): array + { + return [ + 'team' => $this->team->value, + 'awarded_team' => $this->awardedTeam->value, + 'request_type' => $this->requestType->value, + ]; + } +} diff --git a/app/Events/Payloads/DelayWarningRecordedPayload.php b/app/Events/Payloads/DelayWarningRecordedPayload.php new file mode 100644 index 0000000..51231fe --- /dev/null +++ b/app/Events/Payloads/DelayWarningRecordedPayload.php @@ -0,0 +1,34 @@ + $data */ + public static function fromArray(array $data): static + { + return new self( + team: TeamAB::from($data['team']), + requestType: ImproperRequestType::from($data['request_type']), + ); + } + + /** @return array{team: string, request_type: string} */ + public function toArray(): array + { + return [ + 'team' => $this->team->value, + 'request_type' => $this->requestType->value, + ]; + } +} diff --git a/app/Livewire/TeamRoster.php b/app/Livewire/TeamRoster.php index 114f1e3..497a2f7 100644 --- a/app/Livewire/TeamRoster.php +++ b/app/Livewire/TeamRoster.php @@ -67,11 +67,13 @@ public function requestTimeout(): void $timeoutsTaken = $this->team === TeamAB::TeamA ? $state->timeoutsTeamA : $state->timeoutsTeamB; $hasTimeoutLeft = $timeoutsTaken < 2; + $recordedRequestType = null; + try { if ($hasTimeoutLeft) { $activeGame->recordTimeOut($this->team); } else { - $activeGame->recordImproperRequest($this->team, ImproperRequestType::Timeout); + $recordedRequestType = $activeGame->recordImproperRequest($this->team, ImproperRequestType::Timeout); } } catch (InvalidGameEventTransition $exception) { $this->addError('timeout', $exception->getMessage()); @@ -85,6 +87,8 @@ public function requestTimeout(): void team: $this->team->value, hasTimeoutLeft: $hasTimeoutLeft, improperRequest: ! $hasTimeoutLeft, + delayWarning: $recordedRequestType === GameEventType::DelayWarningRecorded, + delayPenalty: $recordedRequestType === GameEventType::DelayPenaltyRecorded, ); } @@ -172,7 +176,7 @@ public function requestSubstitutionWhenFull(): void } try { - $activeGame->recordImproperRequest($this->team, ImproperRequestType::Substitution); + $recordedRequestType = $activeGame->recordImproperRequest($this->team, ImproperRequestType::Substitution); } catch (InvalidGameEventTransition $exception) { $this->addError('substitution', $exception->getMessage()); @@ -180,7 +184,12 @@ public function requestSubstitutionWhenFull(): void } $this->dispatch('game-event-recorded'); - $this->dispatch('substitution-improper-request-recorded', team: $this->team->value); + $this->dispatch( + 'substitution-improper-request-recorded', + team: $this->team->value, + delayWarning: $recordedRequestType === GameEventType::DelayWarningRecorded, + delayPenalty: $recordedRequestType === GameEventType::DelayPenaltyRecorded, + ); } /** diff --git a/app/Models/Concerns/RecordsImproperRequest.php b/app/Models/Concerns/RecordsImproperRequest.php index 125ad64..dd59277 100644 --- a/app/Models/Concerns/RecordsImproperRequest.php +++ b/app/Models/Concerns/RecordsImproperRequest.php @@ -7,6 +7,8 @@ use App\Enums\GameEventType; use App\Enums\ImproperRequestType; use App\Enums\TeamAB; +use App\Events\Payloads\DelayPenaltyRecordedPayload; +use App\Events\Payloads\DelayWarningRecordedPayload; use App\Events\Payloads\ImproperRequestRecordedPayload; use App\Models\Game; use App\Services\GameState\GameEventRuleValidator; @@ -16,10 +18,35 @@ */ trait RecordsImproperRequest { - public function recordImproperRequest(TeamAB $team, ImproperRequestType $requestType): void + public function recordImproperRequest(TeamAB $team, ImproperRequestType $requestType): GameEventType { app(GameEventRuleValidator::class)->assertCanRecordImproperRequest($this); + if ($this->hasDelayWarningFor($team)) { + $this->events()->create([ + 'type' => GameEventType::DelayPenaltyRecorded, + 'payload' => new DelayPenaltyRecordedPayload( + team: $team, + awardedTeam: $this->opponentOf($team), + requestType: $requestType, + ), + ]); + + return GameEventType::DelayPenaltyRecorded; + } + + if ($this->hasImproperRequestFor($team)) { + $this->events()->create([ + 'type' => GameEventType::DelayWarningRecorded, + 'payload' => new DelayWarningRecordedPayload( + team: $team, + requestType: $requestType, + ), + ]); + + return GameEventType::DelayWarningRecorded; + } + $this->events()->create([ 'type' => GameEventType::ImproperRequestRecorded, 'payload' => new ImproperRequestRecordedPayload( @@ -27,5 +54,34 @@ public function recordImproperRequest(TeamAB $team, ImproperRequestType $request requestType: $requestType, ), ]); + + return GameEventType::ImproperRequestRecorded; + } + + private function hasDelayWarningFor(TeamAB $team): bool + { + $state = $this->stateAt(); + + if ($team === TeamAB::TeamA) { + return $state->delayWarningsTeamA + $state->delayPenaltiesTeamA > 0; + } + + return $state->delayWarningsTeamB + $state->delayPenaltiesTeamB > 0; + } + + private function hasImproperRequestFor(TeamAB $team): bool + { + $state = $this->stateAt(); + + if ($team === TeamAB::TeamA) { + return $state->improperRequestsTeamA + $state->delayWarningsTeamA > 0; + } + + return $state->improperRequestsTeamB + $state->delayWarningsTeamB > 0; + } + + private function opponentOf(TeamAB $team): TeamAB + { + return $team === TeamAB::TeamA ? TeamAB::TeamB : TeamAB::TeamA; } } diff --git a/app/Models/GameEvent.php b/app/Models/GameEvent.php index fb74165..2459288 100644 --- a/app/Models/GameEvent.php +++ b/app/Models/GameEvent.php @@ -6,6 +6,8 @@ use App\Enums\GameEventType; use App\Events\Payloads\CourtSidesSwappedPayload; +use App\Events\Payloads\DelayPenaltyRecordedPayload; +use App\Events\Payloads\DelayWarningRecordedPayload; use App\Events\Payloads\GameEndedPayload; use App\Events\Payloads\GameEventPayload; use App\Events\Payloads\ImproperRequestRecordedPayload; @@ -70,6 +72,8 @@ public function payload(): Attribute GameEventType::SubstitutionCompleted => SubstitutionCompletedPayload::fromArray($data), GameEventType::TimeOutRequested => TimeOutRequestedPayload::fromArray($data), GameEventType::ImproperRequestRecorded => ImproperRequestRecordedPayload::fromArray($data), + GameEventType::DelayWarningRecorded => DelayWarningRecordedPayload::fromArray($data), + GameEventType::DelayPenaltyRecorded => DelayPenaltyRecordedPayload::fromArray($data), GameEventType::SetStarted => SetStartedPayload::fromArray($data), GameEventType::SetEnded => SetEndedPayload::fromArray($data), GameEventType::GameEnded => GameEndedPayload::fromArray($data), diff --git a/app/Models/GameStateSnapshot.php b/app/Models/GameStateSnapshot.php index fadb343..3e20a6c 100644 --- a/app/Models/GameStateSnapshot.php +++ b/app/Models/GameStateSnapshot.php @@ -24,6 +24,10 @@ * @property int $substitutions_team_b * @property int $improper_requests_team_a * @property int $improper_requests_team_b + * @property int $delay_warnings_team_a + * @property int $delay_warnings_team_b + * @property int $delay_penalties_team_a + * @property int $delay_penalties_team_b * @property TeamSide|null $team_a_side * @property TeamAB|null $fifth_set_left_team * @property bool $fifth_set_side_swapped @@ -58,6 +62,10 @@ protected function casts(): array 'substitutions_team_b' => 'integer', 'improper_requests_team_a' => 'integer', 'improper_requests_team_b' => 'integer', + 'delay_warnings_team_a' => 'integer', + 'delay_warnings_team_b' => 'integer', + 'delay_penalties_team_a' => 'integer', + 'delay_penalties_team_b' => 'integer', 'team_a_side' => TeamSide::class, 'fifth_set_left_team' => TeamAB::class, 'fifth_set_side_swapped' => 'boolean', diff --git a/app/Services/GameState/GameEventReactor.php b/app/Services/GameState/GameEventReactor.php index 5e8163e..0a09469 100644 --- a/app/Services/GameState/GameEventReactor.php +++ b/app/Services/GameState/GameEventReactor.php @@ -19,13 +19,13 @@ public function __construct( public function reactTo(GameEvent $event): void { match ($event->type) { - GameEventType::RallyEnded => $this->onRallyEnded($event), + GameEventType::RallyEnded, GameEventType::DelayPenaltyRecorded => $this->onRallyAwarded($event), GameEventType::SetEnded => $this->onSetEnded($event), default => null, }; } - protected function onRallyEnded(GameEvent $event): void + protected function onRallyAwarded(GameEvent $event): void { $state = $event->game->stateAt(); diff --git a/app/Services/GameState/GameStateProjector.php b/app/Services/GameState/GameStateProjector.php index 6b91b58..6fafd0b 100644 --- a/app/Services/GameState/GameStateProjector.php +++ b/app/Services/GameState/GameStateProjector.php @@ -8,6 +8,8 @@ use App\Enums\GameEventType; use App\Enums\TeamAB; use App\Events\Payloads\CourtSidesSwappedPayload; +use App\Events\Payloads\DelayPenaltyRecordedPayload; +use App\Events\Payloads\DelayWarningRecordedPayload; use App\Events\Payloads\ImproperRequestRecordedPayload; use App\Events\Payloads\LineupSubmittedPayload; use App\Events\Payloads\RallyEndedPayload; @@ -33,6 +35,8 @@ public function project(GameState $state, GameEvent $event): GameState GameEventType::SubstitutionCompleted => $this->applySubstitutionCompleted($state, $event), GameEventType::TimeOutRequested => $this->applyTimeOutRequested($state, $event), GameEventType::ImproperRequestRecorded => $this->applyImproperRequestRecorded($state, $event), + GameEventType::DelayWarningRecorded => $this->applyDelayWarningRecorded($state, $event), + GameEventType::DelayPenaltyRecorded => $this->applyDelayPenaltyRecorded($state, $event), GameEventType::SetStarted => $this->applySetStarted($state, $event), GameEventType::SetEnded => $this->applySetEnded($state, $event), GameEventType::GameEnded => $this->applyGameEnded($state), @@ -117,19 +121,8 @@ private function applyRallyEnded(GameState $state, GameEvent $event): GameState { /** @var RallyEndedPayload $payload */ $payload = $event->payload; - $winner = $payload->team; - if ($winner === TeamAB::TeamA) { - $state->scoreTeamA++; - } else { - $state->scoreTeamB++; - } - - if ($state->servingTeam !== null && $state->servingTeam !== $winner) { - $this->rotateTeam($state, $winner); - } - - $state->servingTeam = $winner; + $this->awardRallyTo($state, $payload->team); return $state; } @@ -150,6 +143,22 @@ private function applySubstitutionCompleted(GameState $state, GameEvent $event): return $state; } + private function applyDelayPenaltyRecorded(GameState $state, GameEvent $event): GameState + { + /** @var DelayPenaltyRecordedPayload $payload */ + $payload = $event->payload; + + if ($payload->team === TeamAB::TeamA) { + $state->delayPenaltiesTeamA++; + } else { + $state->delayPenaltiesTeamB++; + } + + $this->awardRallyTo($state, $payload->awardedTeam); + + return $state; + } + private function applyTimeOutRequested(GameState $state, GameEvent $event): GameState { /** @var TimeOutRequestedPayload $payload */ @@ -178,6 +187,20 @@ private function applyImproperRequestRecorded(GameState $state, GameEvent $event return $state; } + private function applyDelayWarningRecorded(GameState $state, GameEvent $event): GameState + { + /** @var DelayWarningRecordedPayload $payload */ + $payload = $event->payload; + + if ($payload->team === TeamAB::TeamA) { + $state->delayWarningsTeamA++; + } else { + $state->delayWarningsTeamB++; + } + + return $state; + } + private function applySetStarted(GameState $state, GameEvent $event): GameState { $state->setNumber = max(1, $state->setNumber + 1); @@ -214,8 +237,6 @@ private function applySetEnded(GameState $state, GameEvent $event): GameState $state->scoreTeamB = 0; $state->timeoutsTeamA = 0; $state->timeoutsTeamB = 0; - $state->improperRequestsTeamA = 0; - $state->improperRequestsTeamB = 0; $state->rotationTeamA = []; $state->rotationTeamB = []; $state->setInProgress = false; @@ -242,6 +263,21 @@ private function rotateTeam(GameState $state, TeamAB $team): void $state->rotationTeamB = $this->rotate($state->rotationTeamB); } + private function awardRallyTo(GameState $state, TeamAB $winner): void + { + if ($winner === TeamAB::TeamA) { + $state->scoreTeamA++; + } else { + $state->scoreTeamB++; + } + + if ($state->servingTeam !== null && $state->servingTeam !== $winner) { + $this->rotateTeam($state, $winner); + } + + $state->servingTeam = $winner; + } + /** * @param array $positions * @return array diff --git a/database/migrations/2026_05_22_165203_add_delay_warnings_to_game_state_snapshots_table.php b/database/migrations/2026_05_22_165203_add_delay_warnings_to_game_state_snapshots_table.php new file mode 100644 index 0000000..f761413 --- /dev/null +++ b/database/migrations/2026_05_22_165203_add_delay_warnings_to_game_state_snapshots_table.php @@ -0,0 +1,32 @@ +unsignedTinyInteger('delay_warnings_team_a')->default(0)->after('improper_requests_team_b'); + $table->unsignedTinyInteger('delay_warnings_team_b')->default(0)->after('delay_warnings_team_a'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('game_state_snapshots', function (Blueprint $table) { + $table->dropColumn([ + 'delay_warnings_team_a', + 'delay_warnings_team_b', + ]); + }); + } +}; diff --git a/database/migrations/2026_05_22_170040_add_delay_penalties_to_game_state_snapshots_table.php b/database/migrations/2026_05_22_170040_add_delay_penalties_to_game_state_snapshots_table.php new file mode 100644 index 0000000..0b7c2ce --- /dev/null +++ b/database/migrations/2026_05_22_170040_add_delay_penalties_to_game_state_snapshots_table.php @@ -0,0 +1,32 @@ +unsignedTinyInteger('delay_penalties_team_a')->default(0)->after('delay_warnings_team_b'); + $table->unsignedTinyInteger('delay_penalties_team_b')->default(0)->after('delay_penalties_team_a'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('game_state_snapshots', function (Blueprint $table) { + $table->dropColumn([ + 'delay_penalties_team_a', + 'delay_penalties_team_b', + ]); + }); + } +}; diff --git a/resources/views/livewire/team-roster.blade.php b/resources/views/livewire/team-roster.blade.php index 06edb1a..399f4d9 100644 --- a/resources/views/livewire/team-roster.blade.php +++ b/resources/views/livewire/team-roster.blade.php @@ -87,7 +87,7 @@ class="flex h-8 w-8 items-center justify-center rounded-full border border-slate $flux.modal('request-timeout-{{ $team->value }}-confirm').close(); $flux.modal('request-timeout-{{ $team->value }}-countdown').show(); countdownOpen = true; - $event.detail.hasTimeoutLeft ? start() : (phase = 'improper'); + $event.detail.hasTimeoutLeft ? start() : (phase = ($event.detail.delayPenalty ? 'penalty' : ($event.detail.delayWarning ? 'delay' : 'improper'))); } " x-on:keydown.escape.window="if (countdownOpen) $event.preventDefault()" @@ -159,6 +159,30 @@ class="min-w-[20rem]" Return to play
+ +
+
+ Delay warning + + This is the team's second improper request, so this request now constitutes a delay warning. + +
+
+ Return to play +
+
+ +
+
+ Delay penalty + + This is the team's third improper request, so this request now constitutes a delay penalty and awards the rally to the opponent. + +
+
+ Return to play +
+
@else @@ -310,73 +334,114 @@ class="h-12! w-12!" @elseif ($canShowSubstitutionFullModal) - - Substitutions - {{ $substitutionsTaken }}/6 - + + Substitutions + {{ $substitutionsTaken }}/6 + - -
-
- Request Substitution - Confirm that you want to request a substitution for this team. + +
+
+ Request Substitution + Confirm that you want to request a substitution for this team. +
+
+ + + Cancel + + + Confirm + +
-
- - - Cancel - - - Confirm - + + + +
+
+ Improper request + + This team has already used all six substitutions in this set, so this request constitutes an improper request. + +
+
+ + Return to play + +
-
-
- -
-
- Improper request - - This team has already used all six substitutions in this set, so this request constitutes an improper request. - +
+
+ Delay warning + + This is the team's second improper request, so this request now constitutes a delay warning. + +
+
+ + Return to play + +
-
- - Return to play - + +
+
+ Delay penalty + + This is the team's third improper request, so this request now constitutes a delay penalty and awards the rally to the opponent. + +
+
+ + Return to play + +
-
- + +
@else and($event->payload->requestType)->toBe(ImproperRequestType::Substitution); }); +test('a second improper request records a delay warning', function (): void { + $homeTeam = Team::factory()->create(); + $awayTeam = Team::factory()->create(); + $game = Game::factory()->betweenTeams($homeTeam, $awayTeam)->create(); + + prepareActiveSet($game); + $game->recordImproperRequest(TeamAB::TeamA, ImproperRequestType::Timeout); + $game->recordImproperRequest(TeamAB::TeamA, ImproperRequestType::Substitution); + + $event = $game->events->last(); + expect($event->type)->toBe(GameEventType::DelayWarningRecorded) + ->and($event->payload)->toBeInstanceOf(DelayWarningRecordedPayload::class) + ->and($event->payload->team)->toBe(TeamAB::TeamA) + ->and($event->payload->requestType)->toBe(ImproperRequestType::Substitution) + ->and($game->fresh()->stateAt()->improperRequestsTeamA)->toBe(1) + ->and($game->fresh()->stateAt()->delayWarningsTeamA)->toBe(1); +}); + +test('a third improper request records a delay penalty and awards the rally to the opponent', function (): void { + $homeTeam = Team::factory()->create(); + $awayTeam = Team::factory()->create(); + $game = Game::factory()->betweenTeams($homeTeam, $awayTeam)->create(); + + prepareActiveSet($game); + $game->recordImproperRequest(TeamAB::TeamA, ImproperRequestType::Timeout); + $game->recordImproperRequest(TeamAB::TeamA, ImproperRequestType::Substitution); + $game->recordImproperRequest(TeamAB::TeamA, ImproperRequestType::Timeout); + + $event = $game->events->last(); + $state = $game->fresh()->stateAt(); + + expect($event->type)->toBe(GameEventType::DelayPenaltyRecorded) + ->and($event->payload)->toBeInstanceOf(DelayPenaltyRecordedPayload::class) + ->and($event->payload->team)->toBe(TeamAB::TeamA) + ->and($event->payload->awardedTeam)->toBe(TeamAB::TeamB) + ->and($event->payload->requestType)->toBe(ImproperRequestType::Timeout) + ->and($state->scoreTeamB)->toBe(1) + ->and($state->servingTeam)->toBe(TeamAB::TeamB) + ->and($state->rotationTeamB[1])->toBe(12) + ->and($state->delayPenaltiesTeamA)->toBe(1); +}); + +test('a delay penalty awards only a point when the opponent is already serving', function (): void { + $homeTeam = Team::factory()->create(); + $awayTeam = Team::factory()->create(); + $game = Game::factory()->betweenTeams($homeTeam, $awayTeam)->create(); + + prepareActiveSet($game); + $game->recordImproperRequest(TeamAB::TeamB, ImproperRequestType::Timeout); + $game->recordImproperRequest(TeamAB::TeamB, ImproperRequestType::Substitution); + $game->recordImproperRequest(TeamAB::TeamB, ImproperRequestType::Timeout); + + $state = $game->fresh()->stateAt(); + + expect($state->scoreTeamA)->toBe(1) + ->and($state->servingTeam)->toBe(TeamAB::TeamA) + ->and($state->rotationTeamA[1])->toBe(1) + ->and($state->delayPenaltiesTeamB)->toBe(1); +}); + +test('improper requests do not reset when a set ends', function (): void { + $homeTeam = Team::factory()->create(); + $awayTeam = Team::factory()->create(); + $game = Game::factory()->betweenTeams($homeTeam, $awayTeam)->create(); + + prepareActiveSet($game); + $game->recordImproperRequest(TeamAB::TeamA, ImproperRequestType::Timeout); + + winSet($game, TeamAB::TeamA); + + $state = $game->fresh()->stateAt(); + expect($state->improperRequestsTeamA)->toBe(1) + ->and($state->delayWarningsTeamA)->toBe(0); +}); + test('timeout counts reset to zero when a set ends', function (): void { $homeTeam = Team::factory()->create(); $awayTeam = Team::factory()->create(); diff --git a/tests/Feature/TeamRosterTest.php b/tests/Feature/TeamRosterTest.php index be123eb..477c400 100644 --- a/tests/Feature/TeamRosterTest.php +++ b/tests/Feature/TeamRosterTest.php @@ -297,7 +297,7 @@ 'gameState' => $game->stateAt(), ]) ->call('requestTimeout') - ->assertDispatched('timeout-recorded', team: 'team_a', hasTimeoutLeft: true, improperRequest: false); + ->assertDispatched('timeout-recorded', team: 'team_a', hasTimeoutLeft: true, improperRequest: false, delayWarning: false, delayPenalty: false); }); test('requesting a timeout records an improper request when no timeouts remain', function (): void { @@ -314,7 +314,7 @@ ->call('requestTimeout') ->assertHasNoErrors() ->assertDispatched('game-event-recorded') - ->assertDispatched('timeout-recorded', team: 'team_a', hasTimeoutLeft: false, improperRequest: true); + ->assertDispatched('timeout-recorded', team: 'team_a', hasTimeoutLeft: false, improperRequest: true, delayWarning: false, delayPenalty: false); $freshGame = $game->fresh(); $latestEvent = $freshGame->events->last(); @@ -324,6 +324,70 @@ ->and($freshGame->stateAt()->improperRequestsTeamA)->toBe(1); }); +test('second improper timeout request records a delay warning across sets', function (): void { + $game = gameWithActiveSetForTeamRoster(); + $game->recordTimeOut(TeamAB::TeamA); + $game->recordTimeOut(TeamAB::TeamA); + $game->recordImproperRequest(TeamAB::TeamA, ImproperRequestType::Timeout); + + for ($i = 0; $i < 25; $i++) { + $game->recordRallyWinner(TeamAB::TeamA); + } + + $game->recordLineup(2, TeamAB::TeamA, [1 => 1, 2 => 2, 3 => 3, 4 => 4, 5 => 5, 6 => 6]); + $game->recordLineup(2, TeamAB::TeamB, [1 => 11, 2 => 12, 3 => 13, 4 => 14, 5 => 15, 6 => 16]); + $game->recordSetStarted(); + $game->recordTimeOut(TeamAB::TeamA); + $game->recordTimeOut(TeamAB::TeamA); + + expect($game->fresh()->stateAt()->improperRequestsTeamA)->toBe(1); + + Livewire::test(TeamRoster::class, [ + 'gameId' => $game->getKey(), + 'team' => TeamAB::TeamA, + 'leftSide' => true, + 'gameState' => $game->stateAt(), + ]) + ->call('requestTimeout') + ->assertHasNoErrors() + ->assertDispatched('game-event-recorded') + ->assertDispatched('timeout-recorded', team: 'team_a', hasTimeoutLeft: false, improperRequest: true, delayWarning: true, delayPenalty: false); + + $freshGame = $game->fresh(); + $latestEvent = $freshGame->events->last(); + + expect($latestEvent->type)->toBe(GameEventType::DelayWarningRecorded) + ->and($freshGame->stateAt()->improperRequestsTeamA)->toBe(1) + ->and($freshGame->stateAt()->delayWarningsTeamA)->toBe(1); +}); + +test('third improper timeout request records a delay penalty', function (): void { + $game = gameWithActiveSetForTeamRoster(); + $game->recordTimeOut(TeamAB::TeamA); + $game->recordTimeOut(TeamAB::TeamA); + $game->recordImproperRequest(TeamAB::TeamA, ImproperRequestType::Timeout); + $game->recordImproperRequest(TeamAB::TeamA, ImproperRequestType::Substitution); + + Livewire::test(TeamRoster::class, [ + 'gameId' => $game->getKey(), + 'team' => TeamAB::TeamA, + 'leftSide' => true, + 'gameState' => $game->stateAt(), + ]) + ->call('requestTimeout') + ->assertHasNoErrors() + ->assertDispatched('game-event-recorded') + ->assertDispatched('timeout-recorded', team: 'team_a', hasTimeoutLeft: false, improperRequest: true, delayWarning: false, delayPenalty: true); + + $freshGame = $game->fresh(); + $latestEvent = $freshGame->events->last(); + + expect($latestEvent->type)->toBe(GameEventType::DelayPenaltyRecorded) + ->and($freshGame->stateAt()->scoreTeamB)->toBe(1) + ->and($freshGame->stateAt()->servingTeam)->toBe(TeamAB::TeamB) + ->and($freshGame->stateAt()->delayPenaltiesTeamA)->toBe(1); +}); + test('timeout card becomes interactive again for a new set after two timeouts in the previous set', function (): void { $game = gameWithActiveSetForTeamRoster(); $game->recordTimeOut(TeamAB::TeamA); @@ -420,12 +484,7 @@ test('requesting a substitution records an improper request when all substitutions are used', function (): void { $game = gameWithActiveSetForTeamRoster(); - $game->recordSubstitution(TeamAB::TeamA, 1, 7); - $game->recordSubstitution(TeamAB::TeamA, 7, 1); - $game->recordSubstitution(TeamAB::TeamA, 2, 8); - $game->recordSubstitution(TeamAB::TeamA, 8, 2); - $game->recordSubstitution(TeamAB::TeamA, 3, 9); - $game->recordSubstitution(TeamAB::TeamA, 9, 3); + recordSixSubstitutionsForTeamRoster($game); Livewire::test(TeamRoster::class, [ 'gameId' => $game->getKey(), @@ -436,7 +495,7 @@ ->call('requestSubstitutionWhenFull') ->assertHasNoErrors() ->assertDispatched('game-event-recorded') - ->assertDispatched('substitution-improper-request-recorded', team: 'team_a'); + ->assertDispatched('substitution-improper-request-recorded', team: 'team_a', delayWarning: false, delayPenalty: false); $freshGame = $game->fresh(); $latestEvent = $freshGame->events->last(); @@ -447,6 +506,57 @@ ->and($freshGame->stateAt()->improperRequestsTeamA)->toBe(1); }); +test('second improper substitution request records a delay warning', function (): void { + $game = gameWithActiveSetForTeamRoster(); + $game->recordImproperRequest(TeamAB::TeamA, ImproperRequestType::Timeout); + recordSixSubstitutionsForTeamRoster($game); + + Livewire::test(TeamRoster::class, [ + 'gameId' => $game->getKey(), + 'team' => TeamAB::TeamA, + 'leftSide' => true, + 'gameState' => $game->stateAt(), + ]) + ->call('requestSubstitutionWhenFull') + ->assertHasNoErrors() + ->assertDispatched('game-event-recorded') + ->assertDispatched('substitution-improper-request-recorded', team: 'team_a', delayWarning: true, delayPenalty: false); + + $freshGame = $game->fresh(); + $latestEvent = $freshGame->events->last(); + + expect($latestEvent->type)->toBe(GameEventType::DelayWarningRecorded) + ->and($latestEvent->payload->requestType)->toBe(ImproperRequestType::Substitution) + ->and($freshGame->stateAt()->improperRequestsTeamA)->toBe(1) + ->and($freshGame->stateAt()->delayWarningsTeamA)->toBe(1); +}); + +test('third improper substitution request records a delay penalty', function (): void { + $game = gameWithActiveSetForTeamRoster(); + $game->recordImproperRequest(TeamAB::TeamA, ImproperRequestType::Timeout); + $game->recordImproperRequest(TeamAB::TeamA, ImproperRequestType::Substitution); + recordSixSubstitutionsForTeamRoster($game); + + Livewire::test(TeamRoster::class, [ + 'gameId' => $game->getKey(), + 'team' => TeamAB::TeamA, + 'leftSide' => true, + 'gameState' => $game->stateAt(), + ]) + ->call('requestSubstitutionWhenFull') + ->assertHasNoErrors() + ->assertDispatched('game-event-recorded') + ->assertDispatched('substitution-improper-request-recorded', team: 'team_a', delayWarning: false, delayPenalty: true); + + $freshGame = $game->fresh(); + $latestEvent = $freshGame->events->last(); + + expect($latestEvent->type)->toBe(GameEventType::DelayPenaltyRecorded) + ->and($latestEvent->payload->requestType)->toBe(ImproperRequestType::Substitution) + ->and($freshGame->stateAt()->scoreTeamB)->toBe(1) + ->and($freshGame->stateAt()->delayPenaltiesTeamA)->toBe(1); +}); + test('substitution modal shows on-court and bench player numbers', function (): void { $game = gameWithActiveSetForTeamRoster(); @@ -630,6 +740,16 @@ function gameWithActiveSetForTeamRoster(): Game return $game; } +function recordSixSubstitutionsForTeamRoster(Game $game): void +{ + $game->recordSubstitution(TeamAB::TeamA, 1, 7); + $game->recordSubstitution(TeamAB::TeamA, 7, 1); + $game->recordSubstitution(TeamAB::TeamA, 2, 8); + $game->recordSubstitution(TeamAB::TeamA, 8, 2); + $game->recordSubstitution(TeamAB::TeamA, 3, 9); + $game->recordSubstitution(TeamAB::TeamA, 9, 3); +} + function gameWithNumberedRostersForTeamRoster(): Game { $homeTeam = Team::factory()->create(); From 9680e7e4a9b1aea84e42b05b4a2a7b979daad1bd Mon Sep 17 00:00:00 2001 From: Giulio Troccoli-Allard Date: Sat, 23 May 2026 15:04:47 +0100 Subject: [PATCH 57/63] feat: add misconduct recording and sanction controls --- .env.example | 1 + app/Data/GameState/GameState.php | 32 ++++ app/Enums/GameEventType.php | 1 + app/Enums/MisconductSanction.php | 13 ++ app/Enums/MisconductSubjectType.php | 11 ++ .../Payloads/MisconductRecordedPayload.php | 41 ++++ app/Models/Concerns/RecordsMisconduct.php | 38 ++++ app/Models/Game.php | 2 + app/Models/GameEvent.php | 2 + app/Models/GameStateSnapshot.php | 16 ++ .../GameState/GameEventRuleValidator.php | 31 +++ app/Services/GameState/GameStateProjector.php | 26 +++ ...t_counts_to_game_state_snapshots_table.php | 44 +++++ public/icons/red-card.svg | 7 + public/icons/yellow-card.svg | 7 + public/icons/yellow-red-card.svg | 14 ++ public/icons/yellow-red-side-by-side-card.svg | 14 ++ resources/views/livewire/court.blade.php | 178 +++++++++++++----- .../views/livewire/team-roster.blade.php | 1 + tests/Feature/CourtTest.php | 30 +++ tests/Feature/GameEventTest.php | 80 ++++++++ 21 files changed, 543 insertions(+), 46 deletions(-) create mode 100644 app/Enums/MisconductSanction.php create mode 100644 app/Enums/MisconductSubjectType.php create mode 100644 app/Events/Payloads/MisconductRecordedPayload.php create mode 100644 app/Models/Concerns/RecordsMisconduct.php create mode 100644 database/migrations/2026_05_22_171403_add_misconduct_counts_to_game_state_snapshots_table.php create mode 100644 public/icons/red-card.svg create mode 100644 public/icons/yellow-card.svg create mode 100644 public/icons/yellow-red-card.svg create mode 100644 public/icons/yellow-red-side-by-side-card.svg diff --git a/.env.example b/.env.example index 7e629dd..9791219 100644 --- a/.env.example +++ b/.env.example @@ -65,3 +65,4 @@ AWS_USE_PATH_STYLE_ENDPOINT=false VITE_APP_NAME="${APP_NAME}" GAME_TIMEOUT_DURATION=30 +GAME_BETWEEN_SETS_DURATION=180 diff --git a/app/Data/GameState/GameState.php b/app/Data/GameState/GameState.php index e1318aa..206058a 100644 --- a/app/Data/GameState/GameState.php +++ b/app/Data/GameState/GameState.php @@ -33,6 +33,14 @@ public function __construct( public int $delayWarningsTeamB = 0, public int $delayPenaltiesTeamA = 0, public int $delayPenaltiesTeamB = 0, + public int $misconductWarningsTeamA = 0, + public int $misconductWarningsTeamB = 0, + public int $misconductPenaltiesTeamA = 0, + public int $misconductPenaltiesTeamB = 0, + public int $misconductExpulsionsTeamA = 0, + public int $misconductExpulsionsTeamB = 0, + public int $misconductDisqualificationsTeamA = 0, + public int $misconductDisqualificationsTeamB = 0, public ?TeamSide $teamASide = null, public ?TeamAB $fifthSetLeftTeam = null, public bool $fifthSetSideSwapped = false, @@ -64,6 +72,14 @@ public static function fromSnapshot(GameStateSnapshot $snapshot): self delayWarningsTeamB: $snapshot->delay_warnings_team_b, delayPenaltiesTeamA: $snapshot->delay_penalties_team_a, delayPenaltiesTeamB: $snapshot->delay_penalties_team_b, + misconductWarningsTeamA: $snapshot->misconduct_warnings_team_a, + misconductWarningsTeamB: $snapshot->misconduct_warnings_team_b, + misconductPenaltiesTeamA: $snapshot->misconduct_penalties_team_a, + misconductPenaltiesTeamB: $snapshot->misconduct_penalties_team_b, + misconductExpulsionsTeamA: $snapshot->misconduct_expulsions_team_a, + misconductExpulsionsTeamB: $snapshot->misconduct_expulsions_team_b, + misconductDisqualificationsTeamA: $snapshot->misconduct_disqualifications_team_a, + misconductDisqualificationsTeamB: $snapshot->misconduct_disqualifications_team_b, teamASide: $snapshot->team_a_side, fifthSetLeftTeam: $snapshot->fifth_set_left_team, fifthSetSideSwapped: $snapshot->fifth_set_side_swapped, @@ -99,6 +115,14 @@ public static function fromAttributes(array $attributes): self delayWarningsTeamB: self::toInteger($attributes['delay_warnings_team_b'] ?? 0), delayPenaltiesTeamA: self::toInteger($attributes['delay_penalties_team_a'] ?? 0), delayPenaltiesTeamB: self::toInteger($attributes['delay_penalties_team_b'] ?? 0), + misconductWarningsTeamA: self::toInteger($attributes['misconduct_warnings_team_a'] ?? 0), + misconductWarningsTeamB: self::toInteger($attributes['misconduct_warnings_team_b'] ?? 0), + misconductPenaltiesTeamA: self::toInteger($attributes['misconduct_penalties_team_a'] ?? 0), + misconductPenaltiesTeamB: self::toInteger($attributes['misconduct_penalties_team_b'] ?? 0), + misconductExpulsionsTeamA: self::toInteger($attributes['misconduct_expulsions_team_a'] ?? 0), + misconductExpulsionsTeamB: self::toInteger($attributes['misconduct_expulsions_team_b'] ?? 0), + misconductDisqualificationsTeamA: self::toInteger($attributes['misconduct_disqualifications_team_a'] ?? 0), + misconductDisqualificationsTeamB: self::toInteger($attributes['misconduct_disqualifications_team_b'] ?? 0), teamASide: is_string($attributes['team_a_side'] ?? null) ? TeamSide::tryFrom($attributes['team_a_side']) : null, @@ -147,6 +171,14 @@ public function toAttributes(): array 'delay_warnings_team_b' => $this->delayWarningsTeamB, 'delay_penalties_team_a' => $this->delayPenaltiesTeamA, 'delay_penalties_team_b' => $this->delayPenaltiesTeamB, + 'misconduct_warnings_team_a' => $this->misconductWarningsTeamA, + 'misconduct_warnings_team_b' => $this->misconductWarningsTeamB, + 'misconduct_penalties_team_a' => $this->misconductPenaltiesTeamA, + 'misconduct_penalties_team_b' => $this->misconductPenaltiesTeamB, + 'misconduct_expulsions_team_a' => $this->misconductExpulsionsTeamA, + 'misconduct_expulsions_team_b' => $this->misconductExpulsionsTeamB, + 'misconduct_disqualifications_team_a' => $this->misconductDisqualificationsTeamA, + 'misconduct_disqualifications_team_b' => $this->misconductDisqualificationsTeamB, 'team_a_side' => $this->teamASide?->value, 'fifth_set_left_team' => $this->fifthSetLeftTeam?->value, 'fifth_set_side_swapped' => $this->fifthSetSideSwapped, diff --git a/app/Enums/GameEventType.php b/app/Enums/GameEventType.php index aab4c4c..6bc0a92 100644 --- a/app/Enums/GameEventType.php +++ b/app/Enums/GameEventType.php @@ -15,6 +15,7 @@ enum GameEventType: string case ImproperRequestRecorded = 'improper_request_recorded'; case DelayWarningRecorded = 'delay_warning_recorded'; case DelayPenaltyRecorded = 'delay_penalty_recorded'; + case MisconductRecorded = 'misconduct_recorded'; case SetStarted = 'set_started'; case SetEnded = 'set_ended'; case GameEnded = 'game_ended'; diff --git a/app/Enums/MisconductSanction.php b/app/Enums/MisconductSanction.php new file mode 100644 index 0000000..c3a8e1d --- /dev/null +++ b/app/Enums/MisconductSanction.php @@ -0,0 +1,13 @@ + $data */ + public static function fromArray(array $data): static + { + return new self( + team: TeamAB::from($data['team']), + subjectType: MisconductSubjectType::from($data['subject_type']), + subjectId: (int) $data['subject_id'], + sanction: MisconductSanction::from($data['sanction']), + ); + } + + /** @return array{team: string, subject_type: string, subject_id: int, sanction: string} */ + public function toArray(): array + { + return [ + 'team' => $this->team->value, + 'subject_type' => $this->subjectType->value, + 'subject_id' => $this->subjectId, + 'sanction' => $this->sanction->value, + ]; + } +} diff --git a/app/Models/Concerns/RecordsMisconduct.php b/app/Models/Concerns/RecordsMisconduct.php new file mode 100644 index 0000000..9b25cd2 --- /dev/null +++ b/app/Models/Concerns/RecordsMisconduct.php @@ -0,0 +1,38 @@ +assertCanRecordMisconduct($this, $team, $subjectType, $subjectId); + + $this->events()->create([ + 'type' => GameEventType::MisconductRecorded, + 'payload' => new MisconductRecordedPayload( + team: $team, + subjectType: $subjectType, + subjectId: $subjectId, + sanction: $sanction, + ), + ]); + } +} diff --git a/app/Models/Game.php b/app/Models/Game.php index ac30d75..fba2dca 100644 --- a/app/Models/Game.php +++ b/app/Models/Game.php @@ -13,6 +13,7 @@ use App\Models\Concerns\RecordsEndOfSet; use App\Models\Concerns\RecordsImproperRequest; use App\Models\Concerns\RecordsLineup; +use App\Models\Concerns\RecordsMisconduct; use App\Models\Concerns\RecordsStartOfSet; use App\Models\Concerns\RecordsSubstitution; use App\Models\Concerns\RecordsTimeOut; @@ -64,6 +65,7 @@ class Game extends Model use RecordsEndOfSet; use RecordsImproperRequest; use RecordsLineup; + use RecordsMisconduct; use RecordsStartOfSet; use RecordsSubstitution; use RecordsTimeOut; diff --git a/app/Models/GameEvent.php b/app/Models/GameEvent.php index 2459288..b4d2ea6 100644 --- a/app/Models/GameEvent.php +++ b/app/Models/GameEvent.php @@ -12,6 +12,7 @@ use App\Events\Payloads\GameEventPayload; use App\Events\Payloads\ImproperRequestRecordedPayload; use App\Events\Payloads\LineupSubmittedPayload; +use App\Events\Payloads\MisconductRecordedPayload; use App\Events\Payloads\RallyEndedPayload; use App\Events\Payloads\SetEndedPayload; use App\Events\Payloads\SetStartedPayload; @@ -74,6 +75,7 @@ public function payload(): Attribute GameEventType::ImproperRequestRecorded => ImproperRequestRecordedPayload::fromArray($data), GameEventType::DelayWarningRecorded => DelayWarningRecordedPayload::fromArray($data), GameEventType::DelayPenaltyRecorded => DelayPenaltyRecordedPayload::fromArray($data), + GameEventType::MisconductRecorded => MisconductRecordedPayload::fromArray($data), GameEventType::SetStarted => SetStartedPayload::fromArray($data), GameEventType::SetEnded => SetEndedPayload::fromArray($data), GameEventType::GameEnded => GameEndedPayload::fromArray($data), diff --git a/app/Models/GameStateSnapshot.php b/app/Models/GameStateSnapshot.php index 3e20a6c..c945ce4 100644 --- a/app/Models/GameStateSnapshot.php +++ b/app/Models/GameStateSnapshot.php @@ -28,6 +28,14 @@ * @property int $delay_warnings_team_b * @property int $delay_penalties_team_a * @property int $delay_penalties_team_b + * @property int $misconduct_warnings_team_a + * @property int $misconduct_warnings_team_b + * @property int $misconduct_penalties_team_a + * @property int $misconduct_penalties_team_b + * @property int $misconduct_expulsions_team_a + * @property int $misconduct_expulsions_team_b + * @property int $misconduct_disqualifications_team_a + * @property int $misconduct_disqualifications_team_b * @property TeamSide|null $team_a_side * @property TeamAB|null $fifth_set_left_team * @property bool $fifth_set_side_swapped @@ -66,6 +74,14 @@ protected function casts(): array 'delay_warnings_team_b' => 'integer', 'delay_penalties_team_a' => 'integer', 'delay_penalties_team_b' => 'integer', + 'misconduct_warnings_team_a' => 'integer', + 'misconduct_warnings_team_b' => 'integer', + 'misconduct_penalties_team_a' => 'integer', + 'misconduct_penalties_team_b' => 'integer', + 'misconduct_expulsions_team_a' => 'integer', + 'misconduct_expulsions_team_b' => 'integer', + 'misconduct_disqualifications_team_a' => 'integer', + 'misconduct_disqualifications_team_b' => 'integer', 'team_a_side' => TeamSide::class, 'fifth_set_left_team' => TeamAB::class, 'fifth_set_side_swapped' => 'boolean', diff --git a/app/Services/GameState/GameEventRuleValidator.php b/app/Services/GameState/GameEventRuleValidator.php index df2abf9..7e1ec91 100644 --- a/app/Services/GameState/GameEventRuleValidator.php +++ b/app/Services/GameState/GameEventRuleValidator.php @@ -6,6 +6,7 @@ use App\Data\GameState\GameState; use App\Enums\GameEventType; +use App\Enums\MisconductSubjectType; use App\Enums\TeamAB; use App\Exceptions\InvalidGameEventTransition; use App\Models\Game; @@ -144,6 +145,36 @@ public function assertCanRecordImproperRequest(Game $game): void } } + public function assertCanRecordMisconduct( + Game $game, + TeamAB $team, + MisconductSubjectType $subjectType, + int $subjectId, + ): void { + $state = $game->stateAt(); + + if ($state->gameEnded) { + $this->fail('Misconduct cannot be recorded after the game has ended.'); + } + + $teamId = $team === TeamAB::TeamA ? $game->home_team_id : $game->away_team_id; + + $isRostered = match ($subjectType) { + MisconductSubjectType::Player => $game->players() + ->whereKey($subjectId) + ->wherePivot('team_id', $teamId) + ->exists(), + MisconductSubjectType::Staff => $game->staff() + ->whereKey($subjectId) + ->wherePivot('team_id', $teamId) + ->exists(), + }; + + if (! $isRostered) { + $this->fail('Misconduct can only be recorded for a rostered player or staff member.'); + } + } + public function assertCanRecordSetEnded(Game $game): void { $state = $game->stateAt(); diff --git a/app/Services/GameState/GameStateProjector.php b/app/Services/GameState/GameStateProjector.php index 6fafd0b..d1ff25c 100644 --- a/app/Services/GameState/GameStateProjector.php +++ b/app/Services/GameState/GameStateProjector.php @@ -6,12 +6,14 @@ use App\Data\GameState\GameState; use App\Enums\GameEventType; +use App\Enums\MisconductSanction; use App\Enums\TeamAB; use App\Events\Payloads\CourtSidesSwappedPayload; use App\Events\Payloads\DelayPenaltyRecordedPayload; use App\Events\Payloads\DelayWarningRecordedPayload; use App\Events\Payloads\ImproperRequestRecordedPayload; use App\Events\Payloads\LineupSubmittedPayload; +use App\Events\Payloads\MisconductRecordedPayload; use App\Events\Payloads\RallyEndedPayload; use App\Events\Payloads\SubstitutionCompletedPayload; use App\Events\Payloads\TimeOutRequestedPayload; @@ -37,6 +39,7 @@ public function project(GameState $state, GameEvent $event): GameState GameEventType::ImproperRequestRecorded => $this->applyImproperRequestRecorded($state, $event), GameEventType::DelayWarningRecorded => $this->applyDelayWarningRecorded($state, $event), GameEventType::DelayPenaltyRecorded => $this->applyDelayPenaltyRecorded($state, $event), + GameEventType::MisconductRecorded => $this->applyMisconductRecorded($state, $event), GameEventType::SetStarted => $this->applySetStarted($state, $event), GameEventType::SetEnded => $this->applySetEnded($state, $event), GameEventType::GameEnded => $this->applyGameEnded($state), @@ -201,6 +204,29 @@ private function applyDelayWarningRecorded(GameState $state, GameEvent $event): return $state; } + private function applyMisconductRecorded(GameState $state, GameEvent $event): GameState + { + /** @var MisconductRecordedPayload $payload */ + $payload = $event->payload; + + match ($payload->sanction) { + MisconductSanction::Warning => $payload->team === TeamAB::TeamA + ? $state->misconductWarningsTeamA++ + : $state->misconductWarningsTeamB++, + MisconductSanction::Penalty => $payload->team === TeamAB::TeamA + ? $state->misconductPenaltiesTeamA++ + : $state->misconductPenaltiesTeamB++, + MisconductSanction::Expulsion => $payload->team === TeamAB::TeamA + ? $state->misconductExpulsionsTeamA++ + : $state->misconductExpulsionsTeamB++, + MisconductSanction::Disqualification => $payload->team === TeamAB::TeamA + ? $state->misconductDisqualificationsTeamA++ + : $state->misconductDisqualificationsTeamB++, + }; + + return $state; + } + private function applySetStarted(GameState $state, GameEvent $event): GameState { $state->setNumber = max(1, $state->setNumber + 1); diff --git a/database/migrations/2026_05_22_171403_add_misconduct_counts_to_game_state_snapshots_table.php b/database/migrations/2026_05_22_171403_add_misconduct_counts_to_game_state_snapshots_table.php new file mode 100644 index 0000000..d5a27f0 --- /dev/null +++ b/database/migrations/2026_05_22_171403_add_misconduct_counts_to_game_state_snapshots_table.php @@ -0,0 +1,44 @@ +unsignedTinyInteger('misconduct_warnings_team_a')->default(0)->after('delay_penalties_team_b'); + $table->unsignedTinyInteger('misconduct_warnings_team_b')->default(0)->after('misconduct_warnings_team_a'); + $table->unsignedTinyInteger('misconduct_penalties_team_a')->default(0)->after('misconduct_warnings_team_b'); + $table->unsignedTinyInteger('misconduct_penalties_team_b')->default(0)->after('misconduct_penalties_team_a'); + $table->unsignedTinyInteger('misconduct_expulsions_team_a')->default(0)->after('misconduct_penalties_team_b'); + $table->unsignedTinyInteger('misconduct_expulsions_team_b')->default(0)->after('misconduct_expulsions_team_a'); + $table->unsignedTinyInteger('misconduct_disqualifications_team_a')->default(0)->after('misconduct_expulsions_team_b'); + $table->unsignedTinyInteger('misconduct_disqualifications_team_b')->default(0)->after('misconduct_disqualifications_team_a'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('game_state_snapshots', function (Blueprint $table) { + $table->dropColumn([ + 'misconduct_warnings_team_a', + 'misconduct_warnings_team_b', + 'misconduct_penalties_team_a', + 'misconduct_penalties_team_b', + 'misconduct_expulsions_team_a', + 'misconduct_expulsions_team_b', + 'misconduct_disqualifications_team_a', + 'misconduct_disqualifications_team_b', + ]); + }); + } +}; diff --git a/public/icons/red-card.svg b/public/icons/red-card.svg new file mode 100644 index 0000000..d379f9b --- /dev/null +++ b/public/icons/red-card.svg @@ -0,0 +1,7 @@ + + Red card + A red volleyball sanction card shaped like a playing card. + + + + diff --git a/public/icons/yellow-card.svg b/public/icons/yellow-card.svg new file mode 100644 index 0000000..45d92c9 --- /dev/null +++ b/public/icons/yellow-card.svg @@ -0,0 +1,7 @@ + + Yellow card + A yellow volleyball sanction card shaped like a playing card. + + + + diff --git a/public/icons/yellow-red-card.svg b/public/icons/yellow-red-card.svg new file mode 100644 index 0000000..2ca760e --- /dev/null +++ b/public/icons/yellow-red-card.svg @@ -0,0 +1,14 @@ + + Yellow and red card + A yellow card behind a red card, fanned from the bottom. + + + + + + + + + + + diff --git a/public/icons/yellow-red-side-by-side-card.svg b/public/icons/yellow-red-side-by-side-card.svg new file mode 100644 index 0000000..0e62811 --- /dev/null +++ b/public/icons/yellow-red-side-by-side-card.svg @@ -0,0 +1,14 @@ + + Yellow and red cards side by side + A yellow volleyball sanction card next to a red volleyball sanction card. + + + + + + + + + + + diff --git a/resources/views/livewire/court.blade.php b/resources/views/livewire/court.blade.php index 9e8814c..c83136c 100644 --- a/resources/views/livewire/court.blade.php +++ b/resources/views/livewire/court.blade.php @@ -28,57 +28,143 @@ $leftMarkerTone = $leftTeam === \App\Enums\TeamAB::TeamA ? 'bg-blue-600' : 'bg-red-600'; $rightMarkerTone = $rightTeam === \App\Enums\TeamAB::TeamA ? 'bg-blue-600' : 'bg-red-600'; + + $misconductControls = [ + ['sanction' => 'warning', 'label' => 'Minor misconduct', 'icon' => asset('icons/yellow-card.svg')], + ['sanction' => 'penalty', 'label' => 'Penalty', 'icon' => asset('icons/red-card.svg')], + ['sanction' => 'expulsion', 'label' => 'Expulsion', 'icon' => asset('icons/yellow-red-card.svg')], + ['sanction' => 'disqualification', 'label' => 'Disqualification', 'icon' => asset('icons/yellow-red-side-by-side-card.svg')], + ]; + + $delayControls = [ + ['sanction' => 'delay-warning', 'label' => 'Delay warning', 'icon' => asset('icons/yellow-card.svg')], + ['sanction' => 'delay-penalty', 'label' => 'Delay penalty', 'icon' => asset('icons/red-card.svg')], + ]; @endphp
-
-
-
-
-
- - @foreach ($leftRotation as $position => $number) - @php - $isServingPlayer = $position === 1 && $servingTeam === $leftTeam; - $markerPositionClass = $isServingPlayer - ? ($leftOutsidePositionClasses[$position] ?? $leftCourtPositionClasses[$position] ?? '') - : ($leftCourtPositionClasses[$position] ?? ''); - @endphp -
- {{ $number }} +
+
+
+
diff --git a/resources/views/livewire/team-roster.blade.php b/resources/views/livewire/team-roster.blade.php index 399f4d9..2df35b7 100644 --- a/resources/views/livewire/team-roster.blade.php +++ b/resources/views/livewire/team-roster.blade.php @@ -452,5 +452,6 @@ class="p-1.5 content-center-safe!" {{ $substitutionsTaken }}/6 @endif +
diff --git a/tests/Feature/CourtTest.php b/tests/Feature/CourtTest.php index 3032a9d..1eca6b2 100644 --- a/tests/Feature/CourtTest.php +++ b/tests/Feature/CourtTest.php @@ -415,6 +415,36 @@ ->assertSeeHtml('data-rally-winner-button="team_b"'); }); +test('court renders misconduct controls on both sides', function (): void { + $game = gameWithNumberedRosters(); + $game->recordToss(TeamSide::Home, TeamAB::TeamA); + + Livewire::test(Court::class, [ + 'gameId' => $game->getKey(), + 'gameState' => gameStateWithSubmittedLineups(['set_number' => 1]), + ]) + ->assertSeeHtml('data-misconduct-controls="left"') + ->assertSeeHtml('data-misconduct-controls="right"') + ->assertSeeHtml('data-misconduct-button="warning"') + ->assertSeeHtml('data-misconduct-button="penalty"') + ->assertSeeHtml('data-misconduct-button="expulsion"') + ->assertSeeHtml('data-misconduct-button="disqualification"') + ->assertSeeHtml('data-delay-controls="left"') + ->assertSeeHtml('data-delay-controls="right"') + ->assertSeeHtml('data-delay-button="delay-warning"') + ->assertSeeHtml('data-delay-button="delay-penalty"') + ->assertSeeHtml('yellow-card.svg') + ->assertSeeHtml('red-card.svg') + ->assertSeeHtml('yellow-red-card.svg') + ->assertSeeHtml('yellow-red-side-by-side-card.svg') + ->assertSee('Minor misconduct') + ->assertSee('Penalty') + ->assertSee('Expulsion') + ->assertSee('Disqualification') + ->assertSee('Delay warning') + ->assertSee('Delay penalty'); +}); + test('rally winner controls show button only while a set is in progress and game is not ended', function (): void { $game = Game::factory()->create(); diff --git a/tests/Feature/GameEventTest.php b/tests/Feature/GameEventTest.php index ecdd310..423ca4f 100644 --- a/tests/Feature/GameEventTest.php +++ b/tests/Feature/GameEventTest.php @@ -2,6 +2,9 @@ use App\Enums\GameEventType; use App\Enums\ImproperRequestType; +use App\Enums\MisconductSanction; +use App\Enums\MisconductSubjectType; +use App\Enums\StaffRole; use App\Enums\TeamAB; use App\Enums\TeamSide; use App\Events\Payloads\CourtSidesSwappedPayload; @@ -10,6 +13,7 @@ use App\Events\Payloads\GameEndedPayload; use App\Events\Payloads\ImproperRequestRecordedPayload; use App\Events\Payloads\LineupSubmittedPayload; +use App\Events\Payloads\MisconductRecordedPayload; use App\Events\Payloads\RallyEndedPayload; use App\Events\Payloads\SetEndedPayload; use App\Events\Payloads\SetStartedPayload; @@ -21,6 +25,7 @@ use App\Models\GameEvent; use App\Models\GameStateSnapshot; use App\Models\Player; +use App\Models\Staff; use App\Models\Team; use App\Services\GameState\GameEventRuleValidator; use Illuminate\Foundation\Testing\RefreshDatabase; @@ -637,6 +642,81 @@ function submitLineupsForSet(Game $game, int $set): void ->and($state->delayWarningsTeamA)->toBe(0); }); +test('misconduct can be recorded for a player with the correct type and payload', function (MisconductSanction $sanction): void { + $homeTeam = Team::factory()->create(); + $awayTeam = Team::factory()->create(); + $game = Game::factory()->betweenTeams($homeTeam, $awayTeam)->create(); + + prepareActiveSet($game); + $player = $game->homePlayers()->firstOrFail(); + + $game->recordMisconduct( + team: TeamAB::TeamA, + subjectType: MisconductSubjectType::Player, + subjectId: $player->getKey(), + sanction: $sanction, + ); + + $event = $game->events->last(); + expect($event->type)->toBe(GameEventType::MisconductRecorded) + ->and($event->payload)->toBeInstanceOf(MisconductRecordedPayload::class) + ->and($event->payload->team)->toBe(TeamAB::TeamA) + ->and($event->payload->subjectType)->toBe(MisconductSubjectType::Player) + ->and($event->payload->subjectId)->toBe($player->getKey()) + ->and($event->payload->sanction)->toBe($sanction); +})->with([ + 'warning' => [MisconductSanction::Warning], + 'penalty' => [MisconductSanction::Penalty], + 'expulsion' => [MisconductSanction::Expulsion], + 'disqualification' => [MisconductSanction::Disqualification], +]); + +test('misconduct can be recorded for staff', function (): void { + $homeTeam = Team::factory()->create(); + $awayTeam = Team::factory()->create(); + $staff = Staff::factory()->for($homeTeam)->create(); + $game = Game::factory()->betweenTeams($homeTeam, $awayTeam)->create(); + + prepareActiveSet($game); + $game->addStaff($staff, StaffRole::Coach); + + $game->recordMisconduct( + team: TeamAB::TeamA, + subjectType: MisconductSubjectType::Staff, + subjectId: $staff->getKey(), + sanction: MisconductSanction::Expulsion, + ); + + $event = $game->events->last(); + expect($event->type)->toBe(GameEventType::MisconductRecorded) + ->and($event->payload)->toBeInstanceOf(MisconductRecordedPayload::class) + ->and($event->payload->subjectType)->toBe(MisconductSubjectType::Staff) + ->and($event->payload->subjectId)->toBe($staff->getKey()) + ->and($event->payload->sanction)->toBe(MisconductSanction::Expulsion); +}); + +test('misconduct counts do not reset when a set ends', function (): void { + $homeTeam = Team::factory()->create(); + $awayTeam = Team::factory()->create(); + $game = Game::factory()->betweenTeams($homeTeam, $awayTeam)->create(); + + prepareActiveSet($game); + $player = $game->homePlayers()->firstOrFail(); + + $game->recordMisconduct(TeamAB::TeamA, MisconductSubjectType::Player, $player->getKey(), MisconductSanction::Warning); + $game->recordMisconduct(TeamAB::TeamA, MisconductSubjectType::Player, $player->getKey(), MisconductSanction::Penalty); + $game->recordMisconduct(TeamAB::TeamA, MisconductSubjectType::Player, $player->getKey(), MisconductSanction::Expulsion); + $game->recordMisconduct(TeamAB::TeamA, MisconductSubjectType::Player, $player->getKey(), MisconductSanction::Disqualification); + + winSet($game, TeamAB::TeamA); + + $state = $game->fresh()->stateAt(); + expect($state->misconductWarningsTeamA)->toBe(1) + ->and($state->misconductPenaltiesTeamA)->toBe(1) + ->and($state->misconductExpulsionsTeamA)->toBe(1) + ->and($state->misconductDisqualificationsTeamA)->toBe(1); +}); + test('timeout counts reset to zero when a set ends', function (): void { $homeTeam = Team::factory()->create(); $awayTeam = Team::factory()->create(); From b9bcfdd9a1156dd9dd77c2a1fafa7a4b4678d9d4 Mon Sep 17 00:00:00 2001 From: Giulio Troccoli-Allard Date: Sat, 23 May 2026 15:09:49 +0100 Subject: [PATCH 58/63] docs: add git commit guidelines --- .ai/guidelines/git.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .ai/guidelines/git.md diff --git a/.ai/guidelines/git.md b/.ai/guidelines/git.md new file mode 100644 index 0000000..22a14fa --- /dev/null +++ b/.ai/guidelines/git.md @@ -0,0 +1,5 @@ +## Git + +- Use Conventional Commits for commit messages. +- Format commit subjects as `: `, for example `feat: add misconduct controls` or `fix: correct timeout confirmation state`. +- Prefer standard types such as `feat`, `fix`, `test`, `refactor`, `docs`, `chore`, and `style`. From 872751966a1186ba4655e5bfadf201e333ac8e29 Mon Sep 17 00:00:00 2001 From: Giulio Troccoli-Allard Date: Sun, 31 May 2026 10:53:40 +0100 Subject: [PATCH 59/63] Add court sanctions workflows --- .../Payloads/DelayPenaltyRecordedPayload.php | 15 +- .../Payloads/DelayWarningRecordedPayload.php | 15 +- app/Livewire/Court.php | 356 +++++++++++++- .../Concerns/RecordsImproperRequest.php | 25 + app/Models/Concerns/RecordsMisconduct.php | 2 +- app/Services/GameState/GameEventReactor.php | 15 + .../GameState/GameEventRuleValidator.php | 62 +++ app/Services/GameState/GameStateProjector.php | 5 + resources/views/livewire/court.blade.php | 464 +++++++++++++++--- tests/Feature/CourtTest.php | 339 ++++++++++++- tests/Feature/GameEventTest.php | 195 ++++++++ 11 files changed, 1413 insertions(+), 80 deletions(-) diff --git a/app/Events/Payloads/DelayPenaltyRecordedPayload.php b/app/Events/Payloads/DelayPenaltyRecordedPayload.php index 56e0c57..51350c9 100644 --- a/app/Events/Payloads/DelayPenaltyRecordedPayload.php +++ b/app/Events/Payloads/DelayPenaltyRecordedPayload.php @@ -12,7 +12,7 @@ public function __construct( public TeamAB $team, public TeamAB $awardedTeam, - public ImproperRequestType $requestType, + public ?ImproperRequestType $requestType = null, ) {} /** @param array $data */ @@ -21,17 +21,22 @@ public static function fromArray(array $data): static return new self( team: TeamAB::from($data['team']), awardedTeam: TeamAB::from($data['awarded_team']), - requestType: ImproperRequestType::from($data['request_type']), + requestType: isset($data['request_type']) ? ImproperRequestType::from($data['request_type']) : null, ); } - /** @return array{team: string, awarded_team: string, request_type: string} */ + /** @return array{team: string, awarded_team: string, request_type?: string} */ public function toArray(): array { - return [ + $payload = [ 'team' => $this->team->value, 'awarded_team' => $this->awardedTeam->value, - 'request_type' => $this->requestType->value, ]; + + if ($this->requestType !== null) { + $payload['request_type'] = $this->requestType->value; + } + + return $payload; } } diff --git a/app/Events/Payloads/DelayWarningRecordedPayload.php b/app/Events/Payloads/DelayWarningRecordedPayload.php index 51231fe..0b36e3d 100644 --- a/app/Events/Payloads/DelayWarningRecordedPayload.php +++ b/app/Events/Payloads/DelayWarningRecordedPayload.php @@ -11,7 +11,7 @@ { public function __construct( public TeamAB $team, - public ImproperRequestType $requestType, + public ?ImproperRequestType $requestType = null, ) {} /** @param array $data */ @@ -19,16 +19,21 @@ public static function fromArray(array $data): static { return new self( team: TeamAB::from($data['team']), - requestType: ImproperRequestType::from($data['request_type']), + requestType: isset($data['request_type']) ? ImproperRequestType::from($data['request_type']) : null, ); } - /** @return array{team: string, request_type: string} */ + /** @return array{team: string, request_type?: string} */ public function toArray(): array { - return [ + $payload = [ 'team' => $this->team->value, - 'request_type' => $this->requestType->value, ]; + + if ($this->requestType !== null) { + $payload['request_type'] = $this->requestType->value; + } + + return $payload; } } diff --git a/app/Livewire/Court.php b/app/Livewire/Court.php index e806076..e5130d0 100644 --- a/app/Livewire/Court.php +++ b/app/Livewire/Court.php @@ -5,9 +5,18 @@ namespace App\Livewire; use App\Data\GameState\GameState; +use App\Enums\GameEventType; +use App\Enums\MisconductSanction; +use App\Enums\MisconductSubjectType; +use App\Enums\StaffRole; use App\Enums\TeamAB; +use App\Events\Payloads\MisconductRecordedPayload; use App\Models\Game; +use App\Models\GameEvent; +use App\Models\Player; +use App\Models\Staff; use App\Services\GameSideResolver; +use Flux\Flux; use Illuminate\Contracts\View\View; use Livewire\Attributes\Reactive; use Livewire\Component; @@ -20,6 +29,24 @@ class Court extends Component #[Reactive] public ?GameState $gameState = null; + public ?string $pendingDelayWarningTeam = null; + + public ?string $pendingDelayPenaltyTeam = null; + + public ?string $delaySanctionRecordedTitle = null; + + public ?string $delaySanctionRecordedMessage = null; + + public ?string $pendingMisconductTeam = null; + + public ?string $pendingMisconductSanction = null; + + public ?string $pendingMisconductSubjectType = null; + + public ?int $pendingMisconductSubjectId = null; + + public ?string $pendingMisconductSubjectLabel = null; + public function mount(?int $gameId = null): void { $this->gameId = $gameId; @@ -30,6 +57,125 @@ public function render(): View return view('livewire.court', $this->courtContext()); } + public function requestDelayWarning(string $team): void + { + $this->pendingDelayWarningTeam = TeamAB::from($team)->value; + + Flux::modal('record-delay-warning-confirm')->show(); + } + + public function requestDelayPenalty(string $team): void + { + $this->pendingDelayPenaltyTeam = TeamAB::from($team)->value; + + Flux::modal('record-delay-penalty-confirm')->show(); + } + + public function requestMisconduct(string $team, string $sanction): void + { + $this->pendingMisconductTeam = TeamAB::from($team)->value; + $this->pendingMisconductSanction = MisconductSanction::from($sanction)->value; + $this->pendingMisconductSubjectType = null; + $this->pendingMisconductSubjectId = null; + $this->pendingMisconductSubjectLabel = null; + + Flux::modal('select-misconduct-subject')->show(); + } + + public function selectMisconductSubject(string $subjectType, int $subjectId): void + { + if ($this->pendingMisconductTeam === null || $this->pendingMisconductSanction === null) { + return; + } + + $this->pendingMisconductSubjectType = MisconductSubjectType::from($subjectType)->value; + $this->pendingMisconductSubjectId = $subjectId; + $this->pendingMisconductSubjectLabel = $this->misconductSubjectLabel( + MisconductSubjectType::from($subjectType), + $subjectId, + ); + + Flux::modal('select-misconduct-subject')->close(); + Flux::modal('record-misconduct-confirm')->show(); + } + + public function recordPendingDelayWarning(): void + { + if ($this->pendingDelayWarningTeam === null || $this->gameId === null) { + return; + } + + $team = TeamAB::from($this->pendingDelayWarningTeam); + $game = Game::query()->findSole($this->gameId); + $game->recordDelayWarning($team); + + $this->pendingDelayWarningTeam = null; + $this->delaySanctionRecordedTitle = 'Delay warning recorded'; + $this->delaySanctionRecordedMessage = "A delay warning has been recorded for {$team->label()}."; + + Flux::modal('record-delay-warning-confirm')->close(); + Flux::modal('delay-sanction-recorded')->show(); + + $this->dispatch('game-event-recorded'); + } + + public function recordPendingDelayPenalty(): void + { + if ($this->pendingDelayPenaltyTeam === null || $this->gameId === null) { + return; + } + + $team = TeamAB::from($this->pendingDelayPenaltyTeam); + $game = Game::query()->findSole($this->gameId); + $game->recordDelayPenalty($team); + + $this->pendingDelayPenaltyTeam = null; + $this->delaySanctionRecordedTitle = 'Delay penalty recorded'; + $this->delaySanctionRecordedMessage = "A delay penalty has been recorded for {$team->label()}."; + + Flux::modal('record-delay-penalty-confirm')->close(); + Flux::modal('delay-sanction-recorded')->show(); + + $this->dispatch('game-event-recorded'); + } + + public function recordPendingMisconduct(): void + { + if ( + $this->pendingMisconductTeam === null + || $this->pendingMisconductSanction === null + || $this->pendingMisconductSubjectType === null + || $this->pendingMisconductSubjectId === null + || $this->gameId === null + ) { + return; + } + + $team = TeamAB::from($this->pendingMisconductTeam); + $sanction = MisconductSanction::from($this->pendingMisconductSanction); + $subjectType = MisconductSubjectType::from($this->pendingMisconductSubjectType); + $game = Game::query()->findSole($this->gameId); + + $game->recordMisconduct($team, $subjectType, $this->pendingMisconductSubjectId, $sanction); + + $this->delaySanctionRecordedTitle = 'Misconduct recorded'; + $this->delaySanctionRecordedMessage = "{$this->misconductSanctionLabel($sanction)} has been recorded for {$this->pendingMisconductSubjectLabel}."; + $this->resetPendingMisconduct(); + + Flux::modal('record-misconduct-confirm')->close(); + Flux::modal('delay-sanction-recorded')->show(); + + $this->dispatch('game-event-recorded'); + } + + public function dismissDelaySanctionRecordedMessage(): void + { + $this->delaySanctionRecordedTitle = null; + $this->delaySanctionRecordedMessage = null; + + Flux::modal('delay-sanction-recorded')->close(); + } + /** * @return array{ * leftTeam: TeamAB, @@ -37,7 +183,18 @@ public function render(): View * servingTeam: TeamAB|null, * showRosters: bool, * leftRotation: array, - * rightRotation: array + * rightRotation: array, + * leftDelayWarningDisabled: bool, + * rightDelayWarningDisabled: bool, + * leftDelayPenaltyDisabled: bool, + * rightDelayPenaltyDisabled: bool, + * leftMinorMisconductDisabled: bool, + * rightMinorMisconductDisabled: bool, + * misconductSubjects: array{ + * players: array, + * staff: array + * }, + * pendingMisconductSanctionLabel: string|null * } */ private function courtContext(): array @@ -55,6 +212,14 @@ private function courtContext(): array 'showRosters' => $showRosters, 'leftRotation' => $this->rotationForTeam($leftTeam), 'rightRotation' => $this->rotationForTeam($rightTeam), + 'leftDelayWarningDisabled' => $this->hasDelaySanctionFor($leftTeam), + 'rightDelayWarningDisabled' => $this->hasDelaySanctionFor($rightTeam), + 'leftDelayPenaltyDisabled' => ! $this->hasDelaySanctionFor($leftTeam), + 'rightDelayPenaltyDisabled' => ! $this->hasDelaySanctionFor($rightTeam), + 'leftMinorMisconductDisabled' => $this->hasMinorMisconductFor($leftTeam), + 'rightMinorMisconductDisabled' => $this->hasMinorMisconductFor($rightTeam), + 'misconductSubjects' => $this->pendingMisconductSubjects(), + 'pendingMisconductSanctionLabel' => $this->pendingMisconductSanctionLabel(), ]; } @@ -86,4 +251,193 @@ private function gameSideResolver(): GameSideResolver { return app(GameSideResolver::class); } + + /** + * @return array{ + * players: array, + * staff: array + * } + */ + private function pendingMisconductSubjects(): array + { + if ($this->pendingMisconductTeam === null) { + return ['players' => [], 'staff' => []]; + } + + $game = $this->activeGame(); + + if ($game === null) { + return ['players' => [], 'staff' => []]; + } + + $team = TeamAB::from($this->pendingMisconductTeam); + $players = $team === TeamAB::TeamA ? $game->homePlayers() : $game->awayPlayers(); + $staff = $team === TeamAB::TeamA ? $game->homeStaff() : $game->awayStaff(); + $sanction = $this->pendingMisconductSanction === null + ? null + : MisconductSanction::from($this->pendingMisconductSanction); + + return [ + 'players' => $players + ->orderByPivot('number') + ->get() + ->map(function (Player $player) use ($game, $team, $sanction): array { + $recordedSanction = $sanction === null + ? null + : $this->blockingMisconductSanction($game, $team, MisconductSubjectType::Player, $player->getKey(), $sanction); + + return [ + 'subject_type' => MisconductSubjectType::Player->value, + 'subject_id' => $player->getKey(), + 'marker' => (string) $player->roster->number, + 'name' => "{$player->first_name} {$player->last_name}", + 'unavailable' => $recordedSanction !== null, + 'unavailable_icon' => $recordedSanction === null ? null : $this->misconductSanctionIcon($recordedSanction), + ]; + }) + ->all(), + 'staff' => $this->staffMisconductSubjects($staff->orderByPivot('id')->get()->all(), $game, $team, $sanction), + ]; + } + + /** + * @param array $staff + * @return array + */ + private function staffMisconductSubjects(array $staff, Game $game, TeamAB $team, ?MisconductSanction $sanction): array + { + $assistantCoachCount = 0; + + return array_map(function (Staff $staffMember) use (&$assistantCoachCount, $game, $team, $sanction): array { + $marker = match ($staffMember->roster->role) { + StaffRole::Coach => 'C', + StaffRole::AssistantCoach => 'AC'.(++$assistantCoachCount), + StaffRole::Doctor => 'D', + StaffRole::Therapist => 'T', + }; + $recordedSanction = $sanction === null + ? null + : $this->blockingMisconductSanction($game, $team, MisconductSubjectType::Staff, $staffMember->getKey(), $sanction); + + return [ + 'subject_type' => MisconductSubjectType::Staff->value, + 'subject_id' => $staffMember->getKey(), + 'marker' => $marker, + 'name' => "{$staffMember->first_name} {$staffMember->last_name}", + 'unavailable' => $recordedSanction !== null, + 'unavailable_icon' => $recordedSanction === null ? null : $this->misconductSanctionIcon($recordedSanction), + ]; + }, $staff); + } + + private function blockingMisconductSanction( + Game $game, + TeamAB $team, + MisconductSubjectType $subjectType, + int $subjectId, + MisconductSanction $sanction, + ): ?MisconductSanction { + /** @var MisconductSanction|null $highestRecordedSanction */ + $highestRecordedSanction = $game->events() + ->where('type', GameEventType::MisconductRecorded) + ->get() + ->map(fn (GameEvent $event): mixed => $event->payload) + ->filter(fn (mixed $payload): bool => $payload instanceof MisconductRecordedPayload + && $payload->team === $team + && $payload->subjectType === $subjectType + && $payload->subjectId === $subjectId) + ->map(fn (MisconductRecordedPayload $payload): MisconductSanction => $payload->sanction) + ->sortByDesc(fn (MisconductSanction $recordedSanction): int => $this->misconductSanctionRank($recordedSanction)) + ->first(); + + if ($highestRecordedSanction === null || $this->misconductSanctionRank($sanction) > $this->misconductSanctionRank($highestRecordedSanction)) { + return null; + } + + return $highestRecordedSanction; + } + + private function misconductSubjectLabel(MisconductSubjectType $subjectType, int $subjectId): ?string + { + $subjects = $this->pendingMisconductSubjects(); + $group = $subjectType === MisconductSubjectType::Player ? $subjects['players'] : $subjects['staff']; + + foreach ($group as $subject) { + if ($subject['subject_id'] === $subjectId) { + return $subject['marker']; + } + } + + return null; + } + + private function pendingMisconductSanctionLabel(): ?string + { + if ($this->pendingMisconductSanction === null) { + return null; + } + + return $this->misconductSanctionLabel(MisconductSanction::from($this->pendingMisconductSanction)); + } + + private function misconductSanctionLabel(MisconductSanction $sanction): string + { + return match ($sanction) { + MisconductSanction::Warning => 'Minor misconduct', + MisconductSanction::Penalty => 'Penalty', + MisconductSanction::Expulsion => 'Expulsion', + MisconductSanction::Disqualification => 'Disqualification', + }; + } + + private function misconductSanctionIcon(MisconductSanction $sanction): string + { + return match ($sanction) { + MisconductSanction::Warning => asset('icons/yellow-card.svg'), + MisconductSanction::Penalty => asset('icons/red-card.svg'), + MisconductSanction::Expulsion => asset('icons/yellow-red-card.svg'), + MisconductSanction::Disqualification => asset('icons/yellow-red-side-by-side-card.svg'), + }; + } + + private function misconductSanctionRank(MisconductSanction $sanction): int + { + return match ($sanction) { + MisconductSanction::Warning => 1, + MisconductSanction::Penalty => 2, + MisconductSanction::Expulsion => 3, + MisconductSanction::Disqualification => 4, + }; + } + + private function resetPendingMisconduct(): void + { + $this->pendingMisconductTeam = null; + $this->pendingMisconductSanction = null; + $this->pendingMisconductSubjectType = null; + $this->pendingMisconductSubjectId = null; + $this->pendingMisconductSubjectLabel = null; + } + + private function hasDelaySanctionFor(TeamAB $team): bool + { + $state = $this->resolvedGameState(); + + if ($team === TeamAB::TeamA) { + return $state->delayWarningsTeamA + $state->delayPenaltiesTeamA > 0; + } + + return $state->delayWarningsTeamB + $state->delayPenaltiesTeamB > 0; + } + + private function hasMinorMisconductFor(TeamAB $team): bool + { + $state = $this->resolvedGameState(); + + if ($team === TeamAB::TeamA) { + return $state->misconductWarningsTeamA > 0; + } + + return $state->misconductWarningsTeamB > 0; + } } diff --git a/app/Models/Concerns/RecordsImproperRequest.php b/app/Models/Concerns/RecordsImproperRequest.php index dd59277..d8e7ea2 100644 --- a/app/Models/Concerns/RecordsImproperRequest.php +++ b/app/Models/Concerns/RecordsImproperRequest.php @@ -58,6 +58,31 @@ public function recordImproperRequest(TeamAB $team, ImproperRequestType $request return GameEventType::ImproperRequestRecorded; } + public function recordDelayWarning(TeamAB $team): void + { + app(GameEventRuleValidator::class)->assertCanRecordDelayWarning($this, $team); + + $this->events()->create([ + 'type' => GameEventType::DelayWarningRecorded, + 'payload' => new DelayWarningRecordedPayload( + team: $team, + ), + ]); + } + + public function recordDelayPenalty(TeamAB $team): void + { + app(GameEventRuleValidator::class)->assertCanRecordDelayPenalty($this); + + $this->events()->create([ + 'type' => GameEventType::DelayPenaltyRecorded, + 'payload' => new DelayPenaltyRecordedPayload( + team: $team, + awardedTeam: $this->opponentOf($team), + ), + ]); + } + private function hasDelayWarningFor(TeamAB $team): bool { $state = $this->stateAt(); diff --git a/app/Models/Concerns/RecordsMisconduct.php b/app/Models/Concerns/RecordsMisconduct.php index 9b25cd2..a74af26 100644 --- a/app/Models/Concerns/RecordsMisconduct.php +++ b/app/Models/Concerns/RecordsMisconduct.php @@ -23,7 +23,7 @@ public function recordMisconduct( int $subjectId, MisconductSanction $sanction, ): void { - app(GameEventRuleValidator::class)->assertCanRecordMisconduct($this, $team, $subjectType, $subjectId); + app(GameEventRuleValidator::class)->assertCanRecordMisconduct($this, $team, $subjectType, $subjectId, $sanction); $this->events()->create([ 'type' => GameEventType::MisconductRecorded, diff --git a/app/Services/GameState/GameEventReactor.php b/app/Services/GameState/GameEventReactor.php index 0a09469..6fb0e53 100644 --- a/app/Services/GameState/GameEventReactor.php +++ b/app/Services/GameState/GameEventReactor.php @@ -5,8 +5,10 @@ namespace App\Services\GameState; use App\Enums\GameEventType; +use App\Enums\MisconductSanction; use App\Events\Payloads\GameEndedPayload; use App\Events\Payloads\GameEventPayload; +use App\Events\Payloads\MisconductRecordedPayload; use App\Events\Payloads\SetEndedPayload; use App\Models\GameEvent; @@ -20,11 +22,24 @@ public function reactTo(GameEvent $event): void { match ($event->type) { GameEventType::RallyEnded, GameEventType::DelayPenaltyRecorded => $this->onRallyAwarded($event), + GameEventType::MisconductRecorded => $this->onMisconductRecorded($event), GameEventType::SetEnded => $this->onSetEnded($event), default => null, }; } + protected function onMisconductRecorded(GameEvent $event): void + { + /** @var MisconductRecordedPayload $payload */ + $payload = $event->payload; + + if ($payload->sanction !== MisconductSanction::Penalty) { + return; + } + + $this->onRallyAwarded($event); + } + protected function onRallyAwarded(GameEvent $event): void { $state = $event->game->stateAt(); diff --git a/app/Services/GameState/GameEventRuleValidator.php b/app/Services/GameState/GameEventRuleValidator.php index 7e1ec91..13a4821 100644 --- a/app/Services/GameState/GameEventRuleValidator.php +++ b/app/Services/GameState/GameEventRuleValidator.php @@ -6,8 +6,10 @@ use App\Data\GameState\GameState; use App\Enums\GameEventType; +use App\Enums\MisconductSanction; use App\Enums\MisconductSubjectType; use App\Enums\TeamAB; +use App\Events\Payloads\MisconductRecordedPayload; use App\Exceptions\InvalidGameEventTransition; use App\Models\Game; use App\Models\GameEvent; @@ -145,11 +147,38 @@ public function assertCanRecordImproperRequest(Game $game): void } } + public function assertCanRecordDelayWarning(Game $game, TeamAB $team): void + { + $state = $game->stateAt(); + + if ($state->gameEnded) { + $this->fail('A delay warning cannot be recorded after the game has ended.'); + } + + $hasDelaySanction = $team === TeamAB::TeamA + ? $state->delayWarningsTeamA + $state->delayPenaltiesTeamA > 0 + : $state->delayWarningsTeamB + $state->delayPenaltiesTeamB > 0; + + if ($hasDelaySanction) { + $this->fail('This team already has a delay warning.'); + } + } + + public function assertCanRecordDelayPenalty(Game $game): void + { + $state = $game->stateAt(); + + if ($state->gameEnded) { + $this->fail('A delay penalty cannot be recorded after the game has ended.'); + } + } + public function assertCanRecordMisconduct( Game $game, TeamAB $team, MisconductSubjectType $subjectType, int $subjectId, + MisconductSanction $sanction, ): void { $state = $game->stateAt(); @@ -157,6 +186,14 @@ public function assertCanRecordMisconduct( $this->fail('Misconduct cannot be recorded after the game has ended.'); } + $hasMinorMisconduct = $team === TeamAB::TeamA + ? $state->misconductWarningsTeamA > 0 + : $state->misconductWarningsTeamB > 0; + + if ($sanction === MisconductSanction::Warning && $hasMinorMisconduct) { + $this->fail('This team already has a minor misconduct warning.'); + } + $teamId = $team === TeamAB::TeamA ? $game->home_team_id : $game->away_team_id; $isRostered = match ($subjectType) { @@ -173,6 +210,21 @@ public function assertCanRecordMisconduct( if (! $isRostered) { $this->fail('Misconduct can only be recorded for a rostered player or staff member.'); } + + $highestRecordedSanction = $game->events() + ->where('type', GameEventType::MisconductRecorded) + ->get() + ->map(fn (GameEvent $event): mixed => $event->payload) + ->filter(fn (mixed $payload): bool => $payload instanceof MisconductRecordedPayload + && $payload->team === $team + && $payload->subjectType === $subjectType + && $payload->subjectId === $subjectId) + ->map(fn (MisconductRecordedPayload $payload): int => $this->misconductSanctionRank($payload->sanction)) + ->max(); + + if ($highestRecordedSanction !== null && $this->misconductSanctionRank($sanction) <= $highestRecordedSanction) { + $this->fail('This person already has the same or a higher misconduct sanction.'); + } } public function assertCanRecordSetEnded(Game $game): void @@ -284,4 +336,14 @@ private function fail(string $message): never { throw new InvalidGameEventTransition($message); } + + private function misconductSanctionRank(MisconductSanction $sanction): int + { + return match ($sanction) { + MisconductSanction::Warning => 1, + MisconductSanction::Penalty => 2, + MisconductSanction::Expulsion => 3, + MisconductSanction::Disqualification => 4, + }; + } } diff --git a/app/Services/GameState/GameStateProjector.php b/app/Services/GameState/GameStateProjector.php index d1ff25c..612f172 100644 --- a/app/Services/GameState/GameStateProjector.php +++ b/app/Services/GameState/GameStateProjector.php @@ -208,6 +208,7 @@ private function applyMisconductRecorded(GameState $state, GameEvent $event): Ga { /** @var MisconductRecordedPayload $payload */ $payload = $event->payload; + $opponent = $payload->team === TeamAB::TeamA ? TeamAB::TeamB : TeamAB::TeamA; match ($payload->sanction) { MisconductSanction::Warning => $payload->team === TeamAB::TeamA @@ -224,6 +225,10 @@ private function applyMisconductRecorded(GameState $state, GameEvent $event): Ga : $state->misconductDisqualificationsTeamB++, }; + if ($payload->sanction === MisconductSanction::Penalty) { + $this->awardRallyTo($state, $opponent); + } + return $state; } diff --git a/resources/views/livewire/court.blade.php b/resources/views/livewire/court.blade.php index c83136c..6567ccb 100644 --- a/resources/views/livewire/court.blade.php +++ b/resources/views/livewire/court.blade.php @@ -44,40 +44,131 @@
-
+ + +
+
+ Record delay warning? + + Confirm that you want to record a delay warning for this team. + +
+ +
+ + + Cancel + + + Confirm + +
+
+
+ + +
+
+ Record delay penalty? + + Confirm that you want to record a delay penalty for this team. + +
+ +
+ + + Cancel + + + Confirm + +
+
+
+ + +
+
+ Select person + + Choose who should receive {{ $pendingMisconductSanctionLabel ? strtolower($pendingMisconductSanctionLabel) : 'this sanction' }}. + +
+ +
+
+ Players +
+ @forelse ($misconductSubjects['players'] as $subject) + + @empty + No players are rostered for this team. + @endforelse +
+
+ +
+ Staff +
+ @forelse ($misconductSubjects['staff'] as $subject) + + {{ $subject['marker'] }} + @if ($subject['unavailable']) + + @endif + + @empty + No staff are rostered for this team. + @endforelse +
+
+
+ +
+ + + Cancel + +
+
+
+ + +
+
+ Record misconduct? + + Confirm {{ $pendingMisconductSanctionLabel ? strtolower($pendingMisconductSanctionLabel) : 'this sanction' }} for {{ $pendingMisconductSubjectLabel ?? 'this person' }}. + +
+ +
+ + + Cancel + + + Confirm + +
+
+
+ + +
+
+ {{ $delaySanctionRecordedTitle }} + {{ $delaySanctionRecordedMessage }} +
+ +
+ + Dismiss + +
+
+
diff --git a/tests/Feature/CourtTest.php b/tests/Feature/CourtTest.php index 1eca6b2..8a67b4f 100644 --- a/tests/Feature/CourtTest.php +++ b/tests/Feature/CourtTest.php @@ -4,13 +4,20 @@ use App\Data\GameState\GameState; use App\Enums\GameEventType; +use App\Enums\MisconductSanction; +use App\Enums\MisconductSubjectType; +use App\Enums\StaffRole; use App\Enums\TeamAB; use App\Enums\TeamSide; +use App\Events\Payloads\DelayPenaltyRecordedPayload; +use App\Events\Payloads\DelayWarningRecordedPayload; +use App\Events\Payloads\MisconductRecordedPayload; use App\Events\Payloads\RallyEndedPayload; use App\Livewire\Court; use App\Livewire\RallyWinnerControls; use App\Models\Game; use App\Models\Player; +use App\Models\Staff; use App\Models\Team; use Illuminate\Foundation\Testing\RefreshDatabase; use Livewire\Livewire; @@ -429,20 +436,28 @@ ->assertSeeHtml('data-misconduct-button="penalty"') ->assertSeeHtml('data-misconduct-button="expulsion"') ->assertSeeHtml('data-misconduct-button="disqualification"') + ->assertSee('Misconduct') + ->assertSeeHtml('aria-label="Minor misconduct"') + ->assertSeeHtml('aria-label="Penalty"') + ->assertSeeHtml('aria-label="Expulsion"') + ->assertSeeHtml('aria-label="Disqualification"') ->assertSeeHtml('data-delay-controls="left"') ->assertSeeHtml('data-delay-controls="right"') ->assertSeeHtml('data-delay-button="delay-warning"') ->assertSeeHtml('data-delay-button="delay-penalty"') + ->assertSee('Delay') + ->assertSeeHtml('aria-label="Delay warning"') + ->assertSeeHtml('aria-label="Delay penalty"') ->assertSeeHtml('yellow-card.svg') ->assertSeeHtml('red-card.svg') ->assertSeeHtml('yellow-red-card.svg') ->assertSeeHtml('yellow-red-side-by-side-card.svg') - ->assertSee('Minor misconduct') - ->assertSee('Penalty') - ->assertSee('Expulsion') - ->assertSee('Disqualification') - ->assertSee('Delay warning') - ->assertSee('Delay penalty'); + ->assertDontSeeHtml('Minor misconduct') + ->assertDontSeeHtml('Penalty') + ->assertDontSeeHtml('Expulsion') + ->assertDontSeeHtml('Disqualification') + ->assertDontSeeHtml('Delay warning') + ->assertDontSeeHtml('Delay penalty'); }); test('rally winner controls show button only while a set is in progress and game is not ended', function (): void { @@ -526,6 +541,318 @@ ->and($latestEvent->payload->team)->toBe(TeamAB::TeamA); }); +test('court delay warning button confirms and records a delay warning', function (): void { + $game = gameWithStartedSet(); + + Livewire::test(Court::class, [ + 'gameId' => $game->getKey(), + 'gameState' => $game->stateAt(), + ]) + ->call('requestDelayWarning', TeamAB::TeamA->value) + ->assertSet('pendingDelayWarningTeam', TeamAB::TeamA->value) + ->call('recordPendingDelayWarning') + ->assertSet('pendingDelayWarningTeam', null) + ->assertSet('delaySanctionRecordedTitle', 'Delay warning recorded') + ->assertSee('A delay warning has been recorded for Team A.') + ->assertDontSeeHtml('data-delay-warning-recorded-indicator="left-team_a"') + ->assertDispatched('game-event-recorded'); + + $latestEvent = $game->fresh()->events->last(); + + expect($latestEvent)->not->toBeNull() + ->and($latestEvent->type)->toBe(GameEventType::DelayWarningRecorded) + ->and($latestEvent->payload)->toBeInstanceOf(DelayWarningRecordedPayload::class) + ->and($latestEvent->payload->team)->toBe(TeamAB::TeamA) + ->and($latestEvent->payload->requestType)->toBeNull() + ->and($game->fresh()->stateAt()->delayWarningsTeamA)->toBe(1); +}); + +test('court delay warning button can record a warning between sets', function (): void { + $game = gameWithStartedSet(); + + for ($index = 0; $index < 25; $index++) { + $game->recordRallyWinner(TeamAB::TeamA); + } + + $betweenSetsState = $game->fresh()->stateAt(); + + expect($betweenSetsState->setInProgress)->toBeFalse(); + + Livewire::test(Court::class, [ + 'gameId' => $game->getKey(), + 'gameState' => $betweenSetsState, + ]) + ->call('requestDelayWarning', TeamAB::TeamB->value) + ->call('recordPendingDelayWarning') + ->assertSet('delaySanctionRecordedTitle', 'Delay warning recorded') + ->assertSee('A delay warning has been recorded for Team B.') + ->assertDispatched('game-event-recorded'); + + $latestEvent = $game->fresh()->events->last(); + + expect($latestEvent)->not->toBeNull() + ->and($latestEvent->type)->toBe(GameEventType::DelayWarningRecorded) + ->and($latestEvent->payload)->toBeInstanceOf(DelayWarningRecordedPayload::class) + ->and($latestEvent->payload->team)->toBe(TeamAB::TeamB) + ->and($game->fresh()->stateAt()->delayWarningsTeamB)->toBe(1); +}); + +test('court disables the delay warning button when a warning already exists', function (): void { + $game = gameWithStartedSet(); + $game->recordDelayWarning(TeamAB::TeamA); + + $component = Livewire::test(Court::class, [ + 'gameId' => $game->getKey(), + 'gameState' => $game->stateAt(), + ]); + + expect($component->html()) + ->toMatch('/]*(?:disabled[^>]*data-delay-button="delay-warning"|data-delay-button="delay-warning"[^>]*disabled)[^>]*data-delay-side-team="left-team_a"/') + ->toContain('data-delay-warning-recorded-indicator="left-team_a"') + ->toContain('opacity-60'); +}); + +test('court delay penalty button confirms and records a repeatable delay penalty', function (): void { + $game = gameWithStartedSet(); + $game->recordDelayWarning(TeamAB::TeamA); + + Livewire::test(Court::class, [ + 'gameId' => $game->getKey(), + 'gameState' => $game->stateAt(), + ]) + ->call('requestDelayPenalty', TeamAB::TeamA->value) + ->assertSet('pendingDelayPenaltyTeam', TeamAB::TeamA->value) + ->call('recordPendingDelayPenalty') + ->assertSet('pendingDelayPenaltyTeam', null) + ->assertSet('delaySanctionRecordedTitle', 'Delay penalty recorded') + ->assertSee('A delay penalty has been recorded for Team A.') + ->assertDispatched('game-event-recorded'); + + $state = $game->fresh()->stateAt(); + $latestEvent = $game->fresh()->events->last(); + + expect($latestEvent)->not->toBeNull() + ->and($latestEvent->type)->toBe(GameEventType::DelayPenaltyRecorded) + ->and($latestEvent->payload)->toBeInstanceOf(DelayPenaltyRecordedPayload::class) + ->and($latestEvent->payload->team)->toBe(TeamAB::TeamA) + ->and($latestEvent->payload->awardedTeam)->toBe(TeamAB::TeamB) + ->and($latestEvent->payload->requestType)->toBeNull() + ->and($state->scoreTeamB)->toBe(1) + ->and($state->servingTeam)->toBe(TeamAB::TeamB) + ->and($state->delayPenaltiesTeamA)->toBe(1); + + Livewire::test(Court::class, [ + 'gameId' => $game->getKey(), + 'gameState' => $game->stateAt(), + ]) + ->call('requestDelayPenalty', TeamAB::TeamA->value) + ->call('recordPendingDelayPenalty') + ->assertSet('delaySanctionRecordedTitle', 'Delay penalty recorded') + ->assertDispatched('game-event-recorded'); + + expect($game->fresh()->stateAt()->delayPenaltiesTeamA)->toBe(2); + + $component = Livewire::test(Court::class, [ + 'gameId' => $game->getKey(), + 'gameState' => $game->stateAt(), + ]); + + expect($component->html()) + ->toMatch('/]*disabled=)[^>]*data-delay-button="delay-penalty"[^>]*data-delay-side-team="left-team_a"/') + ->not->toContain('data-delay-penalty-locked-indicator="left-team_a"'); +}); + +test('court disables the delay penalty button with a lock until a warning exists', function (): void { + $game = gameWithStartedSet(); + + $component = Livewire::test(Court::class, [ + 'gameId' => $game->getKey(), + 'gameState' => $game->stateAt(), + ]) + ->assertViewHas('leftDelayPenaltyDisabled', true); + + expect($component->html()) + ->toMatch('/]*(?:disabled[^>]*data-delay-button="delay-penalty"|data-delay-button="delay-penalty"[^>]*disabled)[^>]*data-delay-side-team="left-team_a"/') + ->toContain('data-delay-penalty-locked-indicator="left-team_a"') + ->toContain('data-delay-penalty-locked-icon'); +}); + +test('court misconduct flow shows rostered players and staff for the team', function (): void { + $game = gameWithStartedSet(); + $staff = Staff::factory()->for($game->homeTeam)->create(); + $game->addStaff($staff, StaffRole::Coach); + + $player = $game->homePlayers()->wherePivot('number', 1)->firstOrFail(); + + Livewire::test(Court::class, [ + 'gameId' => $game->getKey(), + 'gameState' => $game->stateAt(), + ]) + ->call('requestMisconduct', TeamAB::TeamA->value, MisconductSanction::Penalty->value) + ->assertSet('pendingMisconductTeam', TeamAB::TeamA->value) + ->assertSet('pendingMisconductSanction', MisconductSanction::Penalty->value) + ->assertViewHas('misconductSubjects', function (array $subjects) use ($player, $staff): bool { + $hasPlayer = collect($subjects['players'])->contains(fn (array $subject): bool => $subject['subject_id'] === $player->getKey() + && $subject['subject_type'] === MisconductSubjectType::Player->value + && $subject['marker'] === '1'); + + $hasStaff = collect($subjects['staff'])->contains(fn (array $subject): bool => $subject['subject_id'] === $staff->getKey() + && $subject['subject_type'] === MisconductSubjectType::Staff->value + && $subject['marker'] === 'C'); + + return $hasPlayer && $hasStaff; + }) + ->assertSeeHtml('data-misconduct-subject-button="player-'.$player->getKey().'"') + ->assertSeeHtml('data-misconduct-subject-button="staff-'.$staff->getKey().'"') + ->assertSeeHtml('rounded-full') + ->assertSeeHtml('bg-blue-600') + ->assertDontSee($player->first_name) + ->assertDontSee($player->last_name) + ->assertDontSee($staff->first_name) + ->assertDontSee($staff->last_name); +}); + +test('court misconduct flow records a sanction against a player after confirmation', function (): void { + $game = gameWithStartedSet(); + $player = $game->homePlayers()->wherePivot('number', 1)->firstOrFail(); + + Livewire::test(Court::class, [ + 'gameId' => $game->getKey(), + 'gameState' => $game->stateAt(), + ]) + ->call('requestMisconduct', TeamAB::TeamA->value, MisconductSanction::Penalty->value) + ->call('selectMisconductSubject', MisconductSubjectType::Player->value, $player->getKey()) + ->assertSet('pendingMisconductSubjectType', MisconductSubjectType::Player->value) + ->assertSet('pendingMisconductSubjectId', $player->getKey()) + ->call('recordPendingMisconduct') + ->assertSet('pendingMisconductTeam', null) + ->assertSet('delaySanctionRecordedTitle', 'Misconduct recorded') + ->assertSee('Penalty has been recorded for 1') + ->assertDispatched('game-event-recorded'); + + $latestEvent = $game->fresh()->events->last(); + + expect($latestEvent)->not->toBeNull() + ->and($latestEvent->type)->toBe(GameEventType::MisconductRecorded) + ->and($latestEvent->payload)->toBeInstanceOf(MisconductRecordedPayload::class) + ->and($latestEvent->payload->team)->toBe(TeamAB::TeamA) + ->and($latestEvent->payload->subjectType)->toBe(MisconductSubjectType::Player) + ->and($latestEvent->payload->subjectId)->toBe($player->getKey()) + ->and($latestEvent->payload->sanction)->toBe(MisconductSanction::Penalty); +}); + +test('court misconduct flow records a sanction against staff after confirmation', function (): void { + $game = gameWithStartedSet(); + $staff = Staff::factory()->for($game->homeTeam)->create(); + $game->addStaff($staff, StaffRole::Coach); + + Livewire::test(Court::class, [ + 'gameId' => $game->getKey(), + 'gameState' => $game->stateAt(), + ]) + ->call('requestMisconduct', TeamAB::TeamA->value, MisconductSanction::Expulsion->value) + ->call('selectMisconductSubject', MisconductSubjectType::Staff->value, $staff->getKey()) + ->assertSet('pendingMisconductSubjectType', MisconductSubjectType::Staff->value) + ->assertSet('pendingMisconductSubjectId', $staff->getKey()) + ->call('recordPendingMisconduct') + ->assertSet('pendingMisconductTeam', null) + ->assertSet('delaySanctionRecordedTitle', 'Misconduct recorded') + ->assertSee('Expulsion has been recorded for C') + ->assertDispatched('game-event-recorded'); + + $latestEvent = $game->fresh()->events->last(); + + expect($latestEvent)->not->toBeNull() + ->and($latestEvent->type)->toBe(GameEventType::MisconductRecorded) + ->and($latestEvent->payload)->toBeInstanceOf(MisconductRecordedPayload::class) + ->and($latestEvent->payload->team)->toBe(TeamAB::TeamA) + ->and($latestEvent->payload->subjectType)->toBe(MisconductSubjectType::Staff) + ->and($latestEvent->payload->subjectId)->toBe($staff->getKey()) + ->and($latestEvent->payload->sanction)->toBe(MisconductSanction::Expulsion); +}); + +test('court disables minor misconduct with a check once it has been recorded for the team', function (): void { + $game = gameWithStartedSet(); + $player = $game->homePlayers()->wherePivot('number', 1)->firstOrFail(); + + $game->recordMisconduct( + team: TeamAB::TeamA, + subjectType: MisconductSubjectType::Player, + subjectId: $player->getKey(), + sanction: MisconductSanction::Warning, + ); + + $component = Livewire::test(Court::class, [ + 'gameId' => $game->getKey(), + 'gameState' => $game->fresh()->stateAt(), + ]) + ->assertViewHas('leftMinorMisconductDisabled', true); + + expect($component->html()) + ->toMatch('/]*(?:disabled[^>]*data-misconduct-button="warning"|data-misconduct-button="warning"[^>]*disabled)[^>]*data-misconduct-side-team="left-team_a"/') + ->toContain('data-minor-misconduct-recorded-indicator="left-team_a"') + ->toContain('opacity-60'); +}); + +test('court misconduct picker marks people unavailable for same or lower sanctions', function (): void { + $game = gameWithStartedSet(); + $player = $game->homePlayers()->wherePivot('number', 1)->firstOrFail(); + $staff = Staff::factory()->for($game->homeTeam)->create(); + $game->addStaff($staff, StaffRole::Coach); + + $game->recordMisconduct( + team: TeamAB::TeamA, + subjectType: MisconductSubjectType::Player, + subjectId: $player->getKey(), + sanction: MisconductSanction::Penalty, + ); + $game->recordMisconduct( + team: TeamAB::TeamA, + subjectType: MisconductSubjectType::Staff, + subjectId: $staff->getKey(), + sanction: MisconductSanction::Expulsion, + ); + + $component = Livewire::test(Court::class, [ + 'gameId' => $game->getKey(), + 'gameState' => $game->fresh()->stateAt(), + ]) + ->call('requestMisconduct', TeamAB::TeamA->value, MisconductSanction::Penalty->value) + ->assertViewHas('misconductSubjects', function (array $subjects) use ($player, $staff): bool { + $playerUnavailable = collect($subjects['players'])->contains(fn (array $subject): bool => $subject['subject_id'] === $player->getKey() + && $subject['unavailable'] === true + && str_ends_with((string) $subject['unavailable_icon'], '/icons/red-card.svg')); + + $staffUnavailable = collect($subjects['staff'])->contains(fn (array $subject): bool => $subject['subject_id'] === $staff->getKey() + && $subject['unavailable'] === true + && str_ends_with((string) $subject['unavailable_icon'], '/icons/yellow-red-card.svg')); + + return $playerUnavailable && $staffUnavailable; + }); + + expect($component->html()) + ->toContain('data-misconduct-subject-unavailable-indicator="player-'.$player->getKey().'"') + ->toContain('data-misconduct-subject-unavailable-indicator="staff-'.$staff->getKey().'"') + ->toContain('red-card.svg') + ->toContain('yellow-red-card.svg') + ->not->toContain('x-mark'); + + Livewire::test(Court::class, [ + 'gameId' => $game->getKey(), + 'gameState' => $game->fresh()->stateAt(), + ]) + ->call('requestMisconduct', TeamAB::TeamA->value, MisconductSanction::Expulsion->value) + ->assertViewHas('misconductSubjects', function (array $subjects) use ($player, $staff): bool { + $playerAvailable = collect($subjects['players'])->contains(fn (array $subject): bool => $subject['subject_id'] === $player->getKey() + && $subject['unavailable'] === false); + + $staffUnavailable = collect($subjects['staff'])->contains(fn (array $subject): bool => $subject['subject_id'] === $staff->getKey() + && $subject['unavailable'] === true); + + return $playerAvailable && $staffUnavailable; + }); +}); + function gameWithNumberedRosters(): Game { $homeTeam = Team::factory()->create(); diff --git a/tests/Feature/GameEventTest.php b/tests/Feature/GameEventTest.php index 423ca4f..13cd008 100644 --- a/tests/Feature/GameEventTest.php +++ b/tests/Feature/GameEventTest.php @@ -627,6 +627,28 @@ function submitLineupsForSet(Game $game, int $set): void ->and($state->delayPenaltiesTeamB)->toBe(1); }); +test('a manual delay penalty can be recorded repeatedly and awards the opponent', function (): void { + $homeTeam = Team::factory()->create(); + $awayTeam = Team::factory()->create(); + $game = Game::factory()->betweenTeams($homeTeam, $awayTeam)->create(); + + prepareActiveSet($game); + $game->recordDelayPenalty(TeamAB::TeamA); + $game->recordDelayPenalty(TeamAB::TeamA); + + $event = $game->events->last(); + $state = $game->fresh()->stateAt(); + + expect($event->type)->toBe(GameEventType::DelayPenaltyRecorded) + ->and($event->payload)->toBeInstanceOf(DelayPenaltyRecordedPayload::class) + ->and($event->payload->team)->toBe(TeamAB::TeamA) + ->and($event->payload->awardedTeam)->toBe(TeamAB::TeamB) + ->and($event->payload->requestType)->toBeNull() + ->and($state->scoreTeamB)->toBe(2) + ->and($state->servingTeam)->toBe(TeamAB::TeamB) + ->and($state->delayPenaltiesTeamA)->toBe(2); +}); + test('improper requests do not reset when a set ends', function (): void { $homeTeam = Team::factory()->create(); $awayTeam = Team::factory()->create(); @@ -695,6 +717,179 @@ function submitLineupsForSet(Game $game, int $set): void ->and($event->payload->sanction)->toBe(MisconductSanction::Expulsion); }); +test('a misconduct penalty awards a point and service to the other team', function (): void { + $homeTeam = Team::factory()->create(); + $awayTeam = Team::factory()->create(); + $game = Game::factory()->betweenTeams($homeTeam, $awayTeam)->create(); + + prepareActiveSet($game); + $player = $game->homePlayers()->firstOrFail(); + + $game->recordMisconduct( + team: TeamAB::TeamA, + subjectType: MisconductSubjectType::Player, + subjectId: $player->getKey(), + sanction: MisconductSanction::Penalty, + ); + + $state = $game->fresh()->stateAt(); + + expect($state->misconductPenaltiesTeamA)->toBe(1) + ->and($state->scoreTeamB)->toBe(1) + ->and($state->servingTeam)->toBe(TeamAB::TeamB) + ->and($state->rotationTeamB[1])->toBe(12); +}); + +test('misconduct sanctions other than penalty do not award a point', function (MisconductSanction $sanction): void { + $homeTeam = Team::factory()->create(); + $awayTeam = Team::factory()->create(); + $game = Game::factory()->betweenTeams($homeTeam, $awayTeam)->create(); + + prepareActiveSet($game); + $player = $game->homePlayers()->firstOrFail(); + + $game->recordMisconduct( + team: TeamAB::TeamA, + subjectType: MisconductSubjectType::Player, + subjectId: $player->getKey(), + sanction: $sanction, + ); + + $state = $game->fresh()->stateAt(); + + expect($state->scoreTeamA)->toBe(0) + ->and($state->scoreTeamB)->toBe(0) + ->and($state->servingTeam)->toBe(TeamAB::TeamA) + ->and($state->rotationTeamB[1])->toBe(11); +})->with([ + 'warning' => [MisconductSanction::Warning], + 'expulsion' => [MisconductSanction::Expulsion], + 'disqualification' => [MisconductSanction::Disqualification], +]); + +test('a misconduct penalty can end a set', function (): void { + $homeTeam = Team::factory()->create(); + $awayTeam = Team::factory()->create(); + $game = Game::factory()->betweenTeams($homeTeam, $awayTeam)->create(); + + prepareActiveSet($game); + $player = $game->homePlayers()->firstOrFail(); + + for ($index = 0; $index < 24; $index++) { + $game->recordRallyWinner(TeamAB::TeamB); + } + + $game->recordMisconduct( + team: TeamAB::TeamA, + subjectType: MisconductSubjectType::Player, + subjectId: $player->getKey(), + sanction: MisconductSanction::Penalty, + ); + + $state = $game->fresh()->stateAt(); + + expect($state->setsWonTeamB)->toBe(1) + ->and($state->setInProgress)->toBeFalse() + ->and($state->scoreTeamA)->toBe(0) + ->and($state->scoreTeamB)->toBe(0); +}); + +test('misconduct can be recorded before the match starts', function (): void { + $homeTeam = Team::factory()->create(); + $awayTeam = Team::factory()->create(); + $game = Game::factory()->betweenTeams($homeTeam, $awayTeam)->create(); + $player = Player::factory()->for($homeTeam)->create(); + + $game->addPlayer($player, number: 7); + $game->recordMisconduct( + team: TeamAB::TeamA, + subjectType: MisconductSubjectType::Player, + subjectId: $player->getKey(), + sanction: MisconductSanction::Warning, + ); + + $event = $game->events->last(); + + expect($event->type)->toBe(GameEventType::MisconductRecorded) + ->and($event->payload)->toBeInstanceOf(MisconductRecordedPayload::class) + ->and($event->payload->team)->toBe(TeamAB::TeamA) + ->and($event->payload->subjectType)->toBe(MisconductSubjectType::Player) + ->and($event->payload->subjectId)->toBe($player->getKey()) + ->and($event->payload->sanction)->toBe(MisconductSanction::Warning); +}); + +test('minor misconduct can only be recorded once per team in a game', function (): void { + $homeTeam = Team::factory()->create(); + $awayTeam = Team::factory()->create(); + $game = Game::factory()->betweenTeams($homeTeam, $awayTeam)->create(); + $firstPlayer = Player::factory()->for($homeTeam)->create(); + $secondPlayer = Player::factory()->for($homeTeam)->create(); + + $game->addPlayer($firstPlayer, number: 7); + $game->addPlayer($secondPlayer, number: 8); + + $game->recordMisconduct( + team: TeamAB::TeamA, + subjectType: MisconductSubjectType::Player, + subjectId: $firstPlayer->getKey(), + sanction: MisconductSanction::Warning, + ); + + expect(fn () => $game->recordMisconduct( + team: TeamAB::TeamA, + subjectType: MisconductSubjectType::Player, + subjectId: $secondPlayer->getKey(), + sanction: MisconductSanction::Warning, + ))->toThrow(InvalidGameEventTransition::class, 'This team already has a minor misconduct warning.'); +}); + +test('a person cannot receive the same or a lower misconduct sanction', function (): void { + $homeTeam = Team::factory()->create(); + $awayTeam = Team::factory()->create(); + $game = Game::factory()->betweenTeams($homeTeam, $awayTeam)->create(); + $player = Player::factory()->for($homeTeam)->create(); + $staff = Staff::factory()->for($homeTeam)->create(); + + $game->addPlayer($player, number: 7); + $game->addStaff($staff, StaffRole::Coach); + + $game->recordMisconduct( + team: TeamAB::TeamA, + subjectType: MisconductSubjectType::Player, + subjectId: $player->getKey(), + sanction: MisconductSanction::Penalty, + ); + $game->recordMisconduct( + team: TeamAB::TeamA, + subjectType: MisconductSubjectType::Staff, + subjectId: $staff->getKey(), + sanction: MisconductSanction::Expulsion, + ); + + expect(fn () => $game->recordMisconduct( + team: TeamAB::TeamA, + subjectType: MisconductSubjectType::Player, + subjectId: $player->getKey(), + sanction: MisconductSanction::Penalty, + ))->toThrow(InvalidGameEventTransition::class, 'This person already has the same or a higher misconduct sanction.'); + + expect(fn () => $game->recordMisconduct( + team: TeamAB::TeamA, + subjectType: MisconductSubjectType::Staff, + subjectId: $staff->getKey(), + sanction: MisconductSanction::Penalty, + ))->toThrow(InvalidGameEventTransition::class, 'This person already has the same or a higher misconduct sanction.'); + + $game->recordMisconduct( + team: TeamAB::TeamA, + subjectType: MisconductSubjectType::Player, + subjectId: $player->getKey(), + sanction: MisconductSanction::Expulsion, + ); + + expect($game->fresh()->stateAt()->misconductExpulsionsTeamA)->toBe(2); +}); + test('misconduct counts do not reset when a set ends', function (): void { $homeTeam = Team::factory()->create(); $awayTeam = Team::factory()->create(); From 540bcb665504f93f2e6daed4b5a020db320bcfff Mon Sep 17 00:00:00 2001 From: Giulio Troccoli-Allard Date: Sun, 7 Jun 2026 10:19:49 +0100 Subject: [PATCH 60/63] feat: add roster submission before the start of the game --- app/Livewire/Court.php | 13 + app/Livewire/Game.php | 7 + app/Livewire/RosterSubmission.php | 664 ++++++++++++++++++ app/Livewire/TossResultSubmission.php | 23 + app/Models/Game.php | 60 +- app/Models/RosterPlayer.php | 2 + app/Models/RosterStaff.php | 2 + app/Models/Staff.php | 3 + database/factories/StaffFactory.php | 37 + ...6_06_01_174415_add_role_to_staff_table.php | 33 + ...7_add_rosters_submitted_to_games_table.php | 28 + database/seeders/ControlledGameSeeder.php | 45 +- database/seeders/DatabaseSeeder.php | 44 +- resources/views/livewire/court.blade.php | 4 + resources/views/livewire/game.blade.php | 5 +- .../livewire/roster-submission.blade.php | 296 ++++++++ .../livewire/toss-result-submission.blade.php | 177 +++-- tests/Feature/CourtTest.php | 4 + tests/Feature/DatabaseSeederTest.php | 21 + tests/Feature/GamePageTest.php | 8 +- tests/Feature/GameRosterTest.php | 4 +- tests/Feature/RosterSubmissionTest.php | 491 +++++++++++++ tests/Feature/TeamRosterTest.php | 56 +- tests/Feature/TossResultSubmissionTest.php | 34 +- 24 files changed, 1919 insertions(+), 142 deletions(-) create mode 100644 app/Livewire/RosterSubmission.php create mode 100644 database/migrations/2026_06_01_174415_add_role_to_staff_table.php create mode 100644 database/migrations/2026_06_01_175307_add_rosters_submitted_to_games_table.php create mode 100644 resources/views/livewire/roster-submission.blade.php create mode 100644 tests/Feature/DatabaseSeederTest.php create mode 100644 tests/Feature/RosterSubmissionTest.php diff --git a/app/Livewire/Court.php b/app/Livewire/Court.php index e5130d0..70f422f 100644 --- a/app/Livewire/Court.php +++ b/app/Livewire/Court.php @@ -182,6 +182,7 @@ public function dismissDelaySanctionRecordedMessage(): void * rightTeam: TeamAB, * servingTeam: TeamAB|null, * showRosters: bool, + * showSanctionControls: bool, * leftRotation: array, * rightRotation: array, * leftDelayWarningDisabled: bool, @@ -204,12 +205,14 @@ private function courtContext(): array $leftTeam = $this->gameSideResolver()->teamOnLeftForState($state); $rightTeam = $this->gameSideResolver()->teamOnRightForState($state); $showRosters = $game !== null && $this->gameSideResolver()->hasRecordedToss($game); + $showSanctionControls = ! $this->isBeforeInitialToss($state, $game); return [ 'leftTeam' => $leftTeam, 'rightTeam' => $rightTeam, 'servingTeam' => $state->servingTeam, 'showRosters' => $showRosters, + 'showSanctionControls' => $showSanctionControls, 'leftRotation' => $this->rotationForTeam($leftTeam), 'rightRotation' => $this->rotationForTeam($rightTeam), 'leftDelayWarningDisabled' => $this->hasDelaySanctionFor($leftTeam), @@ -238,6 +241,16 @@ private function resolvedGameState(): GameState return $this->gameState ?? GameState::initial(); } + private function isBeforeInitialToss(GameState $state, ?Game $game): bool + { + if ($game !== null && $this->gameSideResolver()->hasRecordedToss($game)) { + return false; + } + + return ! $this->gameSideResolver()->hasRequiredToss($state) + && ! $this->gameSideResolver()->requiresFifthSetToss($state); + } + private function activeGame(): ?Game { if ($this->gameId === null) { diff --git a/app/Livewire/Game.php b/app/Livewire/Game.php index 3279892..991408e 100644 --- a/app/Livewire/Game.php +++ b/app/Livewire/Game.php @@ -89,6 +89,7 @@ public function acknowledgeFifthSetSideChange(): void public function render(): View { return view('livewire.game', [ + 'isBeforeInitialToss' => $this->isBeforeInitialToss(), 'justEndedSetNumber' => $this->justEndedSetNumber, 'setWinnerCode' => $this->setWinnerCode, 'finalScoreWinner' => $this->finalScoreWinner, @@ -110,4 +111,10 @@ private function gameSideResolver(): GameSideResolver { return app(GameSideResolver::class); } + + private function isBeforeInitialToss(): bool + { + return ! $this->gameSideResolver()->hasRequiredToss($this->gameState) + && ! $this->gameSideResolver()->requiresFifthSetToss($this->gameState); + } } diff --git a/app/Livewire/RosterSubmission.php b/app/Livewire/RosterSubmission.php new file mode 100644 index 0000000..c42738d --- /dev/null +++ b/app/Livewire/RosterSubmission.php @@ -0,0 +1,664 @@ + */ + public array $homeRosterInputs = []; + + /** @var array */ + public array $awayRosterInputs = []; + + /** @var array */ + public array $homeStaffSelection = []; + + /** @var array */ + public array $awayStaffSelection = []; + + /** @var array */ + public array $homeLiberoSelection = []; + + /** @var array */ + public array $awayLiberoSelection = []; + + public string $homeCaptainSelection = ''; + + public string $awayCaptainSelection = ''; + + public function mount(?int $gameId = null): void + { + $this->gameId = $gameId; + } + + public function updated(string $property): void + { + if ( + str_starts_with($property, 'homeRosterInputs.') + || str_starts_with($property, 'homeLiberoSelection.') + || $property === 'homeCaptainSelection' + ) { + $this->refreshTeamRosterValidation(TeamAB::TeamA); + + return; + } + + if ( + str_starts_with($property, 'awayRosterInputs.') + || str_starts_with($property, 'awayLiberoSelection.') + || $property === 'awayCaptainSelection' + ) { + $this->refreshTeamRosterValidation(TeamAB::TeamB); + } + } + + public function openRosterModal(): void + { + $this->initializeRosterForm(); + $this->resetValidation(); + + Flux::modal('submit-rosters')->show(); + } + + public function submitRosters(): void + { + $activeGame = $this->activeGame(); + + if ($activeGame === null) { + $this->addError('rosters', 'No active game is available to record the rosters.'); + + return; + } + + $homeTeam = $activeGame->homeTeam; + $awayTeam = $activeGame->awayTeam; + + $homePlayers = $homeTeam->players()->orderBy('id')->get(); + $awayPlayers = $awayTeam->players()->orderBy('id')->get(); + $homeStaff = $homeTeam->staff()->orderBy('id')->get(); + $awayStaff = $awayTeam->staff()->orderBy('id')->get(); + + $homePlayerRoster = $this->validatedPlayerRoster( + $homePlayers, + $this->homeRosterInputs, + $this->homeLiberoSelection, + $this->homeCaptainSelection, + 'homeRosterInputs', + ); + $awayPlayerRoster = $this->validatedPlayerRoster( + $awayPlayers, + $this->awayRosterInputs, + $this->awayLiberoSelection, + $this->awayCaptainSelection, + 'awayRosterInputs', + ); + $homeStaffRoster = $this->selectedStaffRoster($homeStaff, $this->homeStaffSelection); + $awayStaffRoster = $this->selectedStaffRoster($awayStaff, $this->awayStaffSelection); + + if ($this->getErrorBag()->isNotEmpty()) { + return; + } + + Flux::modal('submit-rosters')->close(); + Flux::modal('confirm-rosters')->show(); + } + + public function confirmRosters(): void + { + $activeGame = $this->activeGame(); + + if ($activeGame === null) { + $this->addError('rosters', 'No active game is available to record the rosters.'); + + return; + } + + $homeTeam = $activeGame->homeTeam; + $awayTeam = $activeGame->awayTeam; + + $homePlayers = $homeTeam->players()->orderBy('id')->get(); + $awayPlayers = $awayTeam->players()->orderBy('id')->get(); + $homeStaff = $homeTeam->staff()->orderBy('id')->get(); + $awayStaff = $awayTeam->staff()->orderBy('id')->get(); + + $homePlayerRoster = $this->validatedPlayerRoster( + $homePlayers, + $this->homeRosterInputs, + $this->homeLiberoSelection, + $this->homeCaptainSelection, + 'homeRosterInputs', + ); + $awayPlayerRoster = $this->validatedPlayerRoster( + $awayPlayers, + $this->awayRosterInputs, + $this->awayLiberoSelection, + $this->awayCaptainSelection, + 'awayRosterInputs', + ); + $homeStaffRoster = $this->selectedStaffRoster($homeStaff, $this->homeStaffSelection); + $awayStaffRoster = $this->selectedStaffRoster($awayStaff, $this->awayStaffSelection); + + if ($this->getErrorBag()->isNotEmpty()) { + Flux::modal('confirm-rosters')->close(); + Flux::modal('submit-rosters')->show(); + + return; + } + + $activeGame->replaceRosterForTeam(TeamAB::TeamA, $homePlayerRoster, $homeStaffRoster); + $activeGame->replaceRosterForTeam(TeamAB::TeamB, $awayPlayerRoster, $awayStaffRoster); + $activeGame->markRostersSubmitted(); + + Flux::modal('confirm-rosters')->close(); + $this->resetValidation(); + $this->dispatch('game-event-recorded'); + } + + public function returnToRoster(): void + { + Flux::modal('confirm-rosters')->close(); + Flux::modal('submit-rosters')->show(); + } + + public function render(): View + { + $activeGame = $this->activeGame(); + $homePlayers = $activeGame?->homeTeam->players()->orderBy('id')->get() ?? collect(); + $awayPlayers = $activeGame?->awayTeam->players()->orderBy('id')->get() ?? collect(); + $homeStaff = $activeGame?->homeTeam->staff()->orderBy('id')->get() ?? collect(); + $awayStaff = $activeGame?->awayTeam->staff()->orderBy('id')->get() ?? collect(); + + return view('livewire.roster-submission', [ + 'hasSubmittedRosters' => $this->hasSubmittedRosters($activeGame), + 'isBeforeInitialToss' => $this->isBeforeInitialToss($activeGame), + 'homeTeamCode' => $activeGame?->homeTeam->country_code ?? 'Home Team', + 'awayTeamCode' => $activeGame?->awayTeam->country_code ?? 'Away Team', + 'homePlayers' => $homePlayers, + 'awayPlayers' => $awayPlayers, + 'homeStaff' => $homeStaff, + 'awayStaff' => $awayStaff, + 'homeRosterConfirmation' => $this->confirmationRosterForTeam( + $homePlayers, + $homeStaff, + $this->homeRosterInputs, + $this->homeLiberoSelection, + $this->homeCaptainSelection, + $this->homeStaffSelection, + ), + 'awayRosterConfirmation' => $this->confirmationRosterForTeam( + $awayPlayers, + $awayStaff, + $this->awayRosterInputs, + $this->awayLiberoSelection, + $this->awayCaptainSelection, + $this->awayStaffSelection, + ), + ]); + } + + private function hasSubmittedRosters(?Game $activeGame = null): bool + { + $game = $activeGame ?? $this->activeGame(); + + return $game?->hasSubmittedInitialRosters() ?? false; + } + + private function isBeforeInitialToss(?Game $activeGame = null): bool + { + $state = $this->resolvedGameState($activeGame); + + return ! $this->gameSideResolver()->hasRequiredToss($state) + && ! $this->gameSideResolver()->requiresFifthSetToss($state); + } + + private function initializeRosterForm(): void + { + $activeGame = $this->activeGame(); + + if ($activeGame === null) { + $this->homeRosterInputs = []; + $this->awayRosterInputs = []; + $this->homeStaffSelection = []; + $this->awayStaffSelection = []; + $this->homeLiberoSelection = []; + $this->awayLiberoSelection = []; + $this->homeCaptainSelection = ''; + $this->awayCaptainSelection = ''; + + return; + } + + $this->homeRosterInputs = $this->existingRosterInputs( + $activeGame->homeTeam->players()->orderBy('id')->get(), + $activeGame->homePlayers()->get(), + ); + $this->awayRosterInputs = $this->existingRosterInputs( + $activeGame->awayTeam->players()->orderBy('id')->get(), + $activeGame->awayPlayers()->get(), + ); + $this->homeStaffSelection = $this->existingStaffSelection( + $activeGame->homeTeam->staff()->orderBy('id')->get(), + $activeGame->homeStaff()->get(), + ); + $this->awayStaffSelection = $this->existingStaffSelection( + $activeGame->awayTeam->staff()->orderBy('id')->get(), + $activeGame->awayStaff()->get(), + ); + $this->homeLiberoSelection = $this->existingLiberoSelection( + $activeGame->homeTeam->players()->orderBy('id')->get(), + $activeGame->homePlayers()->get(), + ); + $this->awayLiberoSelection = $this->existingLiberoSelection( + $activeGame->awayTeam->players()->orderBy('id')->get(), + $activeGame->awayPlayers()->get(), + ); + $this->homeCaptainSelection = $this->existingCaptainSelection( + $activeGame->homePlayers()->get(), + ); + $this->awayCaptainSelection = $this->existingCaptainSelection( + $activeGame->awayPlayers()->get(), + ); + } + + private function refreshTeamRosterValidation(TeamAB $team): void + { + $activeGame = $this->activeGame(); + + if ($activeGame === null) { + return; + } + + if ($team === TeamAB::TeamA) { + $fieldPrefix = 'homeRosterInputs'; + $teamPlayers = $activeGame->homeTeam->players()->orderBy('id')->get(); + $inputs = $this->homeRosterInputs; + $liberoSelection = $this->homeLiberoSelection; + $captainSelection = $this->homeCaptainSelection; + } else { + $fieldPrefix = 'awayRosterInputs'; + $teamPlayers = $activeGame->awayTeam->players()->orderBy('id')->get(); + $inputs = $this->awayRosterInputs; + $liberoSelection = $this->awayLiberoSelection; + $captainSelection = $this->awayCaptainSelection; + } + + $this->resetValidation($this->validationFieldsForTeam($teamPlayers, $fieldPrefix, $this->captainFieldPrefix($team))); + + $this->validatedPlayerRoster($teamPlayers, $inputs, $liberoSelection, $captainSelection, $fieldPrefix); + } + + /** + * @param EloquentCollection $teamPlayers + * @param array $inputs + * @param array $liberoSelection + * @return array + */ + private function validatedPlayerRoster(EloquentCollection $teamPlayers, array $inputs, array $liberoSelection, string $captainSelection, string $fieldPrefix): array + { + $selectedPlayers = []; + $rosteredPlayerIds = []; + $selectedNumbers = []; + $liberoCount = 0; + $nonLiberoCount = 0; + + foreach ($teamPlayers as $player) { + $input = trim($inputs[$player->getKey()] ?? ''); + $isLibero = (bool) ($liberoSelection[$player->getKey()] ?? false); + + if ($input === '') { + if ($isLibero) { + $this->addError("{$fieldPrefix}.{$player->getKey()}", 'Libero must be a rostered player.'); + } + + continue; + } + + if (! preg_match('/^\d+$/', $input)) { + $this->addError("{$fieldPrefix}.{$player->getKey()}", 'Use a roster number between 1 and 99.'); + + continue; + } + + $number = (int) $input; + + if ($number < 1 || $number > 99) { + $this->addError("{$fieldPrefix}.{$player->getKey()}", 'Roster numbers must be between 1 and 99.'); + + continue; + } + + if (in_array($number, $selectedNumbers, true)) { + $this->addError("{$fieldPrefix}.{$player->getKey()}", 'Roster numbers must be unique within the team.'); + + continue; + } + + $selectedNumbers[] = $number; + $rosteredPlayerIds[] = $player->getKey(); + $selectedPlayers[] = [ + 'player' => $player, + 'number' => $number, + 'is_captain' => false, + 'is_libero' => $isLibero, + ]; + + if ($isLibero) { + $liberoCount++; + } else { + $nonLiberoCount++; + } + } + + if ($liberoCount > 2) { + $this->addError($fieldPrefix, 'A team can have at most two liberos.'); + } + + if ($nonLiberoCount < 6) { + $this->addError($fieldPrefix, 'A team must have at least six non-libero players.'); + } + + if ($nonLiberoCount > 12) { + $this->addError($fieldPrefix, 'A team can have at most 12 non-libero players.'); + } + + if ($captainSelection === '') { + $this->addError($this->captainFieldFromRosterField($fieldPrefix), 'Select a captain.'); + + return $selectedPlayers; + } + + $captainPlayerId = (int) $captainSelection; + if (! in_array($captainPlayerId, $rosteredPlayerIds, true)) { + $this->addError($this->captainFieldFromRosterField($fieldPrefix), 'Captain must be one of the rostered players.'); + + return $selectedPlayers; + } + + foreach ($selectedPlayers as $index => $selectedPlayer) { + if ($selectedPlayer['player']->getKey() === $captainPlayerId) { + $selectedPlayers[$index]['is_captain'] = true; + + break; + } + } + + return $selectedPlayers; + } + + /** + * @param EloquentCollection $teamStaff + * @param array $selection + * @return array + */ + private function selectedStaffRoster(EloquentCollection $teamStaff, array $selection): array + { + return $teamStaff + ->filter(fn (Staff $staff): bool => (bool) ($selection[$staff->getKey()] ?? false)) + ->values() + ->all(); + } + + /** + * @param EloquentCollection $teamPlayers + * @param EloquentCollection $rosteredPlayers + * @return array + */ + private function existingRosterInputs(EloquentCollection $teamPlayers, EloquentCollection $rosteredPlayers): array + { + $inputs = []; + + foreach ($teamPlayers as $player) { + /** @var Player|null $rosteredPlayer */ + $rosteredPlayer = $rosteredPlayers->firstWhere($player->getKeyName(), $player->getKey()); + + $inputs[$player->getKey()] = $rosteredPlayer === null + ? '' + : (string) $rosteredPlayer->roster->number; + } + + return $inputs; + } + + /** + * @param EloquentCollection $teamPlayers + * @param EloquentCollection $rosteredPlayers + * @return array + */ + private function existingLiberoSelection(EloquentCollection $teamPlayers, EloquentCollection $rosteredPlayers): array + { + $selection = []; + + foreach ($teamPlayers as $player) { + /** @var Player|null $rosteredPlayer */ + $rosteredPlayer = $rosteredPlayers->firstWhere($player->getKeyName(), $player->getKey()); + + $selection[$player->getKey()] = $rosteredPlayer?->roster->is_libero ?? false; + } + + return $selection; + } + + /** + * @param EloquentCollection $rosteredPlayers + */ + private function existingCaptainSelection(EloquentCollection $rosteredPlayers): string + { + /** @var Player|null $captain */ + $captain = $rosteredPlayers->first(fn (Player $player): bool => $player->roster->is_captain); + + return $captain === null ? '' : (string) $captain->getKey(); + } + + /** + * @param EloquentCollection $teamStaff + * @param EloquentCollection $rosteredStaff + * @return array + */ + private function existingStaffSelection(EloquentCollection $teamStaff, EloquentCollection $rosteredStaff): array + { + $selection = []; + + foreach ($teamStaff as $staffMember) { + $selection[$staffMember->getKey()] = $rosteredStaff + ->contains(fn (Staff $rosteredStaffMember): bool => $rosteredStaffMember->getKey() === $staffMember->getKey()); + } + + return $selection; + } + + private function resolvedGameState(?Game $activeGame = null): GameState + { + if ($activeGame !== null) { + return $activeGame->stateAt(); + } + + if ($this->gameId === null) { + return $this->gameState ?? GameState::initial(); + } + + $activeGame = Game::query()->whereKey($this->gameId)->first(); + + return $activeGame?->stateAt() ?? ($this->gameState ?? GameState::initial()); + } + + private function activeGame(): ?Game + { + if ($this->gameId === null) { + return null; + } + + return Game::query() + ->with(['homeTeam', 'awayTeam']) + ->whereKey($this->gameId) + ->first(); + } + + private function gameSideResolver(): GameSideResolver + { + return app(GameSideResolver::class); + } + + private function captainFieldPrefix(TeamAB $team): string + { + return $team === TeamAB::TeamA + ? 'homeCaptainSelection' + : 'awayCaptainSelection'; + } + + private function captainFieldFromRosterField(string $fieldPrefix): string + { + return $fieldPrefix === 'homeRosterInputs' + ? 'homeCaptainSelection' + : 'awayCaptainSelection'; + } + + /** + * @param EloquentCollection $teamPlayers + * @return array + */ + private function validationFieldsForTeam(EloquentCollection $teamPlayers, string $fieldPrefix, string $captainField): array + { + $fields = [$fieldPrefix, $captainField]; + + foreach ($teamPlayers as $player) { + $fields[] = "{$fieldPrefix}.{$player->getKey()}"; + } + + return $fields; + } + + /** + * @param EloquentCollection $teamPlayers + * @param EloquentCollection $teamStaff + * @param array $inputs + * @param array $liberoSelection + * @param array $staffSelection + * @return array{ + * players: Collection, + * liberos: Collection, + * bench: Collection + * } + */ + private function confirmationRosterForTeam( + EloquentCollection $teamPlayers, + EloquentCollection $teamStaff, + array $inputs, + array $liberoSelection, + string $captainSelection, + array $staffSelection, + ): array { + $captainPlayerId = $captainSelection === '' ? null : (int) $captainSelection; + + $rosteredPlayers = $teamPlayers + ->map(function (Player $player) use ($inputs, $liberoSelection, $captainPlayerId): ?array { + $input = trim($inputs[$player->getKey()] ?? ''); + + if ($input === '' || ! preg_match('/^\d+$/', $input)) { + return null; + } + + $number = (int) $input; + + if ($number < 1 || $number > 99) { + return null; + } + + return [ + 'name' => $this->formattedPersonName($player->last_name, $player->first_name), + 'number' => $number, + 'is_captain' => $captainPlayerId !== null && $player->getKey() === $captainPlayerId, + 'is_libero' => (bool) ($liberoSelection[$player->getKey()] ?? false), + ]; + }) + ->filter() + ->sortBy('number') + ->values(); + + $selectedStaff = $teamStaff + ->filter(fn (Staff $staff): bool => (bool) ($staffSelection[$staff->getKey()] ?? false)) + ->sortBy(fn (Staff $staff): array => [ + $this->staffOrder($staff->role), + $staff->role === StaffRole::AssistantCoach ? mb_strtolower($staff->last_name) : '', + mb_strtolower($staff->first_name), + ]) + ->values(); + + $assistantCoachCount = 0; + + $selectedStaff = $selectedStaff + ->map(function (Staff $staff) use (&$assistantCoachCount): array { + $roleCode = match ($staff->role) { + StaffRole::Coach => 'C', + StaffRole::AssistantCoach => 'AC'.(++$assistantCoachCount), + StaffRole::Therapist => 'T', + StaffRole::Doctor => 'D', + }; + + return [ + 'name' => $this->formattedPersonName($staff->last_name, $staff->first_name), + 'role' => $roleCode, + ]; + }) + ->values(); + + return [ + 'players' => $rosteredPlayers + ->reject(fn (array $player): bool => $player['is_libero']) + ->map(fn (array $player): array => [ + 'name' => $player['name'], + 'number' => $player['number'], + 'is_captain' => $player['is_captain'], + ]) + ->values(), + 'liberos' => $rosteredPlayers + ->filter(fn (array $player): bool => $player['is_libero']) + ->map(fn (array $player): array => [ + 'name' => $player['name'], + 'number' => $player['number'], + ]) + ->values(), + 'bench' => $selectedStaff, + ]; + } + + private function formattedPersonName(string $lastName, string $firstName): string + { + $firstInitial = mb_substr($firstName, 0, 1); + + return "{$lastName} {$firstInitial}."; + } + + private function staffOrder(StaffRole $role): int + { + return match ($role) { + StaffRole::Coach => 0, + StaffRole::AssistantCoach => 1, + StaffRole::Therapist => 2, + StaffRole::Doctor => 3, + }; + } +} diff --git a/app/Livewire/TossResultSubmission.php b/app/Livewire/TossResultSubmission.php index 05562c1..16fbb27 100644 --- a/app/Livewire/TossResultSubmission.php +++ b/app/Livewire/TossResultSubmission.php @@ -64,6 +64,12 @@ protected function messages(): array public function submit(): void { + if (! $this->isFifthSetToss() && ! $this->hasSubmittedRosters()) { + $this->addError('submit', 'Both team rosters must be submitted before the toss.'); + + return; + } + $this->validate(); if ($this->gameId === null) { @@ -128,6 +134,8 @@ public function render(): View return view('livewire.toss-result-submission', [ 'hasSubmittedToss' => $this->hasSubmittedToss($activeGame), + 'hasSubmittedRosters' => $this->hasSubmittedRosters($activeGame), + 'isBeforeInitialToss' => $this->isBeforeInitialToss($activeGame), 'isFifthSetToss' => $this->isFifthSetToss(), 'homeTeamCode' => $activeGame?->homeTeam->country_code ?? 'Home Team', 'awayTeamCode' => $activeGame?->awayTeam->country_code ?? 'Away Team', @@ -173,6 +181,21 @@ private function isFifthSetToss(): bool return $this->gameSideResolver()->requiresFifthSetToss($this->resolvedGameState()); } + private function isBeforeInitialToss(?Game $activeGame = null): bool + { + $state = $this->resolvedGameState($activeGame); + + return ! $this->gameSideResolver()->hasRequiredToss($state) + && ! $this->gameSideResolver()->requiresFifthSetToss($state); + } + + private function hasSubmittedRosters(?Game $activeGame = null): bool + { + $game = $activeGame ?? $this->activeGame(); + + return $game?->hasSubmittedInitialRosters() ?? false; + } + private function teamForSide(TeamSide $teamASide, TeamSide $selectedSide): TeamAB { return $selectedSide === $teamASide diff --git a/app/Models/Game.php b/app/Models/Game.php index fba2dca..a6cefcc 100644 --- a/app/Models/Game.php +++ b/app/Models/Game.php @@ -7,6 +7,7 @@ use App\Data\GameState\GameState; use App\Enums\OfficialRole; use App\Enums\StaffRole; +use App\Enums\TeamAB; use App\Models\Concerns\RecordsCourtSideSwap; use App\Models\Concerns\RecordsEndOfGame; use App\Models\Concerns\RecordsEndOfRally; @@ -26,6 +27,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Support\Facades\DB; /** * @property int $championship_id @@ -39,6 +41,7 @@ * @property string $division * @property string $pool * @property string $category + * @property bool $rosters_submitted * @property CarbonImmutable|null $created_at * @property CarbonImmutable|null $updated_at * @property-read Championship $championship @@ -79,6 +82,7 @@ protected function casts(): array { return [ 'number' => 'integer', + 'rosters_submitted' => 'boolean', 'date_time' => 'immutable_datetime', 'created_at' => 'immutable_datetime', 'updated_at' => 'immutable_datetime', @@ -193,14 +197,66 @@ public function addPlayer(Player $player, int $number, bool $isCaptain = false, /** * Add a staff member to the match roster. */ - public function addStaff(Staff $staff, StaffRole|string $role): void + public function addStaff(Staff $staff, StaffRole|string|null $role = null): void { + $resolvedRole = $role instanceof StaffRole + ? $role + : ($role !== null ? StaffRole::from($role) : $staff->role); + $this->staff()->attach($staff, [ 'team_id' => $staff->team_id, - 'role' => $role, + 'role' => $resolvedRole, ]); } + /** + * @param array $players + * @param array $staff + */ + public function replaceRosterForTeam(TeamAB $team, array $players, array $staff): void + { + $teamId = $team === TeamAB::TeamA ? $this->home_team_id : $this->away_team_id; + + DB::transaction(function () use ($players, $staff, $teamId): void { + RosterPlayer::query() + ->where('game_id', $this->getKey()) + ->where('team_id', $teamId) + ->delete(); + + RosterStaff::query() + ->where('game_id', $this->getKey()) + ->where('team_id', $teamId) + ->delete(); + + foreach ($players as $playerRoster) { + $this->addPlayer( + player: $playerRoster['player'], + number: $playerRoster['number'], + isCaptain: $playerRoster['is_captain'], + isLibero: $playerRoster['is_libero'], + ); + } + + foreach ($staff as $staffMember) { + $this->addStaff($staffMember); + } + }); + } + + public function hasSubmittedInitialRosters(): bool + { + return $this->rosters_submitted + && $this->homePlayers()->exists() + && $this->awayPlayers()->exists(); + } + + public function markRostersSubmitted(): void + { + $this->forceFill([ + 'rosters_submitted' => true, + ])->save(); + } + /** * Add an official to the match. */ diff --git a/app/Models/RosterPlayer.php b/app/Models/RosterPlayer.php index 75bd025..e832bbf 100644 --- a/app/Models/RosterPlayer.php +++ b/app/Models/RosterPlayer.php @@ -5,6 +5,7 @@ namespace App\Models; use Carbon\CarbonImmutable; +use Illuminate\Database\Eloquent\Attributes\Table; use Illuminate\Database\Eloquent\Relations\Pivot; /** @@ -17,6 +18,7 @@ * @property CarbonImmutable|null $created_at * @property CarbonImmutable|null $updated_at */ +#[Table(name: 'game_player')] class RosterPlayer extends Pivot { #[\Override] diff --git a/app/Models/RosterStaff.php b/app/Models/RosterStaff.php index 76fb152..2c44bf9 100644 --- a/app/Models/RosterStaff.php +++ b/app/Models/RosterStaff.php @@ -6,6 +6,7 @@ use App\Enums\StaffRole; use Carbon\CarbonImmutable; +use Illuminate\Database\Eloquent\Attributes\Table; use Illuminate\Database\Eloquent\Relations\Pivot; /** @@ -16,6 +17,7 @@ * @property CarbonImmutable|null $created_at * @property CarbonImmutable|null $updated_at */ +#[Table(name: 'game_staff')] class RosterStaff extends Pivot { #[\Override] diff --git a/app/Models/Staff.php b/app/Models/Staff.php index d7ea2a4..2a8e663 100644 --- a/app/Models/Staff.php +++ b/app/Models/Staff.php @@ -4,6 +4,7 @@ namespace App\Models; +use App\Enums\StaffRole; use Carbon\CarbonImmutable; use Database\Factories\StaffFactory; use Illuminate\Database\Eloquent\Collection as EloquentCollection; @@ -16,6 +17,7 @@ * @property int $team_id * @property string $first_name * @property string $last_name + * @property StaffRole $role * @property CarbonImmutable|null $created_at * @property CarbonImmutable|null $updated_at * @property-read Team $team @@ -31,6 +33,7 @@ class Staff extends Model protected function casts(): array { return [ + 'role' => StaffRole::class, 'created_at' => 'immutable_datetime', 'updated_at' => 'immutable_datetime', ]; diff --git a/database/factories/StaffFactory.php b/database/factories/StaffFactory.php index 3fa42b7..ad84a08 100644 --- a/database/factories/StaffFactory.php +++ b/database/factories/StaffFactory.php @@ -4,6 +4,7 @@ namespace Database\Factories; +use App\Enums\StaffRole; use App\Models\Staff; use App\Models\Team; use Illuminate\Database\Eloquent\Factories\Factory; @@ -26,6 +27,7 @@ public function definition(): array 'team_id' => Team::factory(), 'first_name' => $this->faker->firstName(), 'last_name' => $this->faker->lastName(), + 'role' => $this->faker->randomElement(StaffRole::cases()), ]; } @@ -44,4 +46,39 @@ public function forCountry(string $code): static { return $this->withLocale($this->getLocaleForCountry($code)); } + + public function named(string $firstName, string $lastName): static + { + return $this->state(fn (array $attributes) => [ + 'first_name' => $firstName, + 'last_name' => $lastName, + ]); + } + + public function withRole(StaffRole $role): static + { + return $this->state(fn (array $attributes) => [ + 'role' => $role, + ]); + } + + public function asCoach(): static + { + return $this->withRole(StaffRole::Coach); + } + + public function asAssistantCoach(): static + { + return $this->withRole(StaffRole::AssistantCoach); + } + + public function asDoctor(): static + { + return $this->withRole(StaffRole::Doctor); + } + + public function asTherapist(): static + { + return $this->withRole(StaffRole::Therapist); + } } diff --git a/database/migrations/2026_06_01_174415_add_role_to_staff_table.php b/database/migrations/2026_06_01_174415_add_role_to_staff_table.php new file mode 100644 index 0000000..e84be25 --- /dev/null +++ b/database/migrations/2026_06_01_174415_add_role_to_staff_table.php @@ -0,0 +1,33 @@ +string('role')->nullable()->after('last_name'); + }); + + Schema::table('staff', function (Blueprint $table) { + $table->string('role')->default(StaffRole::Coach->value)->nullable(false)->change(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('staff', function (Blueprint $table) { + $table->dropColumn('role'); + }); + } +}; diff --git a/database/migrations/2026_06_01_175307_add_rosters_submitted_to_games_table.php b/database/migrations/2026_06_01_175307_add_rosters_submitted_to_games_table.php new file mode 100644 index 0000000..12eb0f1 --- /dev/null +++ b/database/migrations/2026_06_01_175307_add_rosters_submitted_to_games_table.php @@ -0,0 +1,28 @@ +boolean('rosters_submitted')->default(false)->after('category'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('games', function (Blueprint $table) { + $table->dropColumn('rosters_submitted'); + }); + } +}; diff --git a/database/seeders/ControlledGameSeeder.php b/database/seeders/ControlledGameSeeder.php index b569074..6ce9f4a 100644 --- a/database/seeders/ControlledGameSeeder.php +++ b/database/seeders/ControlledGameSeeder.php @@ -5,7 +5,6 @@ namespace Database\Seeders; use App\Enums\OfficialRole; -use App\Enums\StaffRole; use App\Enums\TeamAB; use App\Enums\TeamSide; use App\Models\Championship; @@ -65,17 +64,19 @@ private function seedControlledGame(): void ->count(12) ->create(); - Staff::factory() - ->for($homeTeam) - ->forCountry($homeTeam->country_code) - ->count(5) - ->create(); + $homeStaff = [ + Staff::factory()->for($homeTeam)->forCountry($homeTeam->country_code)->asCoach()->create(), + Staff::factory()->for($homeTeam)->forCountry($homeTeam->country_code)->asAssistantCoach()->create(), + Staff::factory()->for($homeTeam)->forCountry($homeTeam->country_code)->asAssistantCoach()->create(), + Staff::factory()->for($homeTeam)->forCountry($homeTeam->country_code)->asDoctor()->create(), + Staff::factory()->for($homeTeam)->forCountry($homeTeam->country_code)->asTherapist()->create(), + ]; - Staff::factory() - ->for($awayTeam) - ->forCountry($awayTeam->country_code) - ->count(3) - ->create(); + $awayStaff = [ + Staff::factory()->for($awayTeam)->forCountry($awayTeam->country_code)->asCoach()->create(), + Staff::factory()->for($awayTeam)->forCountry($awayTeam->country_code)->asAssistantCoach()->create(), + Staff::factory()->for($awayTeam)->forCountry($awayTeam->country_code)->asDoctor()->create(), + ]; $game = Game::factory() ->for($championship, 'championship') @@ -113,26 +114,12 @@ private function seedControlledGame(): void ); } - $homeStaffRoles = [ - StaffRole::Coach, - StaffRole::AssistantCoach, - StaffRole::AssistantCoach, - StaffRole::Doctor, - StaffRole::Therapist, - ]; - - foreach ($homeTeam->staff()->orderBy('id')->get() as $index => $staff) { - $game->addStaff($staff, $homeStaffRoles[$index]); + foreach ($homeStaff as $staff) { + $game->addStaff($staff); } - $awayStaffRoles = [ - StaffRole::Coach, - StaffRole::AssistantCoach, - StaffRole::Doctor, - ]; - - foreach ($awayTeam->staff()->orderBy('id')->get() as $index => $staff) { - $game->addStaff($staff, $awayStaffRoles[$index]); + foreach ($awayStaff as $staff) { + $game->addStaff($staff); } $game->recordToss(TeamSide::Home, TeamAB::TeamA); diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 6e2d17a..0c2a565 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -5,7 +5,6 @@ namespace Database\Seeders; use App\Enums\OfficialRole; -use App\Enums\StaffRole; use App\Models\Championship; use App\Models\Game; use App\Models\Official; @@ -38,11 +37,11 @@ public function run(): void ->count(14) ->create(); - Staff::factory() - ->for($team) - ->forCountry($team->country_code) - ->count(5) - ->create(); + Staff::factory()->for($team)->forCountry($team->country_code)->asCoach()->create(); + Staff::factory()->for($team)->forCountry($team->country_code)->asAssistantCoach()->create(); + Staff::factory()->for($team)->forCountry($team->country_code)->asAssistantCoach()->create(); + Staff::factory()->for($team)->forCountry($team->country_code)->asTherapist()->create(); + Staff::factory()->for($team)->forCountry($team->country_code)->asDoctor()->create(); }); /** @var Collection $officials */ @@ -55,7 +54,7 @@ public function run(): void ->scheduledAt(now()->addDays(1)) ->create(); - // Assign officials, players and staff to matches + // Assign officials to the match. Leave rosters empty so the UI starts at roster submission. $roles = OfficialRole::cases(); /** @var Collection $shuffledOfficials */ $shuffledOfficials = $officials @@ -68,37 +67,6 @@ public function run(): void $game->addOfficial($official, $role); } - // Players and Staff for both teams - foreach ([$game->homeTeam, $game->awayTeam] as $team) { - $usedNumbers = []; - /** @var Player $player */ - $captain = mt_rand(1, 14); - do { - $libero1 = mt_rand(1, 14); - $libero2 = mt_rand(1, 14); - } while ($captain === $libero1 || $captain === $libero2 || $libero1 === $libero2); - foreach ($team->players as $index => $player) { - $playerNumber = fake()->unique()->numberBetween(1, 99); - $game->addPlayer( - player: $player, - number: $playerNumber, - isCaptain: $index === $captain, - isLibero: $index === $libero1 || $index === $libero2 - ); - } - - $staffRoles = [ - StaffRole::Coach, - StaffRole::AssistantCoach, - StaffRole::AssistantCoach, - StaffRole::Therapist, - StaffRole::Doctor, - ]; - foreach ($team->staff as $index => $staff) { - $game->addStaff(staff: $staff, role: $staffRoles[$index]); - } - } - User::factory() ->testUser() ->create(); diff --git a/resources/views/livewire/court.blade.php b/resources/views/livewire/court.blade.php index 6567ccb..4818cd9 100644 --- a/resources/views/livewire/court.blade.php +++ b/resources/views/livewire/court.blade.php @@ -44,6 +44,7 @@
+ @if ($showSanctionControls)
+ @endif
+ @endif
diff --git a/resources/views/livewire/game.blade.php b/resources/views/livewire/game.blade.php index de671cb..2380290 100644 --- a/resources/views/livewire/game.blade.php +++ b/resources/views/livewire/game.blade.php @@ -4,9 +4,12 @@ id="game-canvas" class="relative flex min-h-[680px] w-full max-w-[1536px] items-center justify-center overflow-hidden rounded-2xl border border-accent bg-sky-100 sm:min-h-[760px] md:min-h-[840px] 2xl:min-h-[998px]" > - + @unless ($isBeforeInitialToss) + + @endunless +
diff --git a/resources/views/livewire/roster-submission.blade.php b/resources/views/livewire/roster-submission.blade.php new file mode 100644 index 0000000..97640c0 --- /dev/null +++ b/resources/views/livewire/roster-submission.blade.php @@ -0,0 +1,296 @@ +
+ @if ($isBeforeInitialToss && ! $hasSubmittedRosters) +
+ + Submit rosters + +
+ + +
+
+ Submit rosters + + Enter each selected player's roster number, choose one captain, and tick the libero checkbox where needed. Check the staff who will take part in the game. + +
+ +
+
+
+ Home Team + {{ $homeTeamCode }} +
+ +
+ Players +
+ Name + Captain? + Number + Libero? +
+
+ @forelse ($homePlayers as $player) +
+
+
{{ $player->first_name }} {{ $player->last_name }}
+
+
+ +
+
+ +
+
+ +
+
+ @error('homeRosterInputs.'.$player->getKey()) + {{ $message }} + @enderror + @empty + No players available. + @endforelse +
+ @error('homeRosterInputs') + {{ $message }} + @enderror + @error('homeCaptainSelection') + {{ $message }} + @enderror +
+ +
+ Staff +
+ @forelse ($homeStaff as $staffMember) + + @empty + No staff available. + @endforelse +
+
+
+ +
+
+ Away Team + {{ $awayTeamCode }} +
+ +
+ Players +
+ Name + Captain? + Number + Libero? +
+
+ @forelse ($awayPlayers as $player) +
+
+
{{ $player->first_name }} {{ $player->last_name }}
+
+
+ +
+
+ +
+
+ +
+
+ @error('awayRosterInputs.'.$player->getKey()) + {{ $message }} + @enderror + @empty + No players available. + @endforelse +
+ @error('awayRosterInputs') + {{ $message }} + @enderror + @error('awayCaptainSelection') + {{ $message }} + @enderror +
+ +
+ Staff +
+ @forelse ($awayStaff as $staffMember) + + @empty + No staff available. + @endforelse +
+
+
+
+ + @error('rosters') + {{ $message }} + @enderror + +
+ + + Cancel + + + Submit + +
+
+
+ + +
+
+ Confirm rosters + + Review the submitted roster details for both teams before saving them permanently. + +
+ +
+
+ {{ $homeTeamCode }} +
+
+ {{ $awayTeamCode }} +
+
+ +
+
+
+ @foreach ([$homeRosterConfirmation, $awayRosterConfirmation] as $teamConfirmation) +
+ Players + @if ($teamConfirmation['players']->isEmpty()) + No rostered players. + @else +
+ @foreach ($teamConfirmation['players'] as $player) +
+ + @if ($player['is_captain']) + + {{ $player['number'] }} + + @else + {{ $player['number'] }} + @endif + + {{ $player['name'] }} +
+ @endforeach +
+ @endif +
+ @endforeach +
+
+ +
+
+ @foreach ([$homeRosterConfirmation, $awayRosterConfirmation] as $teamConfirmation) +
+ Liberos + @if ($teamConfirmation['liberos']->isEmpty()) + No liberos selected. + @else +
+ @foreach ($teamConfirmation['liberos'] as $libero) +
+ + {{ $libero['number'] }} + + {{ $libero['name'] }} +
+ @endforeach +
+ @endif +
+ @endforeach +
+
+ +
+
+ @foreach ([$homeRosterConfirmation, $awayRosterConfirmation] as $teamConfirmation) +
+ Bench + @if ($teamConfirmation['bench']->isEmpty()) + No bench staff selected. + @else +
+ @foreach ($teamConfirmation['bench'] as $staffMember) +
+ + {{ $staffMember['role'] }} + + {{ $staffMember['name'] }} +
+ @endforeach +
+ @endif +
+ @endforeach +
+
+
+ + @error('rosters') + {{ $message }} + @enderror + +
+ + + Return to roster + + + Submit + +
+
+
+ @endif +
diff --git a/resources/views/livewire/toss-result-submission.blade.php b/resources/views/livewire/toss-result-submission.blade.php index b26e7eb..741c882 100644 --- a/resources/views/livewire/toss-result-submission.blade.php +++ b/resources/views/livewire/toss-result-submission.blade.php @@ -1,64 +1,127 @@
- @unless ($hasSubmittedToss) -
- - - {{ $isFifthSetToss ? 'Submit Fifth Set Toss' : 'Submit Toss Result' }} - - + @if ($isBeforeInitialToss) + @if ($hasSubmittedRosters) +
+ + + Submit Toss Result + + - -
-
- {{ $isFifthSetToss ? 'Submit Fifth Set Toss' : 'Submit Toss Result' }} - - {{ $isFifthSetToss - ? 'Select which team starts on the left and which team serves first.' - : 'Select which side is Team A and which team serves first.' }} - -
+ + +
+ Submit Toss Result + + Select which side is Team A and which team serves first. + +
- - - - - @error($isFifthSetToss ? 'left' : 'teamA') - {{ $message }} - @enderror + + + + + @error('teamA') + {{ $message }} + @enderror - - - - - @error('serving') - {{ $message }} - @enderror + + + + + @error('serving') + {{ $message }} + @enderror - @error('submit') - {{ $message }} - @enderror + @error('submit') + {{ $message }} + @enderror -
- - - Cancel - - - {{ $isFifthSetToss ? 'Save Fifth Set Toss' : 'Save Toss Result' }} - -
- -
-
- @endunless +
+ + + Cancel + + + Save Toss Result + +
+ + +
+ @endif + @else + @unless ($hasSubmittedToss) +
+ + + {{ $isFifthSetToss ? 'Submit Fifth Set Toss' : 'Submit Toss Result' }} + + + + +
+
+ {{ $isFifthSetToss ? 'Submit Fifth Set Toss' : 'Submit Toss Result' }} + + {{ $isFifthSetToss + ? 'Select which team starts on the left and which team serves first.' + : 'Select which side is Team A and which team serves first.' }} + +
+ + + + + + @error($isFifthSetToss ? 'left' : 'teamA') + {{ $message }} + @enderror + + + + + + @error('serving') + {{ $message }} + @enderror + + @error('submit') + {{ $message }} + @enderror + +
+ + + Cancel + + + {{ $isFifthSetToss ? 'Save Fifth Set Toss' : 'Save Toss Result' }} + +
+
+
+
+ @endunless + @endif
diff --git a/tests/Feature/CourtTest.php b/tests/Feature/CourtTest.php index 8a67b4f..ea12fc9 100644 --- a/tests/Feature/CourtTest.php +++ b/tests/Feature/CourtTest.php @@ -37,6 +37,10 @@ ->assertDontSeeHtml('data-team-roster-number="12"') ->assertDontSeeHtml('data-team-roster-number="2"') ->assertDontSeeHtml('data-team-roster-number="9"') + ->assertDontSeeHtml('data-misconduct-controls="left"') + ->assertDontSeeHtml('data-misconduct-controls="right"') + ->assertDontSeeHtml('data-delay-controls="left"') + ->assertDontSeeHtml('data-delay-controls="right"') ->assertDontSee('1 Libero') ->assertDontSee('20 Keeper') ->assertDontSee('Anna') diff --git a/tests/Feature/DatabaseSeederTest.php b/tests/Feature/DatabaseSeederTest.php new file mode 100644 index 0000000..4cd4b15 --- /dev/null +++ b/tests/Feature/DatabaseSeederTest.php @@ -0,0 +1,21 @@ +seed(DatabaseSeeder::class); + + $game = Game::query()->with(['players', 'staff', 'officials'])->sole(); + + expect($game->rosters_submitted)->toBeFalse() + ->and($game->players)->toHaveCount(0) + ->and($game->staff)->toHaveCount(0) + ->and($game->officials)->toHaveCount(count(OfficialRole::cases())); +}); diff --git a/tests/Feature/GamePageTest.php b/tests/Feature/GamePageTest.php index d641b3d..ac1b872 100644 --- a/tests/Feature/GamePageTest.php +++ b/tests/Feature/GamePageTest.php @@ -26,7 +26,9 @@ ->assertSee('id="game-canvas"', false) ->assertSee('bg-sky-100') ->assertSee('id="volleyball-court"', false) - ->assertSee('Submit Toss Result') + ->assertSee('Submit rosters') + ->assertDontSee('Submit Toss Result') + ->assertDontSee('data-scoreboard', false) ->assertDontSee('Submit Lineup'); }); @@ -35,7 +37,9 @@ Livewire::test(Game::class, ['game' => $game]) ->assertSeeHtml('id="game-canvas"') - ->assertSeeHtml('id="volleyball-court"'); + ->assertSeeHtml('id="volleyball-court"') + ->assertSee('Submit rosters') + ->assertDontSeeHtml('data-scoreboard'); }); test('the game page renders the start set button when both lineups are submitted for the upcoming set', function (): void { diff --git a/tests/Feature/GameRosterTest.php b/tests/Feature/GameRosterTest.php index 4a30d93..ef8561f 100644 --- a/tests/Feature/GameRosterTest.php +++ b/tests/Feature/GameRosterTest.php @@ -34,9 +34,9 @@ test('a game has match staff', function (): void { $game = Game::factory()->create(); $team = Team::factory()->create(); - $staff = Staff::factory()->for($team)->create(); + $staff = Staff::factory()->for($team)->asCoach()->create(); - $game->addStaff($staff, StaffRole::Coach); + $game->addStaff($staff); expect($game->staff)->toHaveCount(1) ->and($game->staff->first()->getKey())->toBe($staff->getKey()) diff --git a/tests/Feature/RosterSubmissionTest.php b/tests/Feature/RosterSubmissionTest.php new file mode 100644 index 0000000..3b40114 --- /dev/null +++ b/tests/Feature/RosterSubmissionTest.php @@ -0,0 +1,491 @@ +set('game.between_sets_duration', 0); +}); + +test('initial roster submission is shown before the initial toss', function (): void { + $game = Game::factory()->create(); + + Livewire::test(RosterSubmission::class, ['gameId' => $game->getKey()]) + ->assertSee('Submit rosters') + ->assertSeeHtml('data-submit-roster-button'); +}); + +test('submit rosters modal shows home and away players and staff', function (): void { + $game = Game::factory()->create(); + Player::factory()->for($game->homeTeam)->named('Anna', 'Zephyr')->create(); + Player::factory()->for($game->awayTeam)->named('Dora', 'Young')->create(); + Staff::factory()->for($game->homeTeam)->asCoach()->named('Helen', 'Coach')->create(); + Staff::factory()->for($game->awayTeam)->asDoctor()->named('Mila', 'Doctor')->create(); + + Livewire::test(RosterSubmission::class, ['gameId' => $game->getKey()]) + ->call('openRosterModal') + ->assertSee('Anna Zephyr') + ->assertSee('Dora Young') + ->assertSee('Captain?') + ->assertSee('Helen Coach') + ->assertSee('Mila Doctor') + ->assertSee(StaffRole::Coach->value) + ->assertSee(StaffRole::Doctor->value); +}); + +test('submitting rosters opens confirmation and confirm saves selected players and checked staff for both teams', function (): void { + $game = Game::factory()->create(); + $homePlayers = Player::factory()->for($game->homeTeam)->count(7)->create(); + $awayPlayers = Player::factory()->for($game->awayTeam)->count(7)->create(); + $homeCoach = Staff::factory()->for($game->homeTeam)->asCoach()->create(); + $awayDoctor = Staff::factory()->for($game->awayTeam)->asDoctor()->create(); + + $component = Livewire::test(RosterSubmission::class, ['gameId' => $game->getKey()]); + + foreach ($homePlayers as $index => $player) { + $component->set("homeRosterInputs.{$player->getKey()}", $index === 6 ? '12' : (string) ($index + 1)); + } + + foreach ($awayPlayers as $index => $player) { + $component->set("awayRosterInputs.{$player->getKey()}", $index === 6 ? '22' : (string) ($index + 11)); + } + + $component + ->set('homeCaptainSelection', (string) $homePlayers[0]->getKey()) + ->set('awayCaptainSelection', (string) $awayPlayers[0]->getKey()) + ->set("homeLiberoSelection.{$homePlayers[6]->getKey()}", true) + ->set("awayLiberoSelection.{$awayPlayers[6]->getKey()}", true) + ->set("homeStaffSelection.{$homeCoach->getKey()}", true) + ->set("awayStaffSelection.{$awayDoctor->getKey()}", true) + ->call('submitRosters') + ->assertHasNoErrors(); + + expect($game->fresh()->homePlayers)->toHaveCount(0) + ->and($game->fresh()->awayPlayers)->toHaveCount(0) + ->and($game->fresh()->rosters_submitted)->toBeFalse(); + + $component + ->assertSee('Confirm rosters') + ->assertSee($homePlayers[0]->last_name.' '.mb_substr((string) $homePlayers[0]->first_name, 0, 1).'.') + ->assertSee((string) 12) + ->assertSee('Bench') + ->call('confirmRosters') + ->assertHasNoErrors() + ->assertDontSee('Submit rosters'); + + $freshGame = $game->fresh(); + + expect($freshGame->homePlayers)->toHaveCount(7) + ->and($freshGame->awayPlayers)->toHaveCount(7) + ->and($freshGame->homePlayers->firstWhere('id', $homePlayers[0]->getKey())?->roster->is_captain)->toBeTrue() + ->and($freshGame->homePlayers->firstWhere('id', $homePlayers[6]->getKey())?->roster->number)->toBe(12) + ->and($freshGame->homePlayers->firstWhere('id', $homePlayers[6]->getKey())?->roster->is_libero)->toBeTrue() + ->and($freshGame->awayPlayers->firstWhere('id', $awayPlayers[0]->getKey())?->roster->is_captain)->toBeTrue() + ->and($freshGame->awayPlayers->firstWhere('id', $awayPlayers[6]->getKey())?->roster->number)->toBe(22) + ->and($freshGame->awayPlayers->firstWhere('id', $awayPlayers[6]->getKey())?->roster->is_libero)->toBeTrue() + ->and($freshGame->homeStaff->first()?->getKey())->toBe($homeCoach->getKey()) + ->and($freshGame->awayStaff->first()?->getKey())->toBe($awayDoctor->getKey()) + ->and($freshGame->rosters_submitted)->toBeTrue(); +}); + +test('confirming rosters only saves the checked staff members for each team', function (): void { + $game = Game::factory()->create(); + $homePlayers = Player::factory()->for($game->homeTeam)->count(6)->create(); + $awayPlayers = Player::factory()->for($game->awayTeam)->count(6)->create(); + $homeCoach = Staff::factory()->for($game->homeTeam)->asCoach()->create(); + $homeDoctor = Staff::factory()->for($game->homeTeam)->asDoctor()->create(); + $homeTherapist = Staff::factory()->for($game->homeTeam)->asTherapist()->create(); + $awayCoach = Staff::factory()->for($game->awayTeam)->asCoach()->create(); + $awayAssistantCoach = Staff::factory()->for($game->awayTeam)->asAssistantCoach()->create(); + $awayDoctor = Staff::factory()->for($game->awayTeam)->asDoctor()->create(); + + $component = Livewire::test(RosterSubmission::class, ['gameId' => $game->getKey()]); + + foreach ($homePlayers as $index => $player) { + $component->set("homeRosterInputs.{$player->getKey()}", (string) ($index + 1)); + } + + foreach ($awayPlayers as $index => $player) { + $component->set("awayRosterInputs.{$player->getKey()}", (string) ($index + 11)); + } + + $component + ->set('homeCaptainSelection', (string) $homePlayers[0]->getKey()) + ->set('awayCaptainSelection', (string) $awayPlayers[0]->getKey()) + ->set("homeStaffSelection.{$homeCoach->getKey()}", true) + ->set("homeStaffSelection.{$homeDoctor->getKey()}", true) + ->set("awayStaffSelection.{$awayAssistantCoach->getKey()}", true) + ->call('confirmRosters') + ->assertHasNoErrors(); + + $freshGame = $game->fresh(); + + expect($freshGame->homeStaff->modelKeys()) + ->toBe([$homeCoach->getKey(), $homeDoctor->getKey()]) + ->and($freshGame->homeStaff->contains(fn (Staff $staff): bool => $staff->getKey() === $homeTherapist->getKey()))->toBeFalse() + ->and($freshGame->awayStaff->modelKeys())->toBe([$awayAssistantCoach->getKey()]) + ->and($freshGame->awayStaff->contains(fn (Staff $staff): bool => $staff->getKey() === $awayCoach->getKey()))->toBeFalse() + ->and($freshGame->awayStaff->contains(fn (Staff $staff): bool => $staff->getKey() === $awayDoctor->getKey()))->toBeFalse(); +}); + +test('returning to roster from confirmation preserves entered selections', function (): void { + $game = Game::factory()->create(); + $homePlayers = Player::factory()->for($game->homeTeam)->count(6)->create(); + $awayPlayers = Player::factory()->for($game->awayTeam)->count(6)->create(); + + $component = Livewire::test(RosterSubmission::class, ['gameId' => $game->getKey()]); + + foreach ($homePlayers as $index => $player) { + $component->set("homeRosterInputs.{$player->getKey()}", (string) ($index + 1)); + } + + foreach ($awayPlayers as $index => $player) { + $component->set("awayRosterInputs.{$player->getKey()}", (string) ($index + 11)); + } + + $component + ->set('homeCaptainSelection', (string) $homePlayers[1]->getKey()) + ->set('awayCaptainSelection', (string) $awayPlayers[2]->getKey()) + ->set("homeLiberoSelection.{$homePlayers[5]->getKey()}", true) + ->set("awayLiberoSelection.{$awayPlayers[4]->getKey()}", true) + ->call('submitRosters') + ->assertSee('Confirm rosters') + ->call('returnToRoster') + ->assertSet("homeRosterInputs.{$homePlayers[0]->getKey()}", '1') + ->assertSet("awayRosterInputs.{$awayPlayers[0]->getKey()}", '11') + ->assertSet('homeCaptainSelection', (string) $homePlayers[1]->getKey()) + ->assertSet('awayCaptainSelection', (string) $awayPlayers[2]->getKey()) + ->assertSet("homeLiberoSelection.{$homePlayers[5]->getKey()}", true) + ->assertSet("awayLiberoSelection.{$awayPlayers[4]->getKey()}", true); +}); + +test('confirmation groups and orders roster details for each team', function (): void { + $game = Game::factory()->create(); + + $homePlayers = collect([ + Player::factory()->for($game->homeTeam)->named('Alice', 'Zulu')->create(), + Player::factory()->for($game->homeTeam)->named('Beth', 'Able')->create(), + Player::factory()->for($game->homeTeam)->named('Cara', 'Mason')->create(), + Player::factory()->for($game->homeTeam)->named('Dana', 'Nolan')->create(), + Player::factory()->for($game->homeTeam)->named('Eve', 'Olsen')->create(), + Player::factory()->for($game->homeTeam)->named('Faye', 'Piper')->create(), + Player::factory()->for($game->homeTeam)->named('Gia', 'Quill')->create(), + ]); + + $awayPlayers = Player::factory()->for($game->awayTeam)->count(6)->create(); + + $homeCoach = Staff::factory()->for($game->homeTeam)->asCoach()->named('Holly', 'Coach')->create(); + $assistantBravo = Staff::factory()->for($game->homeTeam)->asAssistantCoach()->named('Iris', 'Bravo')->create(); + $assistantAlpha = Staff::factory()->for($game->homeTeam)->asAssistantCoach()->named('Jade', 'Alpha')->create(); + $therapist = Staff::factory()->for($game->homeTeam)->asTherapist()->named('Kora', 'Therapist')->create(); + $doctor = Staff::factory()->for($game->homeTeam)->asDoctor()->named('Lina', 'Doctor')->create(); + + $component = Livewire::test(RosterSubmission::class, ['gameId' => $game->getKey()]); + + foreach ($homePlayers as $index => $player) { + $number = match ($index) { + 0 => '8', + 1 => '3', + 2 => '11', + 3 => '5', + 4 => '7', + 5 => '2', + default => '13', + }; + + $component->set("homeRosterInputs.{$player->getKey()}", $number); + } + + foreach ($awayPlayers as $index => $player) { + $component->set("awayRosterInputs.{$player->getKey()}", (string) ($index + 21)); + } + + $component + ->set('homeCaptainSelection', (string) $homePlayers[1]->getKey()) + ->set('awayCaptainSelection', (string) $awayPlayers[0]->getKey()) + ->set("homeLiberoSelection.{$homePlayers[6]->getKey()}", true) + ->set("homeStaffSelection.{$homeCoach->getKey()}", true) + ->set("homeStaffSelection.{$assistantBravo->getKey()}", true) + ->set("homeStaffSelection.{$assistantAlpha->getKey()}", true) + ->set("homeStaffSelection.{$therapist->getKey()}", true) + ->set("homeStaffSelection.{$doctor->getKey()}", true) + ->call('submitRosters') + ->assertSeeInOrder([ + 'Players', + '3', + 'Able B.', + '5', + 'Nolan D.', + '7', + 'Olsen E.', + '8', + 'Zulu A.', + '11', + 'Mason C.', + 'Liberos', + '13', + 'Quill G.', + 'Bench', + 'C', + 'Coach H.', + 'AC1', + 'Alpha J.', + 'AC2', + 'Bravo I.', + 'T', + 'Therapist K.', + 'D', + 'Doctor L.', + ]); +}); + +test('submitting rosters requires six non-libero players per team', function (): void { + $game = Game::factory()->create(); + $homePlayers = Player::factory()->for($game->homeTeam)->count(6)->create(); + $awayPlayers = Player::factory()->for($game->awayTeam)->count(6)->create(); + + $component = Livewire::test(RosterSubmission::class, ['gameId' => $game->getKey()]); + + foreach ($homePlayers as $index => $player) { + $component->set("homeRosterInputs.{$player->getKey()}", $index === 5 ? '7' : (string) ($index + 1)); + } + + foreach ($awayPlayers as $index => $player) { + $component->set("awayRosterInputs.{$player->getKey()}", (string) ($index + 11)); + } + + $component + ->set('homeCaptainSelection', (string) $homePlayers[0]->getKey()) + ->set('awayCaptainSelection', (string) $awayPlayers[0]->getKey()) + ->set("homeLiberoSelection.{$homePlayers[5]->getKey()}", true) + ->call('confirmRosters') + ->assertHasErrors(['homeRosterInputs']); +}); + +test('submitting rosters requires player numbers to be between 1 and 99', function (): void { + $game = Game::factory()->create(); + $homePlayers = Player::factory()->for($game->homeTeam)->count(6)->create(); + $awayPlayers = Player::factory()->for($game->awayTeam)->count(6)->create(); + + $component = Livewire::test(RosterSubmission::class, ['gameId' => $game->getKey()]); + + foreach ($homePlayers as $index => $player) { + $component->set("homeRosterInputs.{$player->getKey()}", $index === 0 ? '0' : (string) ($index + 1)); + } + + foreach ($awayPlayers as $index => $player) { + $component->set("awayRosterInputs.{$player->getKey()}", $index === 0 ? '100' : (string) ($index + 11)); + } + + $component + ->set('homeCaptainSelection', (string) $homePlayers[0]->getKey()) + ->set('awayCaptainSelection', (string) $awayPlayers[0]->getKey()) + ->call('confirmRosters') + ->assertHasErrors([ + "homeRosterInputs.{$homePlayers[0]->getKey()}", + "awayRosterInputs.{$awayPlayers[0]->getKey()}", + ]); +}); + +test('submitting rosters requires unique player numbers within each team including liberos', function (): void { + $game = Game::factory()->create(); + $homePlayers = Player::factory()->for($game->homeTeam)->count(7)->create(); + $awayPlayers = Player::factory()->for($game->awayTeam)->count(6)->create(); + + $component = Livewire::test(RosterSubmission::class, ['gameId' => $game->getKey()]); + + foreach ($homePlayers as $index => $player) { + $component->set("homeRosterInputs.{$player->getKey()}", $index === 6 ? '1' : (string) ($index + 1)); + } + + foreach ($awayPlayers as $index => $player) { + $component->set("awayRosterInputs.{$player->getKey()}", (string) ($index + 11)); + } + + $component + ->set('homeCaptainSelection', (string) $homePlayers[0]->getKey()) + ->set('awayCaptainSelection', (string) $awayPlayers[0]->getKey()) + ->set("homeLiberoSelection.{$homePlayers[6]->getKey()}", true) + ->call('confirmRosters') + ->assertHasErrors(["homeRosterInputs.{$homePlayers[6]->getKey()}"]); +}); + +test('submitting rosters allows at most two liberos per team', function (): void { + $game = Game::factory()->create(); + $homePlayers = Player::factory()->for($game->homeTeam)->count(8)->create(); + $awayPlayers = Player::factory()->for($game->awayTeam)->count(6)->create(); + + $component = Livewire::test(RosterSubmission::class, ['gameId' => $game->getKey()]); + + foreach ($homePlayers as $index => $player) { + $component->set("homeRosterInputs.{$player->getKey()}", (string) ($index + 1)); + } + + foreach ($awayPlayers as $index => $player) { + $component->set("awayRosterInputs.{$player->getKey()}", (string) ($index + 11)); + } + + $component + ->set('homeCaptainSelection', (string) $homePlayers[0]->getKey()) + ->set('awayCaptainSelection', (string) $awayPlayers[0]->getKey()) + ->set("homeLiberoSelection.{$homePlayers[5]->getKey()}", true) + ->set("homeLiberoSelection.{$homePlayers[6]->getKey()}", true) + ->set("homeLiberoSelection.{$homePlayers[7]->getKey()}", true) + ->call('confirmRosters') + ->assertHasErrors(['homeRosterInputs']); +}); + +test('submitting rosters allows at most twelve non-libero players per team', function (): void { + $game = Game::factory()->create(); + $homePlayers = Player::factory()->for($game->homeTeam)->count(14)->create(); + $awayPlayers = Player::factory()->for($game->awayTeam)->count(6)->create(); + + $component = Livewire::test(RosterSubmission::class, ['gameId' => $game->getKey()]); + + foreach ($homePlayers as $index => $player) { + $component->set("homeRosterInputs.{$player->getKey()}", (string) ($index + 1)); + } + + foreach ($awayPlayers as $index => $player) { + $component->set("awayRosterInputs.{$player->getKey()}", (string) ($index + 21)); + } + + $component + ->set('homeCaptainSelection', (string) $homePlayers[0]->getKey()) + ->set('awayCaptainSelection', (string) $awayPlayers[0]->getKey()) + ->set("homeLiberoSelection.{$homePlayers[12]->getKey()}", true) + ->set("homeLiberoSelection.{$homePlayers[13]->getKey()}", true) + ->call('confirmRosters') + ->assertHasNoErrors(['homeRosterInputs']); + + $component + ->set("homeLiberoSelection.{$homePlayers[12]->getKey()}", false) + ->call('confirmRosters') + ->assertHasErrors(['homeRosterInputs']); +}); + +test('submitting rosters requires a libero to be a rostered player', function (): void { + $game = Game::factory()->create(); + $homePlayers = Player::factory()->for($game->homeTeam)->count(6)->create(); + $awayPlayers = Player::factory()->for($game->awayTeam)->count(6)->create(); + + $component = Livewire::test(RosterSubmission::class, ['gameId' => $game->getKey()]); + + foreach ($homePlayers as $index => $player) { + $component->set("homeRosterInputs.{$player->getKey()}", $index === 5 ? '' : (string) ($index + 1)); + } + + foreach ($awayPlayers as $index => $player) { + $component->set("awayRosterInputs.{$player->getKey()}", (string) ($index + 11)); + } + + $component + ->set('homeCaptainSelection', (string) $homePlayers[0]->getKey()) + ->set('awayCaptainSelection', (string) $awayPlayers[0]->getKey()) + ->set("homeLiberoSelection.{$homePlayers[5]->getKey()}", true) + ->call('confirmRosters') + ->assertHasErrors(["homeRosterInputs.{$homePlayers[5]->getKey()}"]) + ->assertHasNoErrors(['homeCaptainSelection']); +}); + +test('submitting rosters requires one captain per team', function (): void { + $game = Game::factory()->create(); + $homePlayers = Player::factory()->for($game->homeTeam)->count(6)->create(); + $awayPlayers = Player::factory()->for($game->awayTeam)->count(6)->create(); + + $component = Livewire::test(RosterSubmission::class, ['gameId' => $game->getKey()]); + + foreach ($homePlayers as $index => $player) { + $component->set("homeRosterInputs.{$player->getKey()}", (string) ($index + 1)); + } + + foreach ($awayPlayers as $index => $player) { + $component->set("awayRosterInputs.{$player->getKey()}", (string) ($index + 11)); + } + + $component + ->set('awayCaptainSelection', (string) $awayPlayers[0]->getKey()) + ->call('confirmRosters') + ->assertHasErrors(['homeCaptainSelection']); +}); + +test('submitting rosters requires the captain to be one of the rostered players', function (): void { + $game = Game::factory()->create(); + $homePlayers = Player::factory()->for($game->homeTeam)->count(6)->create(); + $awayPlayers = Player::factory()->for($game->awayTeam)->count(6)->create(); + + $component = Livewire::test(RosterSubmission::class, ['gameId' => $game->getKey()]); + + foreach ($homePlayers as $index => $player) { + $component->set("homeRosterInputs.{$player->getKey()}", (string) ($index + 1)); + } + + foreach ($awayPlayers as $index => $player) { + $component->set("awayRosterInputs.{$player->getKey()}", (string) ($index + 11)); + } + + $component + ->set('homeCaptainSelection', (string) $homePlayers[0]->getKey()) + ->set('awayCaptainSelection', (string) $awayPlayers[5]->getKey()) + ->set("awayRosterInputs.{$awayPlayers[5]->getKey()}", '') + ->call('confirmRosters') + ->assertHasErrors(['awayCaptainSelection']) + ->assertHasNoErrors(["awayRosterInputs.{$awayPlayers[5]->getKey()}"]); +}); + +test('roster validation errors clear when the user fixes the input', function (): void { + $game = Game::factory()->create(); + $homePlayers = Player::factory()->for($game->homeTeam)->count(6)->create(); + $awayPlayers = Player::factory()->for($game->awayTeam)->count(6)->create(); + + $component = Livewire::test(RosterSubmission::class, ['gameId' => $game->getKey()]); + + foreach ($homePlayers as $index => $player) { + $component->set("homeRosterInputs.{$player->getKey()}", $index === 1 ? '1' : (string) ($index + 1)); + } + + foreach ($awayPlayers as $index => $player) { + $component->set("awayRosterInputs.{$player->getKey()}", (string) ($index + 11)); + } + + $component + ->set('homeCaptainSelection', (string) $homePlayers[0]->getKey()) + ->set('awayCaptainSelection', (string) $awayPlayers[0]->getKey()) + ->call('confirmRosters') + ->assertHasErrors(["homeRosterInputs.{$homePlayers[1]->getKey()}"]); + + $component + ->set("homeRosterInputs.{$homePlayers[1]->getKey()}", '2') + ->assertHasNoErrors(["homeRosterInputs.{$homePlayers[1]->getKey()}"]); +}); + +function seedRosterableTeams(Game $game): void +{ + $homePlayers = Player::factory()->for($game->homeTeam)->count(6)->create(); + $awayPlayers = Player::factory()->for($game->awayTeam)->count(6)->create(); + + $component = Livewire::test(RosterSubmission::class, ['gameId' => $game->getKey()]); + + foreach ($homePlayers as $index => $player) { + $component->set("homeRosterInputs.{$player->getKey()}", (string) ($index + 1)); + } + + foreach ($awayPlayers as $index => $player) { + $component->set("awayRosterInputs.{$player->getKey()}", (string) ($index + 11)); + } + + $component + ->set('homeCaptainSelection', (string) $homePlayers[0]->getKey()) + ->set('awayCaptainSelection', (string) $awayPlayers[0]->getKey()) + ->call('confirmRosters') + ->assertHasNoErrors(); +} diff --git a/tests/Feature/TeamRosterTest.php b/tests/Feature/TeamRosterTest.php index 477c400..46188fb 100644 --- a/tests/Feature/TeamRosterTest.php +++ b/tests/Feature/TeamRosterTest.php @@ -248,6 +248,42 @@ ->assertDontSeeHtml('data-team-roster-staff-role="T"'); }); +test('team roster bench only shows staff roles that were selected during roster submission', function (): void { + $game = Game::factory()->create(); + $homePlayers = Player::factory()->for($game->homeTeam)->count(6)->create(); + $awayPlayers = Player::factory()->for($game->awayTeam)->count(6)->create(); + $homeCoach = Staff::factory()->for($game->homeTeam)->asCoach()->create(); + $homeAssistantCoach = Staff::factory()->for($game->homeTeam)->asAssistantCoach()->create(); + $homeDoctor = Staff::factory()->for($game->homeTeam)->asDoctor()->create(); + $homeTherapist = Staff::factory()->for($game->homeTeam)->asTherapist()->create(); + + foreach ($homePlayers as $index => $player) { + $game->addPlayer($player, number: $index + 1); + } + + foreach ($awayPlayers as $index => $player) { + $game->addPlayer($player, number: $index + 11); + } + + $game->addStaff($homeCoach, StaffRole::Coach); + $game->addStaff($homeAssistantCoach, StaffRole::AssistantCoach); + $game->addStaff($homeDoctor, StaffRole::Doctor); + + Livewire::test(TeamRoster::class, [ + 'gameId' => $game->getKey(), + 'team' => TeamAB::TeamA, + 'leftSide' => true, + ]) + ->assertSeeHtml('data-team-roster-staff-role="C"') + ->assertSeeHtml('data-team-roster-staff-role="A1"') + ->assertSeeHtml('data-team-roster-staff-role="D"') + ->assertDontSeeHtml('data-team-roster-staff-role="A2"') + ->assertDontSeeHtml('data-team-roster-staff-role="T"'); + + expect($game->homeStaff->contains(fn (Staff $staff): bool => $staff->getKey() === $homeTherapist->getKey())) + ->toBeFalse(); +}); + test('timeout card shows modal trigger when a set is in progress', function (): void { $game = gameWithActiveSetForTeamRoster(); @@ -763,16 +799,16 @@ function gameWithNumberedRostersForTeamRoster(): Game $awayPlayerOne = Player::factory()->for($awayTeam)->named('Dora', 'Young')->create(); $awayPlayerTwo = Player::factory()->for($awayTeam)->named('Etta', 'Baker')->create(); $awayLibero = Player::factory()->for($awayTeam)->named('Faye', 'Keeper')->create(); - $homeCoach = Staff::factory()->for($homeTeam)->create(); - $homeAssistantCoachOne = Staff::factory()->for($homeTeam)->create(); - $homeAssistantCoachTwo = Staff::factory()->for($homeTeam)->create(); - $homeDoctor = Staff::factory()->for($homeTeam)->create(); - $homeTherapist = Staff::factory()->for($homeTeam)->create(); - $awayCoach = Staff::factory()->for($awayTeam)->create(); - $awayAssistantCoachOne = Staff::factory()->for($awayTeam)->create(); - $awayAssistantCoachTwo = Staff::factory()->for($awayTeam)->create(); - $awayDoctor = Staff::factory()->for($awayTeam)->create(); - $awayTherapist = Staff::factory()->for($awayTeam)->create(); + $homeCoach = Staff::factory()->for($homeTeam)->asCoach()->create(); + $homeAssistantCoachOne = Staff::factory()->for($homeTeam)->asAssistantCoach()->create(); + $homeAssistantCoachTwo = Staff::factory()->for($homeTeam)->asAssistantCoach()->create(); + $homeDoctor = Staff::factory()->for($homeTeam)->asDoctor()->create(); + $homeTherapist = Staff::factory()->for($homeTeam)->asTherapist()->create(); + $awayCoach = Staff::factory()->for($awayTeam)->asCoach()->create(); + $awayAssistantCoachOne = Staff::factory()->for($awayTeam)->asAssistantCoach()->create(); + $awayAssistantCoachTwo = Staff::factory()->for($awayTeam)->asAssistantCoach()->create(); + $awayDoctor = Staff::factory()->for($awayTeam)->asDoctor()->create(); + $awayTherapist = Staff::factory()->for($awayTeam)->asTherapist()->create(); $game->addPlayer($homePlayerOne, number: 12); $game->addPlayer($homePlayerTwo, number: 3); diff --git a/tests/Feature/TossResultSubmissionTest.php b/tests/Feature/TossResultSubmissionTest.php index 2dbe1ce..ef053b1 100644 --- a/tests/Feature/TossResultSubmissionTest.php +++ b/tests/Feature/TossResultSubmissionTest.php @@ -6,6 +6,7 @@ use App\Enums\TeamAB; use App\Enums\TeamSide; use App\Events\Payloads\SetStartedPayload; +use App\Livewire\RosterSubmission; use App\Livewire\TossResultSubmission; use App\Models\Game; use App\Models\GameEvent; @@ -23,6 +24,7 @@ test('submitting toss result creates a toss completed event', function (): void { $game = Game::factory()->create(); + seedRosterableTeams($game); Livewire::test(TossResultSubmission::class, ['gameId' => $game->getKey()]) ->set('teamA', TeamSide::Away->value) @@ -69,6 +71,7 @@ test('submitting toss result fails when toss has already been recorded', function (): void { $game = Game::factory()->create(); + seedRosterableTeams($game); $game->recordToss(TeamSide::Home, TeamAB::TeamA); Livewire::test(TossResultSubmission::class, ['gameId' => $game->getKey()]) @@ -83,6 +86,7 @@ test('submitting toss result records the event against the provided game id', function (): void { $targetGame = Game::factory()->create(); $otherGame = Game::factory()->create(); + seedRosterableTeams($targetGame); Livewire::test(TossResultSubmission::class, ['gameId' => $targetGame->getKey()]) ->set('teamA', TeamSide::Away->value) @@ -96,7 +100,7 @@ }); test('toss modal shows home and away team country codes', function (): void { - $game = Game::factory()->create(); + $game = tiedGameReadyForFifthSetToss(); Livewire::test(TossResultSubmission::class, ['gameId' => $game->getKey()]) ->assertSee($game->homeTeam->country_code) @@ -105,6 +109,16 @@ ->assertDontSee('Away Team'); }); +test('initial toss submission fails until rosters have been submitted', function (): void { + $game = Game::factory()->create(); + + Livewire::test(TossResultSubmission::class, ['gameId' => $game->getKey()]) + ->set('teamA', TeamSide::Away->value) + ->set('serving', TeamSide::Home->value) + ->call('submit') + ->assertHasErrors(['submit']); +}); + test('toss submit button is hidden when toss has already been recorded', function (): void { $game = Game::factory()->create(); $game->recordToss(TeamSide::Home, TeamAB::TeamA); @@ -150,6 +164,24 @@ ->assertDontSee('Save Toss Result'); }); +function seedRosterableTeams(Game $game): void +{ + $homePlayers = Player::factory()->for($game->homeTeam)->count(6)->create(); + $awayPlayers = Player::factory()->for($game->awayTeam)->count(6)->create(); + + $component = Livewire::test(RosterSubmission::class, ['gameId' => $game->getKey()]); + + foreach ($homePlayers as $index => $player) { + $component->set("homeRosterInputs.{$player->getKey()}", (string) ($index + 1)); + } + + foreach ($awayPlayers as $index => $player) { + $component->set("awayRosterInputs.{$player->getKey()}", (string) ($index + 11)); + } + + $component->call('submitRosters')->assertHasNoErrors(); +} + function tiedGameReadyForFifthSetToss(): Game { $game = Game::factory()->create(); From 0b10ca0d27301e1f07b0ff829cb86e7442918c47 Mon Sep 17 00:00:00 2001 From: Giulio Troccoli-Allard Date: Sun, 7 Jun 2026 10:22:06 +0100 Subject: [PATCH 61/63] chore: installed Laravel Pao and updated composer and node packages --- composer.json | 1 + composer.lock | 988 ++++++++++++++++++++++++++++------------------ package-lock.json | 311 +++++++++------ 3 files changed, 776 insertions(+), 524 deletions(-) diff --git a/composer.json b/composer.json index 67470d1..ead0478 100644 --- a/composer.json +++ b/composer.json @@ -25,6 +25,7 @@ "larastan/larastan": "^3.0", "laravel/boost": "^2.0", "laravel/pail": "^1.2.2", + "laravel/pao": "^1.1", "laravel/pint": "^1.24", "laravel/sail": "^1.41", "mockery/mockery": "^1.6", diff --git a/composer.lock b/composer.lock index 711ceb2..bbf5358 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": "e907f6bb60ca7bd9b048784533786dd5", + "content-hash": "b068fd4719ccb6b0320a0e412464a924", "packages": [ { "name": "brick/math", @@ -643,25 +643,26 @@ }, { "name": "guzzlehttp/guzzle", - "version": "7.10.0", + "version": "7.11.0", "source": { "type": "git", "url": "https://github.com/guzzle/guzzle.git", - "reference": "b51ac707cfa420b7bfd4e4d5e510ba8008e822b4" + "reference": "c987f8ce84b8434fa430795eca0f3430663da72b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/guzzle/zipball/b51ac707cfa420b7bfd4e4d5e510ba8008e822b4", - "reference": "b51ac707cfa420b7bfd4e4d5e510ba8008e822b4", + "url": "https://api.github.com/repos/guzzle/guzzle/zipball/c987f8ce84b8434fa430795eca0f3430663da72b", + "reference": "c987f8ce84b8434fa430795eca0f3430663da72b", "shasum": "" }, "require": { "ext-json": "*", - "guzzlehttp/promises": "^2.3", - "guzzlehttp/psr7": "^2.8", + "guzzlehttp/promises": "^2.5", + "guzzlehttp/psr7": "^2.11", "php": "^7.2.5 || ^8.0", "psr/http-client": "^1.0", - "symfony/deprecation-contracts": "^2.2 || ^3.0" + "symfony/deprecation-contracts": "^2.5 || ^3.0", + "symfony/polyfill-php80": "^1.24" }, "provide": { "psr/http-client-implementation": "1.0" @@ -670,8 +671,9 @@ "bamarni/composer-bin-plugin": "^1.8.2", "ext-curl": "*", "guzzle/client-integration-tests": "3.0.2", + "guzzlehttp/test-server": "^0.4", "php-http/message-factory": "^1.1", - "phpunit/phpunit": "^8.5.39 || ^9.6.20", + "phpunit/phpunit": "^8.5.52 || ^9.6.34", "psr/log": "^1.1 || ^2.0 || ^3.0" }, "suggest": { @@ -749,7 +751,7 @@ ], "support": { "issues": "https://github.com/guzzle/guzzle/issues", - "source": "https://github.com/guzzle/guzzle/tree/7.10.0" + "source": "https://github.com/guzzle/guzzle/tree/7.11.0" }, "funding": [ { @@ -765,28 +767,29 @@ "type": "tidelift" } ], - "time": "2025-08-23T22:36:01+00:00" + "time": "2026-06-02T12:40:51+00:00" }, { "name": "guzzlehttp/promises", - "version": "2.3.0", + "version": "2.5.0", "source": { "type": "git", "url": "https://github.com/guzzle/promises.git", - "reference": "481557b130ef3790cf82b713667b43030dc9c957" + "reference": "4360e982f87f5f258bf872d094647791db2f4c8e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/promises/zipball/481557b130ef3790cf82b713667b43030dc9c957", - "reference": "481557b130ef3790cf82b713667b43030dc9c957", + "url": "https://api.github.com/repos/guzzle/promises/zipball/4360e982f87f5f258bf872d094647791db2f4c8e", + "reference": "4360e982f87f5f258bf872d094647791db2f4c8e", "shasum": "" }, "require": { - "php": "^7.2.5 || ^8.0" + "php": "^7.2.5 || ^8.0", + "symfony/deprecation-contracts": "^2.5 || ^3.0" }, "require-dev": { "bamarni/composer-bin-plugin": "^1.8.2", - "phpunit/phpunit": "^8.5.44 || ^9.6.25" + "phpunit/phpunit": "^8.5.52 || ^9.6.34" }, "type": "library", "extra": { @@ -832,7 +835,7 @@ ], "support": { "issues": "https://github.com/guzzle/promises/issues", - "source": "https://github.com/guzzle/promises/tree/2.3.0" + "source": "https://github.com/guzzle/promises/tree/2.5.0" }, "funding": [ { @@ -848,27 +851,29 @@ "type": "tidelift" } ], - "time": "2025-08-22T14:34:08+00:00" + "time": "2026-06-02T12:23:43+00:00" }, { "name": "guzzlehttp/psr7", - "version": "2.9.0", + "version": "2.11.0", "source": { "type": "git", "url": "https://github.com/guzzle/psr7.git", - "reference": "7d0ed42f28e42d61352a7a79de682e5e67fec884" + "reference": "bbb5e61349fa5cb822b3e87842b951088b76b81f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/psr7/zipball/7d0ed42f28e42d61352a7a79de682e5e67fec884", - "reference": "7d0ed42f28e42d61352a7a79de682e5e67fec884", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/bbb5e61349fa5cb822b3e87842b951088b76b81f", + "reference": "bbb5e61349fa5cb822b3e87842b951088b76b81f", "shasum": "" }, "require": { "php": "^7.2.5 || ^8.0", "psr/http-factory": "^1.0", "psr/http-message": "^1.1 || ^2.0", - "ralouphie/getallheaders": "^3.0" + "ralouphie/getallheaders": "^3.0", + "symfony/deprecation-contracts": "^2.5 || ^3.0", + "symfony/polyfill-php80": "^1.24" }, "provide": { "psr/http-factory-implementation": "1.0", @@ -876,9 +881,9 @@ }, "require-dev": { "bamarni/composer-bin-plugin": "^1.8.2", - "http-interop/http-factory-tests": "0.9.0", + "http-interop/http-factory-tests": "1.1.0", "jshttp/mime-db": "1.54.0.1", - "phpunit/phpunit": "^8.5.44 || ^9.6.25" + "phpunit/phpunit": "^8.5.52 || ^9.6.34" }, "suggest": { "laminas/laminas-httphandlerrunner": "Emit PSR-7 responses" @@ -949,7 +954,7 @@ ], "support": { "issues": "https://github.com/guzzle/psr7/issues", - "source": "https://github.com/guzzle/psr7/tree/2.9.0" + "source": "https://github.com/guzzle/psr7/tree/2.11.0" }, "funding": [ { @@ -965,20 +970,20 @@ "type": "tidelift" } ], - "time": "2026-03-10T16:41:02+00:00" + "time": "2026-06-02T12:30:48+00:00" }, { "name": "guzzlehttp/uri-template", - "version": "v1.0.5", + "version": "v1.0.6", "source": { "type": "git", "url": "https://github.com/guzzle/uri-template.git", - "reference": "4f4bbd4e7172148801e76e3decc1e559bdee34e1" + "reference": "eef7f87bab6f204eba3c39224d8075c70c637946" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/uri-template/zipball/4f4bbd4e7172148801e76e3decc1e559bdee34e1", - "reference": "4f4bbd4e7172148801e76e3decc1e559bdee34e1", + "url": "https://api.github.com/repos/guzzle/uri-template/zipball/eef7f87bab6f204eba3c39224d8075c70c637946", + "reference": "eef7f87bab6f204eba3c39224d8075c70c637946", "shasum": "" }, "require": { @@ -987,7 +992,7 @@ }, "require-dev": { "bamarni/composer-bin-plugin": "^1.8.2", - "phpunit/phpunit": "^8.5.44 || ^9.6.25", + "phpunit/phpunit": "^8.5.52 || ^9.6.34", "uri-template/tests": "1.0.0" }, "type": "library", @@ -1035,7 +1040,7 @@ ], "support": { "issues": "https://github.com/guzzle/uri-template/issues", - "source": "https://github.com/guzzle/uri-template/tree/v1.0.5" + "source": "https://github.com/guzzle/uri-template/tree/v1.0.6" }, "funding": [ { @@ -1051,20 +1056,20 @@ "type": "tidelift" } ], - "time": "2025-08-22T14:27:06+00:00" + "time": "2026-05-23T22:00:21+00:00" }, { "name": "laravel/framework", - "version": "v13.8.0", + "version": "v13.14.0", "source": { "type": "git", "url": "https://github.com/laravel/framework.git", - "reference": "e7db333a025a1e93ebca7744953069d7719f4bcf" + "reference": "e60b1c817a9ef7da319e4007de6cfda5301a58c0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/framework/zipball/e7db333a025a1e93ebca7744953069d7719f4bcf", - "reference": "e7db333a025a1e93ebca7744953069d7719f4bcf", + "url": "https://api.github.com/repos/laravel/framework/zipball/e60b1c817a9ef7da319e4007de6cfda5301a58c0", + "reference": "e60b1c817a9ef7da319e4007de6cfda5301a58c0", "shasum": "" }, "require": { @@ -1168,7 +1173,7 @@ "aws/aws-sdk-php": "^3.322.9", "ext-gmp": "*", "fakerphp/faker": "^1.24", - "guzzlehttp/psr7": "^2.4", + "guzzlehttp/psr7": "^2.9", "laravel/pint": "^1.18", "league/flysystem-aws-s3-v3": "^3.25.1", "league/flysystem-ftp": "^3.25.1", @@ -1275,20 +1280,20 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2026-05-05T21:01:14+00:00" + "time": "2026-06-04T18:46:35+00:00" }, { "name": "laravel/prompts", - "version": "v0.3.17", + "version": "v0.3.18", "source": { "type": "git", "url": "https://github.com/laravel/prompts.git", - "reference": "6a82ac19a28b916ae0885828795dbd4c59d9a818" + "reference": "a19af51bb144bf87f08397921fa619f85c7d4e72" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/prompts/zipball/6a82ac19a28b916ae0885828795dbd4c59d9a818", - "reference": "6a82ac19a28b916ae0885828795dbd4c59d9a818", + "url": "https://api.github.com/repos/laravel/prompts/zipball/a19af51bb144bf87f08397921fa619f85c7d4e72", + "reference": "a19af51bb144bf87f08397921fa619f85c7d4e72", "shasum": "" }, "require": { @@ -1332,9 +1337,9 @@ "description": "Add beautiful and user-friendly forms to your command-line applications.", "support": { "issues": "https://github.com/laravel/prompts/issues", - "source": "https://github.com/laravel/prompts/tree/v0.3.17" + "source": "https://github.com/laravel/prompts/tree/v0.3.18" }, - "time": "2026-04-20T16:07:33+00:00" + "time": "2026-05-19T00:47:18+00:00" }, { "name": "laravel/serializable-closure", @@ -1657,16 +1662,16 @@ }, { "name": "league/flysystem", - "version": "3.33.0", + "version": "3.34.0", "source": { "type": "git", "url": "https://github.com/thephpleague/flysystem.git", - "reference": "570b8871e0ce693764434b29154c54b434905350" + "reference": "2daaac3b0d4c83ea7ed5d8586e786f5d00f3540e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/570b8871e0ce693764434b29154c54b434905350", - "reference": "570b8871e0ce693764434b29154c54b434905350", + "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/2daaac3b0d4c83ea7ed5d8586e786f5d00f3540e", + "reference": "2daaac3b0d4c83ea7ed5d8586e786f5d00f3540e", "shasum": "" }, "require": { @@ -1734,9 +1739,9 @@ ], "support": { "issues": "https://github.com/thephpleague/flysystem/issues", - "source": "https://github.com/thephpleague/flysystem/tree/3.33.0" + "source": "https://github.com/thephpleague/flysystem/tree/3.34.0" }, - "time": "2026-03-25T07:59:30+00:00" + "time": "2026-05-14T10:28:08+00:00" }, { "name": "league/flysystem-local", @@ -2166,16 +2171,16 @@ }, { "name": "livewire/livewire", - "version": "v4.3.0", + "version": "v4.3.1", "source": { "type": "git", "url": "https://github.com/livewire/livewire.git", - "reference": "19ebb1ee4d057debceccf70ff01950e6a6114edc" + "reference": "6a9dd03f45a4b200abfd0ff644745b23fa7baaaa" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/livewire/livewire/zipball/19ebb1ee4d057debceccf70ff01950e6a6114edc", - "reference": "19ebb1ee4d057debceccf70ff01950e6a6114edc", + "url": "https://api.github.com/repos/livewire/livewire/zipball/6a9dd03f45a4b200abfd0ff644745b23fa7baaaa", + "reference": "6a9dd03f45a4b200abfd0ff644745b23fa7baaaa", "shasum": "" }, "require": { @@ -2230,7 +2235,7 @@ "description": "A front-end framework for Laravel.", "support": { "issues": "https://github.com/livewire/livewire/issues", - "source": "https://github.com/livewire/livewire/tree/v4.3.0" + "source": "https://github.com/livewire/livewire/tree/v4.3.1" }, "funding": [ { @@ -2238,7 +2243,7 @@ "type": "github" } ], - "time": "2026-05-01T00:46:07+00:00" + "time": "2026-06-02T08:58:52+00:00" }, { "name": "monolog/monolog", @@ -2517,16 +2522,16 @@ }, { "name": "nette/utils", - "version": "v4.1.3", + "version": "v4.1.4", "source": { "type": "git", "url": "https://github.com/nette/utils.git", - "reference": "bb3ea637e3d131d72acc033cfc2746ee893349fe" + "reference": "7da6c396d7ebe142bc857c20479d5e70a5e1aac7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nette/utils/zipball/bb3ea637e3d131d72acc033cfc2746ee893349fe", - "reference": "bb3ea637e3d131d72acc033cfc2746ee893349fe", + "url": "https://api.github.com/repos/nette/utils/zipball/7da6c396d7ebe142bc857c20479d5e70a5e1aac7", + "reference": "7da6c396d7ebe142bc857c20479d5e70a5e1aac7", "shasum": "" }, "require": { @@ -2602,9 +2607,9 @@ ], "support": { "issues": "https://github.com/nette/utils/issues", - "source": "https://github.com/nette/utils/tree/v4.1.3" + "source": "https://github.com/nette/utils/tree/v4.1.4" }, - "time": "2026-02-13T03:05:33+00:00" + "time": "2026-05-11T20:49:54+00:00" }, { "name": "nikic/php-parser", @@ -3240,16 +3245,16 @@ }, { "name": "psy/psysh", - "version": "v0.12.22", + "version": "v0.12.23", "source": { "type": "git", "url": "https://github.com/bobthecow/psysh.git", - "reference": "3be75d5b9244936dd4ac62ade2bfb004d13acf0f" + "reference": "4dcc0f08047d52bbde475eda481146fd8e27e1a4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/bobthecow/psysh/zipball/3be75d5b9244936dd4ac62ade2bfb004d13acf0f", - "reference": "3be75d5b9244936dd4ac62ade2bfb004d13acf0f", + "url": "https://api.github.com/repos/bobthecow/psysh/zipball/4dcc0f08047d52bbde475eda481146fd8e27e1a4", + "reference": "4dcc0f08047d52bbde475eda481146fd8e27e1a4", "shasum": "" }, "require": { @@ -3313,9 +3318,9 @@ ], "support": { "issues": "https://github.com/bobthecow/psysh/issues", - "source": "https://github.com/bobthecow/psysh/tree/v0.12.22" + "source": "https://github.com/bobthecow/psysh/tree/v0.12.23" }, - "time": "2026-03-22T23:03:24+00:00" + "time": "2026-05-23T13:41:31+00:00" }, { "name": "ralouphie/getallheaders", @@ -3517,16 +3522,16 @@ }, { "name": "setasign/fpdf", - "version": "1.8.6", + "version": "1.9.0", "source": { "type": "git", "url": "https://github.com/Setasign/FPDF.git", - "reference": "0838e0ee4925716fcbbc50ad9e1799b5edfae0a0" + "reference": "051b70e4c57dedc88df41b1eff1c62894e5f9ed0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Setasign/FPDF/zipball/0838e0ee4925716fcbbc50ad9e1799b5edfae0a0", - "reference": "0838e0ee4925716fcbbc50ad9e1799b5edfae0a0", + "url": "https://api.github.com/repos/Setasign/FPDF/zipball/051b70e4c57dedc88df41b1eff1c62894e5f9ed0", + "reference": "051b70e4c57dedc88df41b1eff1c62894e5f9ed0", "shasum": "" }, "require": { @@ -3557,22 +3562,22 @@ "pdf" ], "support": { - "source": "https://github.com/Setasign/FPDF/tree/1.8.6" + "source": "https://github.com/Setasign/FPDF/tree/1.9.0" }, - "time": "2023-06-26T14:44:25+00:00" + "time": "2026-05-31T14:25:29+00:00" }, { "name": "setasign/fpdi", - "version": "v2.6.6", + "version": "v2.6.7", "source": { "type": "git", "url": "https://github.com/Setasign/FPDI.git", - "reference": "de0cf35911be3e9ea63b48e0f307883b1c7c48ac" + "reference": "388c51e69982a3fc16698710b763e8107a49f510" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Setasign/FPDI/zipball/de0cf35911be3e9ea63b48e0f307883b1c7c48ac", - "reference": "de0cf35911be3e9ea63b48e0f307883b1c7c48ac", + "url": "https://api.github.com/repos/Setasign/FPDI/zipball/388c51e69982a3fc16698710b763e8107a49f510", + "reference": "388c51e69982a3fc16698710b763e8107a49f510", "shasum": "" }, "require": { @@ -3623,7 +3628,7 @@ ], "support": { "issues": "https://github.com/Setasign/FPDI/issues", - "source": "https://github.com/Setasign/FPDI/tree/v2.6.6" + "source": "https://github.com/Setasign/FPDI/tree/v2.6.7" }, "funding": [ { @@ -3631,24 +3636,24 @@ "type": "tidelift" } ], - "time": "2026-03-13T08:38:20+00:00" + "time": "2026-05-13T10:16:22+00:00" }, { "name": "symfony/clock", - "version": "v8.0.8", + "version": "v8.1.0", "source": { "type": "git", "url": "https://github.com/symfony/clock.git", - "reference": "b55a638b189a6faa875e0ccdb00908fb87af95b3" + "reference": "701ef4de9705d6c32292ebee5e8044094a09fbf6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/clock/zipball/b55a638b189a6faa875e0ccdb00908fb87af95b3", - "reference": "b55a638b189a6faa875e0ccdb00908fb87af95b3", + "url": "https://api.github.com/repos/symfony/clock/zipball/701ef4de9705d6c32292ebee5e8044094a09fbf6", + "reference": "701ef4de9705d6c32292ebee5e8044094a09fbf6", "shasum": "" }, "require": { - "php": ">=8.4", + "php": ">=8.4.1", "psr/clock": "^1.0" }, "provide": { @@ -3688,7 +3693,7 @@ "time" ], "support": { - "source": "https://github.com/symfony/clock/tree/v8.0.8" + "source": "https://github.com/symfony/clock/tree/v8.1.0" }, "funding": [ { @@ -3708,27 +3713,33 @@ "type": "tidelift" } ], - "time": "2026-03-30T15:14:47+00:00" + "time": "2026-05-29T05:06:50+00:00" }, { "name": "symfony/console", - "version": "v8.0.9", + "version": "v8.1.0", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "7113778e2e91f4709cb3194a75dfa9c0d028d94d" + "reference": "f5a856c6ecb56b3c21ed94a5b7bf940d857d110a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/7113778e2e91f4709cb3194a75dfa9c0d028d94d", - "reference": "7113778e2e91f4709cb3194a75dfa9c0d028d94d", + "url": "https://api.github.com/repos/symfony/console/zipball/f5a856c6ecb56b3c21ed94a5b7bf940d857d110a", + "reference": "f5a856c6ecb56b3c21ed94a5b7bf940d857d110a", "shasum": "" }, "require": { - "php": ">=8.4", + "php": ">=8.4.1", + "symfony/deprecation-contracts": "^2.5|^3", "symfony/polyfill-mbstring": "^1.0", + "symfony/polyfill-php85": "^1.32", "symfony/service-contracts": "^2.5|^3", - "symfony/string": "^7.4|^8.0" + "symfony/string": "^7.4.6|^8.0.6" + }, + "conflict": { + "symfony/dependency-injection": "<8.1", + "symfony/event-dispatcher": "<8.1" }, "provide": { "psr/log-implementation": "1.0|2.0|3.0" @@ -3736,14 +3747,18 @@ "require-dev": { "psr/log": "^1|^2|^3", "symfony/config": "^7.4|^8.0", - "symfony/dependency-injection": "^7.4|^8.0", - "symfony/event-dispatcher": "^7.4|^8.0", + "symfony/dependency-injection": "^8.1", + "symfony/event-dispatcher": "^8.1", + "symfony/filesystem": "^7.4|^8.0", "symfony/http-foundation": "^7.4|^8.0", "symfony/http-kernel": "^7.4|^8.0", "symfony/lock": "^7.4|^8.0", "symfony/messenger": "^7.4|^8.0", + "symfony/mime": "^7.4|^8.0", "symfony/process": "^7.4|^8.0", "symfony/stopwatch": "^7.4|^8.0", + "symfony/uid": "^7.4|^8.0", + "symfony/validator": "^7.4|^8.0", "symfony/var-dumper": "^7.4|^8.0" }, "type": "library", @@ -3778,7 +3793,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v8.0.9" + "source": "https://github.com/symfony/console/tree/v8.1.0" }, "funding": [ { @@ -3798,24 +3813,24 @@ "type": "tidelift" } ], - "time": "2026-04-29T15:02:55+00:00" + "time": "2026-05-29T05:06:50+00:00" }, { "name": "symfony/css-selector", - "version": "v8.0.9", + "version": "v8.1.0", "source": { "type": "git", "url": "https://github.com/symfony/css-selector.git", - "reference": "3665cfade90565430909b906394c73c8739e57d0" + "reference": "dc0e2be45c9b5588c82414f02ac574b4b986abcd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/css-selector/zipball/3665cfade90565430909b906394c73c8739e57d0", - "reference": "3665cfade90565430909b906394c73c8739e57d0", + "url": "https://api.github.com/repos/symfony/css-selector/zipball/dc0e2be45c9b5588c82414f02ac574b4b986abcd", + "reference": "dc0e2be45c9b5588c82414f02ac574b4b986abcd", "shasum": "" }, "require": { - "php": ">=8.4" + "php": ">=8.4.1" }, "type": "library", "autoload": { @@ -3847,7 +3862,7 @@ "description": "Converts CSS selectors to XPath expressions", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/css-selector/tree/v8.0.9" + "source": "https://github.com/symfony/css-selector/tree/v8.1.0" }, "funding": [ { @@ -3867,7 +3882,7 @@ "type": "tidelift" } ], - "time": "2026-04-18T13:51:42+00:00" + "time": "2026-05-29T05:06:50+00:00" }, { "name": "symfony/deprecation-contracts", @@ -3942,20 +3957,20 @@ }, { "name": "symfony/error-handler", - "version": "v8.0.8", + "version": "v8.1.0", "source": { "type": "git", "url": "https://github.com/symfony/error-handler.git", - "reference": "c1119fe8dcfc3825ec74ec061b96ef0c8f281517" + "reference": "d8aeb1abd3fef84795567850d3a567bdb5945ee5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/error-handler/zipball/c1119fe8dcfc3825ec74ec061b96ef0c8f281517", - "reference": "c1119fe8dcfc3825ec74ec061b96ef0c8f281517", + "url": "https://api.github.com/repos/symfony/error-handler/zipball/d8aeb1abd3fef84795567850d3a567bdb5945ee5", + "reference": "d8aeb1abd3fef84795567850d3a567bdb5945ee5", "shasum": "" }, "require": { - "php": ">=8.4", + "php": ">=8.4.1", "psr/log": "^1|^2|^3", "symfony/polyfill-php85": "^1.32", "symfony/var-dumper": "^7.4|^8.0" @@ -3999,7 +4014,7 @@ "description": "Provides tools to manage errors and ease debugging PHP code", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/error-handler/tree/v8.0.8" + "source": "https://github.com/symfony/error-handler/tree/v8.1.0" }, "funding": [ { @@ -4019,24 +4034,25 @@ "type": "tidelift" } ], - "time": "2026-03-30T15:14:47+00:00" + "time": "2026-05-29T05:06:50+00:00" }, { "name": "symfony/event-dispatcher", - "version": "v8.0.9", + "version": "v8.1.0", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher.git", - "reference": "0c3c1a17604c4dbbec4b93fe162c538482096e1f" + "reference": "f249ae3f680958b6f1f9dd76e5747cf0695b4102" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/0c3c1a17604c4dbbec4b93fe162c538482096e1f", - "reference": "0c3c1a17604c4dbbec4b93fe162c538482096e1f", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/f249ae3f680958b6f1f9dd76e5747cf0695b4102", + "reference": "f249ae3f680958b6f1f9dd76e5747cf0695b4102", "shasum": "" }, "require": { - "php": ">=8.4", + "php": ">=8.4.1", + "symfony/deprecation-contracts": "^2.5|^3", "symfony/event-dispatcher-contracts": "^2.5|^3" }, "conflict": { @@ -4084,7 +4100,7 @@ "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/event-dispatcher/tree/v8.0.9" + "source": "https://github.com/symfony/event-dispatcher/tree/v8.1.0" }, "funding": [ { @@ -4104,7 +4120,7 @@ "type": "tidelift" } ], - "time": "2026-04-18T13:51:42+00:00" + "time": "2026-05-29T05:06:50+00:00" }, { "name": "symfony/event-dispatcher-contracts", @@ -4188,20 +4204,20 @@ }, { "name": "symfony/finder", - "version": "v8.0.8", + "version": "v8.1.0", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "8da41214757b87d97f181e3d14a4179286151007" + "reference": "58d2e767a66052c1487356f953445634a8194c64" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/8da41214757b87d97f181e3d14a4179286151007", - "reference": "8da41214757b87d97f181e3d14a4179286151007", + "url": "https://api.github.com/repos/symfony/finder/zipball/58d2e767a66052c1487356f953445634a8194c64", + "reference": "58d2e767a66052c1487356f953445634a8194c64", "shasum": "" }, "require": { - "php": ">=8.4" + "php": ">=8.4.1" }, "require-dev": { "symfony/filesystem": "^7.4|^8.0" @@ -4232,7 +4248,7 @@ "description": "Finds files and directories via an intuitive fluent interface", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/finder/tree/v8.0.8" + "source": "https://github.com/symfony/finder/tree/v8.1.0" }, "funding": [ { @@ -4252,24 +4268,25 @@ "type": "tidelift" } ], - "time": "2026-03-30T15:14:47+00:00" + "time": "2026-05-29T05:06:50+00:00" }, { "name": "symfony/http-foundation", - "version": "v8.0.8", + "version": "v8.1.0", "source": { "type": "git", "url": "https://github.com/symfony/http-foundation.git", - "reference": "02656f7ebeae5c155d659e946f6b3a33df24051b" + "reference": "af11474600f06718086c2cda4fa6fa8d0a672e7e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-foundation/zipball/02656f7ebeae5c155d659e946f6b3a33df24051b", - "reference": "02656f7ebeae5c155d659e946f6b3a33df24051b", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/af11474600f06718086c2cda4fa6fa8d0a672e7e", + "reference": "af11474600f06718086c2cda4fa6fa8d0a672e7e", "shasum": "" }, "require": { - "php": ">=8.4", + "php": ">=8.4.1", + "symfony/deprecation-contracts": "^2.5|^3", "symfony/polyfill-mbstring": "^1.1" }, "conflict": { @@ -4312,7 +4329,7 @@ "description": "Defines an object-oriented layer for the HTTP specification", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-foundation/tree/v8.0.8" + "source": "https://github.com/symfony/http-foundation/tree/v8.1.0" }, "funding": [ { @@ -4332,34 +4349,38 @@ "type": "tidelift" } ], - "time": "2026-03-30T15:14:47+00:00" + "time": "2026-05-29T05:06:50+00:00" }, { "name": "symfony/http-kernel", - "version": "v8.0.10", + "version": "v8.1.0", "source": { "type": "git", "url": "https://github.com/symfony/http-kernel.git", - "reference": "fb3f65b3d4ca2dad31c80d323819a762ca31d6ac" + "reference": "cefeb37c82eed3e0c42fa25ba64cd3a908d90f39" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-kernel/zipball/fb3f65b3d4ca2dad31c80d323819a762ca31d6ac", - "reference": "fb3f65b3d4ca2dad31c80d323819a762ca31d6ac", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/cefeb37c82eed3e0c42fa25ba64cd3a908d90f39", + "reference": "cefeb37c82eed3e0c42fa25ba64cd3a908d90f39", "shasum": "" }, "require": { - "php": ">=8.4", + "php": ">=8.4.1", "psr/log": "^1|^2|^3", + "symfony/deprecation-contracts": "^2.5|^3", "symfony/error-handler": "^7.4|^8.0", "symfony/event-dispatcher": "^7.4|^8.0", "symfony/http-foundation": "^7.4|^8.0", "symfony/polyfill-ctype": "^1.8" }, "conflict": { + "symfony/dependency-injection": "<8.1", "symfony/flex": "<2.10", "symfony/http-client-contracts": "<2.5", "symfony/translation-contracts": "<2.5", + "symfony/var-dumper": "<8.1", + "symfony/web-profiler-bundle": "<8.1", "twig/twig": "<3.21" }, "provide": { @@ -4372,13 +4393,14 @@ "symfony/config": "^7.4|^8.0", "symfony/console": "^7.4|^8.0", "symfony/css-selector": "^7.4|^8.0", - "symfony/dependency-injection": "^7.4|^8.0", + "symfony/dependency-injection": "^8.1", "symfony/dom-crawler": "^7.4|^8.0", "symfony/expression-language": "^7.4|^8.0", "symfony/finder": "^7.4|^8.0", "symfony/http-client-contracts": "^2.5|^3", "symfony/process": "^7.4|^8.0", "symfony/property-access": "^7.4|^8.0", + "symfony/rate-limiter": "^7.4|^8.0", "symfony/routing": "^7.4|^8.0", "symfony/serializer": "^7.4|^8.0", "symfony/stopwatch": "^7.4|^8.0", @@ -4386,7 +4408,7 @@ "symfony/translation-contracts": "^2.5|^3", "symfony/uid": "^7.4|^8.0", "symfony/validator": "^7.4|^8.0", - "symfony/var-dumper": "^7.4|^8.0", + "symfony/var-dumper": "^8.1", "symfony/var-exporter": "^7.4|^8.0", "twig/twig": "^3.21" }, @@ -4416,7 +4438,7 @@ "description": "Provides a structured process for converting a Request into a Response", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-kernel/tree/v8.0.10" + "source": "https://github.com/symfony/http-kernel/tree/v8.1.0" }, "funding": [ { @@ -4436,25 +4458,25 @@ "type": "tidelift" } ], - "time": "2026-05-06T12:27:31+00:00" + "time": "2026-05-29T08:46:08+00:00" }, { "name": "symfony/mailer", - "version": "v8.0.8", + "version": "v8.1.0", "source": { "type": "git", "url": "https://github.com/symfony/mailer.git", - "reference": "ca5f6edaf8780ece814404b58a4482b22b509c56" + "reference": "9418d772df3a03a142e3bc06f602adb2b8724877" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mailer/zipball/ca5f6edaf8780ece814404b58a4482b22b509c56", - "reference": "ca5f6edaf8780ece814404b58a4482b22b509c56", + "url": "https://api.github.com/repos/symfony/mailer/zipball/9418d772df3a03a142e3bc06f602adb2b8724877", + "reference": "9418d772df3a03a142e3bc06f602adb2b8724877", "shasum": "" }, "require": { "egulias/email-validator": "^2.1.10|^3|^4", - "php": ">=8.4", + "php": ">=8.4.1", "psr/event-dispatcher": "^1", "psr/log": "^1|^2|^3", "symfony/event-dispatcher": "^7.4|^8.0", @@ -4496,7 +4518,7 @@ "description": "Helps sending emails", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/mailer/tree/v8.0.8" + "source": "https://github.com/symfony/mailer/tree/v8.1.0" }, "funding": [ { @@ -4516,24 +4538,24 @@ "type": "tidelift" } ], - "time": "2026-03-30T15:14:47+00:00" + "time": "2026-05-29T05:06:50+00:00" }, { "name": "symfony/mime", - "version": "v8.0.9", + "version": "v8.1.0", "source": { "type": "git", "url": "https://github.com/symfony/mime.git", - "reference": "a9fcb293650c054b62a5b406f4e92e7b711ea333" + "reference": "b164ae7e3f7915aacfe9ee155f2f358502440664" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mime/zipball/a9fcb293650c054b62a5b406f4e92e7b711ea333", - "reference": "a9fcb293650c054b62a5b406f4e92e7b711ea333", + "url": "https://api.github.com/repos/symfony/mime/zipball/b164ae7e3f7915aacfe9ee155f2f358502440664", + "reference": "b164ae7e3f7915aacfe9ee155f2f358502440664", "shasum": "" }, "require": { - "php": ">=8.4", + "php": ">=8.4.1", "symfony/polyfill-intl-idn": "^1.10", "symfony/polyfill-mbstring": "^1.0" }, @@ -4582,7 +4604,7 @@ "mime-type" ], "support": { - "source": "https://github.com/symfony/mime/tree/v8.0.9" + "source": "https://github.com/symfony/mime/tree/v8.1.0" }, "funding": [ { @@ -4602,7 +4624,7 @@ "type": "tidelift" } ], - "time": "2026-04-29T15:02:55+00:00" + "time": "2026-05-29T05:06:50+00:00" }, { "name": "symfony/polyfill-ctype", @@ -4689,16 +4711,16 @@ }, { "name": "symfony/polyfill-intl-grapheme", - "version": "v1.37.0", + "version": "v1.38.1", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-grapheme.git", - "reference": "4864388bfbd3001ce88e234fab652acd91fdc57e" + "reference": "e9247d281d694a5120554d9afaf54e070e88a603" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/4864388bfbd3001ce88e234fab652acd91fdc57e", - "reference": "4864388bfbd3001ce88e234fab652acd91fdc57e", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/e9247d281d694a5120554d9afaf54e070e88a603", + "reference": "e9247d281d694a5120554d9afaf54e070e88a603", "shasum": "" }, "require": { @@ -4747,7 +4769,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.37.0" + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.38.1" }, "funding": [ { @@ -4767,20 +4789,20 @@ "type": "tidelift" } ], - "time": "2026-04-26T13:13:48+00:00" + "time": "2026-05-26T05:58:03+00:00" }, { "name": "symfony/polyfill-intl-idn", - "version": "v1.37.0", + "version": "v1.38.1", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-idn.git", - "reference": "9614ac4d8061dc257ecc64cba1b140873dce8ad3" + "reference": "dc21118016c039a66235cf93d96b435ffb282412" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/9614ac4d8061dc257ecc64cba1b140873dce8ad3", - "reference": "9614ac4d8061dc257ecc64cba1b140873dce8ad3", + "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/dc21118016c039a66235cf93d96b435ffb282412", + "reference": "dc21118016c039a66235cf93d96b435ffb282412", "shasum": "" }, "require": { @@ -4834,7 +4856,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.37.0" + "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.38.1" }, "funding": [ { @@ -4854,20 +4876,20 @@ "type": "tidelift" } ], - "time": "2024-09-10T14:38:51+00:00" + "time": "2026-05-25T15:22:23+00:00" }, { "name": "symfony/polyfill-intl-normalizer", - "version": "v1.37.0", + "version": "v1.38.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-normalizer.git", - "reference": "3833d7255cc303546435cb650316bff708a1c75c" + "reference": "2d446c214bdbe5b71bde5011b060a05fece3ae6b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/3833d7255cc303546435cb650316bff708a1c75c", - "reference": "3833d7255cc303546435cb650316bff708a1c75c", + "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/2d446c214bdbe5b71bde5011b060a05fece3ae6b", + "reference": "2d446c214bdbe5b71bde5011b060a05fece3ae6b", "shasum": "" }, "require": { @@ -4919,7 +4941,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.37.0" + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.38.0" }, "funding": [ { @@ -4939,20 +4961,20 @@ "type": "tidelift" } ], - "time": "2024-09-09T11:45:10+00:00" + "time": "2026-05-25T13:48:31+00:00" }, { "name": "symfony/polyfill-mbstring", - "version": "v1.37.0", + "version": "v1.38.1", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-mbstring.git", - "reference": "6a21eb99c6973357967f6ce3708cd55a6bec6315" + "reference": "14c5439eec4ccff081ac14eca2dc57feb2a66d92" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/6a21eb99c6973357967f6ce3708cd55a6bec6315", - "reference": "6a21eb99c6973357967f6ce3708cd55a6bec6315", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/14c5439eec4ccff081ac14eca2dc57feb2a66d92", + "reference": "14c5439eec4ccff081ac14eca2dc57feb2a66d92", "shasum": "" }, "require": { @@ -5004,7 +5026,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.37.0" + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.38.1" }, "funding": [ { @@ -5024,7 +5046,7 @@ "type": "tidelift" } ], - "time": "2026-04-10T17:25:58+00:00" + "time": "2026-05-26T12:51:13+00:00" }, { "name": "symfony/polyfill-php80", @@ -5112,16 +5134,16 @@ }, { "name": "symfony/polyfill-php84", - "version": "v1.37.0", + "version": "v1.38.1", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php84.git", - "reference": "88486db2c389b290bf87ff1de7ebc1e13e42bb06" + "reference": "f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/88486db2c389b290bf87ff1de7ebc1e13e42bb06", - "reference": "88486db2c389b290bf87ff1de7ebc1e13e42bb06", + "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa", + "reference": "f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa", "shasum": "" }, "require": { @@ -5168,7 +5190,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php84/tree/v1.37.0" + "source": "https://github.com/symfony/polyfill-php84/tree/v1.38.1" }, "funding": [ { @@ -5188,20 +5210,20 @@ "type": "tidelift" } ], - "time": "2026-04-10T18:47:49+00:00" + "time": "2026-05-26T12:51:13+00:00" }, { "name": "symfony/polyfill-php85", - "version": "v1.37.0", + "version": "v1.38.1", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php85.git", - "reference": "fcfa4973a9917cef23f2e38774da74a2b7d115ee" + "reference": "ba2ba04f3352cfa2dcbbcb90aee13ed967f505b1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/fcfa4973a9917cef23f2e38774da74a2b7d115ee", - "reference": "fcfa4973a9917cef23f2e38774da74a2b7d115ee", + "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/ba2ba04f3352cfa2dcbbcb90aee13ed967f505b1", + "reference": "ba2ba04f3352cfa2dcbbcb90aee13ed967f505b1", "shasum": "" }, "require": { @@ -5248,7 +5270,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php85/tree/v1.37.0" + "source": "https://github.com/symfony/polyfill-php85/tree/v1.38.1" }, "funding": [ { @@ -5268,20 +5290,20 @@ "type": "tidelift" } ], - "time": "2026-04-26T13:10:57+00:00" + "time": "2026-05-26T02:25:22+00:00" }, { "name": "symfony/polyfill-php86", - "version": "v1.37.0", + "version": "v1.38.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php86.git", - "reference": "33d8fc5a705481e21fe3a81212b26f9b1f61749c" + "reference": "fcec68d64f46dc84e1f6ffcf2c6dda40ff3143ad" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php86/zipball/33d8fc5a705481e21fe3a81212b26f9b1f61749c", - "reference": "33d8fc5a705481e21fe3a81212b26f9b1f61749c", + "url": "https://api.github.com/repos/symfony/polyfill-php86/zipball/fcec68d64f46dc84e1f6ffcf2c6dda40ff3143ad", + "reference": "fcec68d64f46dc84e1f6ffcf2c6dda40ff3143ad", "shasum": "" }, "require": { @@ -5328,7 +5350,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php86/tree/v1.37.0" + "source": "https://github.com/symfony/polyfill-php86/tree/v1.38.0" }, "funding": [ { @@ -5348,7 +5370,7 @@ "type": "tidelift" } ], - "time": "2026-04-26T13:13:48+00:00" + "time": "2026-05-25T11:52:35+00:00" }, { "name": "symfony/polyfill-uuid", @@ -5435,20 +5457,20 @@ }, { "name": "symfony/process", - "version": "v8.0.8", + "version": "v8.1.0", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "cb8939aff03470d1a9d1d1b66d08c6fa71b3bbdc" + "reference": "c4a9e58f235a6bf7f97ffbfedae2687353ac79e5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/cb8939aff03470d1a9d1d1b66d08c6fa71b3bbdc", - "reference": "cb8939aff03470d1a9d1d1b66d08c6fa71b3bbdc", + "url": "https://api.github.com/repos/symfony/process/zipball/c4a9e58f235a6bf7f97ffbfedae2687353ac79e5", + "reference": "c4a9e58f235a6bf7f97ffbfedae2687353ac79e5", "shasum": "" }, "require": { - "php": ">=8.4" + "php": ">=8.4.1" }, "type": "library", "autoload": { @@ -5476,7 +5498,7 @@ "description": "Executes commands in sub-processes", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/process/tree/v8.0.8" + "source": "https://github.com/symfony/process/tree/v8.1.0" }, "funding": [ { @@ -5496,24 +5518,24 @@ "type": "tidelift" } ], - "time": "2026-03-30T15:14:47+00:00" + "time": "2026-05-29T05:06:50+00:00" }, { "name": "symfony/routing", - "version": "v8.0.9", + "version": "v8.1.0", "source": { "type": "git", "url": "https://github.com/symfony/routing.git", - "reference": "75d1bd8e5da3424e4db2fc3ff0222cb4d0c73038" + "reference": "fe0bfec72c8a806109fb9c3a5f2b898fe0c76eb3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/routing/zipball/75d1bd8e5da3424e4db2fc3ff0222cb4d0c73038", - "reference": "75d1bd8e5da3424e4db2fc3ff0222cb4d0c73038", + "url": "https://api.github.com/repos/symfony/routing/zipball/fe0bfec72c8a806109fb9c3a5f2b898fe0c76eb3", + "reference": "fe0bfec72c8a806109fb9c3a5f2b898fe0c76eb3", "shasum": "" }, "require": { - "php": ">=8.4", + "php": ">=8.4.1", "symfony/deprecation-contracts": "^2.5|^3" }, "require-dev": { @@ -5556,7 +5578,7 @@ "url" ], "support": { - "source": "https://github.com/symfony/routing/tree/v8.0.9" + "source": "https://github.com/symfony/routing/tree/v8.1.0" }, "funding": [ { @@ -5576,7 +5598,7 @@ "type": "tidelift" } ], - "time": "2026-04-29T15:02:55+00:00" + "time": "2026-05-29T05:06:50+00:00" }, { "name": "symfony/service-contracts", @@ -5667,20 +5689,20 @@ }, { "name": "symfony/string", - "version": "v8.0.8", + "version": "v8.1.0", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "ae9488f874d7603f9d2dfbf120203882b645d963" + "reference": "afd5944f4005862d961efb85c8bbd5c523c4e3c9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/ae9488f874d7603f9d2dfbf120203882b645d963", - "reference": "ae9488f874d7603f9d2dfbf120203882b645d963", + "url": "https://api.github.com/repos/symfony/string/zipball/afd5944f4005862d961efb85c8bbd5c523c4e3c9", + "reference": "afd5944f4005862d961efb85c8bbd5c523c4e3c9", "shasum": "" }, "require": { - "php": ">=8.4", + "php": ">=8.4.1", "symfony/polyfill-ctype": "^1.8", "symfony/polyfill-intl-grapheme": "^1.33", "symfony/polyfill-intl-normalizer": "^1.0", @@ -5733,7 +5755,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v8.0.8" + "source": "https://github.com/symfony/string/tree/v8.1.0" }, "funding": [ { @@ -5753,24 +5775,24 @@ "type": "tidelift" } ], - "time": "2026-03-30T15:14:47+00:00" + "time": "2026-05-29T05:06:50+00:00" }, { "name": "symfony/translation", - "version": "v8.0.10", + "version": "v8.1.0", "source": { "type": "git", "url": "https://github.com/symfony/translation.git", - "reference": "f63e9342e12646a57c91ef8a366a4f9d8e557b67" + "reference": "b2bd012ca28c4acae830ee1206a5b6e35dd99693" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/translation/zipball/f63e9342e12646a57c91ef8a366a4f9d8e557b67", - "reference": "f63e9342e12646a57c91ef8a366a4f9d8e557b67", + "url": "https://api.github.com/repos/symfony/translation/zipball/b2bd012ca28c4acae830ee1206a5b6e35dd99693", + "reference": "b2bd012ca28c4acae830ee1206a5b6e35dd99693", "shasum": "" }, "require": { - "php": ">=8.4", + "php": ">=8.4.1", "symfony/polyfill-mbstring": "^1.0", "symfony/translation-contracts": "^3.6.1" }, @@ -5826,7 +5848,7 @@ "description": "Provides tools to internationalize your application", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/translation/tree/v8.0.10" + "source": "https://github.com/symfony/translation/tree/v8.1.0" }, "funding": [ { @@ -5846,7 +5868,7 @@ "type": "tidelift" } ], - "time": "2026-05-06T11:30:54+00:00" + "time": "2026-05-29T05:06:50+00:00" }, { "name": "symfony/translation-contracts", @@ -5932,20 +5954,20 @@ }, { "name": "symfony/uid", - "version": "v8.0.9", + "version": "v8.1.0", "source": { "type": "git", "url": "https://github.com/symfony/uid.git", - "reference": "4d9d6510bbe88ebb4608b7200d18606cdf80825c" + "reference": "7393f157a55f7e70a4de0334435c55a5a8fe749a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/uid/zipball/4d9d6510bbe88ebb4608b7200d18606cdf80825c", - "reference": "4d9d6510bbe88ebb4608b7200d18606cdf80825c", + "url": "https://api.github.com/repos/symfony/uid/zipball/7393f157a55f7e70a4de0334435c55a5a8fe749a", + "reference": "7393f157a55f7e70a4de0334435c55a5a8fe749a", "shasum": "" }, "require": { - "php": ">=8.4", + "php": ">=8.4.1", "symfony/polyfill-uuid": "^1.15" }, "require-dev": { @@ -5986,7 +6008,7 @@ "uuid" ], "support": { - "source": "https://github.com/symfony/uid/tree/v8.0.9" + "source": "https://github.com/symfony/uid/tree/v8.1.0" }, "funding": [ { @@ -6006,24 +6028,24 @@ "type": "tidelift" } ], - "time": "2026-04-30T16:10:06+00:00" + "time": "2026-05-29T05:06:50+00:00" }, { "name": "symfony/var-dumper", - "version": "v8.0.8", + "version": "v8.1.0", "source": { "type": "git", "url": "https://github.com/symfony/var-dumper.git", - "reference": "cfb7badd53bf4177f6e9416cfbbccc13c0e773a1" + "reference": "c2c4df1d21477cc21c9f6dc1b14d07c3abc4963e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-dumper/zipball/cfb7badd53bf4177f6e9416cfbbccc13c0e773a1", - "reference": "cfb7badd53bf4177f6e9416cfbbccc13c0e773a1", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/c2c4df1d21477cc21c9f6dc1b14d07c3abc4963e", + "reference": "c2c4df1d21477cc21c9f6dc1b14d07c3abc4963e", "shasum": "" }, "require": { - "php": ">=8.4", + "php": ">=8.4.1", "symfony/polyfill-mbstring": "^1.0" }, "conflict": { @@ -6073,7 +6095,7 @@ "dump" ], "support": { - "source": "https://github.com/symfony/var-dumper/tree/v8.0.8" + "source": "https://github.com/symfony/var-dumper/tree/v8.1.0" }, "funding": [ { @@ -6093,7 +6115,7 @@ "type": "tidelift" } ], - "time": "2026-03-31T07:15:36+00:00" + "time": "2026-05-29T05:06:50+00:00" }, { "name": "tijsverkoyen/css-to-inline-styles", @@ -6598,16 +6620,16 @@ }, { "name": "driftingly/rector-laravel", - "version": "2.3.0", + "version": "2.5.0", "source": { "type": "git", "url": "https://github.com/driftingly/rector-laravel.git", - "reference": "3c1c13f335b3b4d1a1f944a8ea194020044871ed" + "reference": "0dbdd842dfdbc821cfe5f47ca7326e2502b2a107" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/driftingly/rector-laravel/zipball/3c1c13f335b3b4d1a1f944a8ea194020044871ed", - "reference": "3c1c13f335b3b4d1a1f944a8ea194020044871ed", + "url": "https://api.github.com/repos/driftingly/rector-laravel/zipball/0dbdd842dfdbc821cfe5f47ca7326e2502b2a107", + "reference": "0dbdd842dfdbc821cfe5f47ca7326e2502b2a107", "shasum": "" }, "require": { @@ -6628,9 +6650,9 @@ "description": "Rector upgrades rules for Laravel Framework", "support": { "issues": "https://github.com/driftingly/rector-laravel/issues", - "source": "https://github.com/driftingly/rector-laravel/tree/2.3.0" + "source": "https://github.com/driftingly/rector-laravel/tree/2.5.0" }, - "time": "2026-04-08T10:52:44+00:00" + "time": "2026-06-02T16:22:14+00:00" }, { "name": "fakerphp/faker", @@ -6981,16 +7003,16 @@ }, { "name": "larastan/larastan", - "version": "v3.9.6", + "version": "v3.10.0", "source": { "type": "git", "url": "https://github.com/larastan/larastan.git", - "reference": "9ad17e83e96b63536cb6ac39c3d40d29ff9cf636" + "reference": "2970f83398154178a739609c244577267c7ee8eb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/larastan/larastan/zipball/9ad17e83e96b63536cb6ac39c3d40d29ff9cf636", - "reference": "9ad17e83e96b63536cb6ac39c3d40d29ff9cf636", + "url": "https://api.github.com/repos/larastan/larastan/zipball/2970f83398154178a739609c244577267c7ee8eb", + "reference": "2970f83398154178a739609c244577267c7ee8eb", "shasum": "" }, "require": { @@ -7004,17 +7026,17 @@ "illuminate/pipeline": "^11.44.2 || ^12.4.1 || ^13", "illuminate/support": "^11.44.2 || ^12.4.1 || ^13", "php": "^8.2", - "phpstan/phpstan": "^2.1.44" + "phpstan/phpstan": "^2.2.0" }, "require-dev": { - "doctrine/coding-standard": "^13", + "doctrine/coding-standard": "^14", "laravel/framework": "^11.44.2 || ^12.7.2 || ^13", "mockery/mockery": "^1.6.12", "nikic/php-parser": "^5.4", "orchestra/canvas": "^v9.2.2 || ^10.0.1 || ^11", "orchestra/testbench-core": "^9.12.0 || ^10.1 || ^11", "phpstan/phpstan-deprecation-rules": "^2.0.1", - "phpunit/phpunit": "^10.5.35 || ^11.5.15 || ^12.5.8" + "phpunit/phpunit": "^10.5.35 || ^11.5.15 || ^12.5.8 || ^13.1.8" }, "suggest": { "orchestra/testbench": "Using Larastan for analysing a package needs Testbench", @@ -7059,7 +7081,7 @@ ], "support": { "issues": "https://github.com/larastan/larastan/issues", - "source": "https://github.com/larastan/larastan/tree/v3.9.6" + "source": "https://github.com/larastan/larastan/tree/v3.10.0" }, "funding": [ { @@ -7067,20 +7089,82 @@ "type": "github" } ], - "time": "2026-04-16T10:02:43+00:00" + "time": "2026-05-28T08:00:58+00:00" + }, + { + "name": "laravel/agent-detector", + "version": "v2.0.2", + "source": { + "type": "git", + "url": "https://github.com/laravel/agent-detector.git", + "reference": "90694b9256099591cf9e55d08c18ba7a00bf099f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/agent-detector/zipball/90694b9256099591cf9e55d08c18ba7a00bf099f", + "reference": "90694b9256099591cf9e55d08c18ba7a00bf099f", + "shasum": "" + }, + "require": { + "php": "^8.2.0" + }, + "require-dev": { + "laravel/pint": "^1.24.0", + "pestphp/pest": "^3.8.5|^4.1.0", + "pestphp/pest-plugin-type-coverage": "^3.0|^4.0.2", + "phpstan/phpstan": "^2.1.26", + "rector/rector": "^2.1.7", + "symfony/var-dumper": "^7.3.3" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Laravel\\AgentDetector\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Detect if code is running in an AI agent or automated development environment", + "homepage": "https://github.com/laravel/agent-detector", + "keywords": [ + "Agent", + "ai", + "automation", + "claude", + "cursor", + "detection", + "devin", + "php" + ], + "support": { + "issues": "https://github.com/laravel/agent-detector/issues", + "source": "https://github.com/laravel/agent-detector" + }, + "time": "2026-04-29T18:32:34+00:00" }, { "name": "laravel/boost", - "version": "v2.4.6", + "version": "v2.4.9", "source": { "type": "git", "url": "https://github.com/laravel/boost.git", - "reference": "c9ea6368c66f7c0e6a9b26706b401de900cdb9ac" + "reference": "f0359e55f6c3782023a35baf1d3df817053d69e8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/boost/zipball/c9ea6368c66f7c0e6a9b26706b401de900cdb9ac", - "reference": "c9ea6368c66f7c0e6a9b26706b401de900cdb9ac", + "url": "https://api.github.com/repos/laravel/boost/zipball/f0359e55f6c3782023a35baf1d3df817053d69e8", + "reference": "f0359e55f6c3782023a35baf1d3df817053d69e8", "shasum": "" }, "require": { @@ -7089,7 +7173,7 @@ "illuminate/contracts": "^11.45.3|^12.41.1|^13.0", "illuminate/routing": "^11.45.3|^12.41.1|^13.0", "illuminate/support": "^11.45.3|^12.41.1|^13.0", - "laravel/mcp": "^0.5.1|^0.6.0|^0.7.0", + "laravel/mcp": "^0.7.1", "laravel/prompts": "^0.3.10", "laravel/roster": "^0.5.0", "php": "^8.2" @@ -7133,20 +7217,20 @@ "issues": "https://github.com/laravel/boost/issues", "source": "https://github.com/laravel/boost" }, - "time": "2026-04-28T11:52:01+00:00" + "time": "2026-06-04T10:33:57+00:00" }, { "name": "laravel/mcp", - "version": "v0.7.0", + "version": "v0.7.2", "source": { "type": "git", "url": "https://github.com/laravel/mcp.git", - "reference": "3513b4feca5f1678be4d2261dcfa8e456436d02a" + "reference": "08962a276357f89164f78b38407c08187ab26cfe" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/mcp/zipball/3513b4feca5f1678be4d2261dcfa8e456436d02a", - "reference": "3513b4feca5f1678be4d2261dcfa8e456436d02a", + "url": "https://api.github.com/repos/laravel/mcp/zipball/08962a276357f89164f78b38407c08187ab26cfe", + "reference": "08962a276357f89164f78b38407c08187ab26cfe", "shasum": "" }, "require": { @@ -7160,7 +7244,8 @@ "illuminate/routing": "^11.45.3|^12.41.1|^13.0", "illuminate/support": "^11.45.3|^12.41.1|^13.0", "illuminate/validation": "^11.45.3|^12.41.1|^13.0", - "php": "^8.2" + "php": "^8.2", + "symfony/process": "^7.4.5|^8.0.5" }, "require-dev": { "laravel/pint": "^1.20", @@ -7206,20 +7291,20 @@ "issues": "https://github.com/laravel/mcp/issues", "source": "https://github.com/laravel/mcp" }, - "time": "2026-04-21T10:23:03+00:00" + "time": "2026-05-22T11:45:29+00:00" }, { "name": "laravel/pail", - "version": "v1.2.6", + "version": "v1.2.7", "source": { "type": "git", "url": "https://github.com/laravel/pail.git", - "reference": "aa71a01c309e7f66bc2ec4fb1a59291b82eb4abf" + "reference": "2f7d27dada8effc48b8c424445a69cca7007daaa" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/pail/zipball/aa71a01c309e7f66bc2ec4fb1a59291b82eb4abf", - "reference": "aa71a01c309e7f66bc2ec4fb1a59291b82eb4abf", + "url": "https://api.github.com/repos/laravel/pail/zipball/2f7d27dada8effc48b8c424445a69cca7007daaa", + "reference": "2f7d27dada8effc48b8c424445a69cca7007daaa", "shasum": "" }, "require": { @@ -7286,7 +7371,92 @@ "issues": "https://github.com/laravel/pail/issues", "source": "https://github.com/laravel/pail" }, - "time": "2026-02-09T13:44:54+00:00" + "time": "2026-05-20T22:24:57+00:00" + }, + { + "name": "laravel/pao", + "version": "v1.1.0", + "source": { + "type": "git", + "url": "https://github.com/laravel/pao.git", + "reference": "519eecdefb9d5da362876376b52207c8f11b477c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/pao/zipball/519eecdefb9d5da362876376b52207c8f11b477c", + "reference": "519eecdefb9d5da362876376b52207c8f11b477c", + "shasum": "" + }, + "require": { + "laravel/agent-detector": "^2.0.2", + "php": "^8.3" + }, + "conflict": { + "laravel/framework": "<12.0.0", + "nunomaduro/collision": "<8.9.3", + "pestphp/pest": "<4.6.3 || >=6.0.0", + "phpunit/phpunit": "<12.5.23 || >=13.0.0 <13.1.7 || >=14.0.0" + }, + "require-dev": { + "brianium/paratest": "^7.20.0", + "laravel/pint": "^1.29.1", + "orchestra/testbench": "^10.11.0 || ^11.1.0", + "pestphp/pest": "^4.7.2 || ^5.0.0", + "pestphp/pest-plugin-type-coverage": "^4.0.4 || ^5.0.0", + "phpstan/phpstan": "^2.2.1", + "rector/rector": "^2.4.5", + "symfony/process": "^7.4.8 || ^8.1.0", + "symfony/var-dumper": "^7.4.8 || ^8.1.0" + }, + "type": "library", + "extra": { + "pest": { + "plugins": [ + "Laravel\\Pao\\Drivers\\Pest\\Plugin" + ] + }, + "laravel": { + "providers": [ + "Laravel\\Pao\\Laravel\\ServiceProvider" + ] + } + }, + "autoload": { + "files": [ + "src/Autoload.php" + ], + "psr-4": { + "Laravel\\Pao\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Agent-optimized output for PHP testing tools", + "keywords": [ + "Agent", + "PHPStan", + "ai", + "dev", + "paratest", + "pest", + "php", + "phpunit", + "rector", + "testing" + ], + "support": { + "issues": "https://github.com/laravel/pao/issues", + "source": "https://github.com/laravel/pao" + }, + "time": "2026-06-01T07:26:51+00:00" }, { "name": "laravel/pint", @@ -7419,16 +7589,16 @@ }, { "name": "laravel/sail", - "version": "v1.58.0", + "version": "v1.62.0", "source": { "type": "git", "url": "https://github.com/laravel/sail.git", - "reference": "2e5e968138ca52ed87d712449697a8364d73b466" + "reference": "3aaeefc979f8ba6586fbc5b6e0b1b3638058f98e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/sail/zipball/2e5e968138ca52ed87d712449697a8364d73b466", - "reference": "2e5e968138ca52ed87d712449697a8364d73b466", + "url": "https://api.github.com/repos/laravel/sail/zipball/3aaeefc979f8ba6586fbc5b6e0b1b3638058f98e", + "reference": "3aaeefc979f8ba6586fbc5b6e0b1b3638058f98e", "shasum": "" }, "require": { @@ -7478,7 +7648,7 @@ "issues": "https://github.com/laravel/sail/issues", "source": "https://github.com/laravel/sail" }, - "time": "2026-04-27T13:38:34+00:00" + "time": "2026-05-27T04:02:01+00:00" }, { "name": "mockery/mockery", @@ -7721,16 +7891,16 @@ }, { "name": "pestphp/pest", - "version": "v4.7.0", + "version": "v4.7.2", "source": { "type": "git", "url": "https://github.com/pestphp/pest.git", - "reference": "2fc75cfcf03c041c804778fa894282234adc3c66" + "reference": "40b88b62ef8a7c6fcae5fc28f1fa747f601c131b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/pestphp/pest/zipball/2fc75cfcf03c041c804778fa894282234adc3c66", - "reference": "2fc75cfcf03c041c804778fa894282234adc3c66", + "url": "https://api.github.com/repos/pestphp/pest/zipball/40b88b62ef8a7c6fcae5fc28f1fa747f601c131b", + "reference": "40b88b62ef8a7c6fcae5fc28f1fa747f601c131b", "shasum": "" }, "require": { @@ -7743,21 +7913,21 @@ "pestphp/pest-plugin-mutate": "^4.0.1", "pestphp/pest-plugin-profanity": "^4.2.1", "php": "^8.3.0", - "phpunit/phpunit": "^12.5.24", - "symfony/process": "^7.4.8|^8.0.8" + "phpunit/phpunit": "^12.5.28", + "symfony/process": "^7.4.13|^8.1.0" }, "conflict": { "filp/whoops": "<2.18.3", - "phpunit/phpunit": ">12.5.24", + "phpunit/phpunit": ">12.5.28", "sebastian/exporter": "<7.0.0", "webmozart/assert": "<1.11.0" }, "require-dev": { - "mrpunyapal/peststan": "^0.2.9", + "mrpunyapal/peststan": "^0.2.10", "pestphp/pest-dev-tools": "^4.1.0", "pestphp/pest-plugin-browser": "^4.3.1", "pestphp/pest-plugin-type-coverage": "^4.0.4", - "psy/psysh": "^0.12.22" + "psy/psysh": "^0.12.23" }, "bin": [ "bin/pest" @@ -7824,7 +7994,7 @@ ], "support": { "issues": "https://github.com/pestphp/pest/issues", - "source": "https://github.com/pestphp/pest/tree/v4.7.0" + "source": "https://github.com/pestphp/pest/tree/v4.7.2" }, "funding": [ { @@ -7836,7 +8006,7 @@ "type": "github" } ], - "time": "2026-05-03T16:09:32+00:00" + "time": "2026-06-01T06:08:59+00:00" }, { "name": "pestphp/pest-plugin", @@ -8575,11 +8745,11 @@ }, { "name": "phpstan/phpstan", - "version": "2.1.54", + "version": "2.2.2", "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/8be50c3992107dc837b17da4d140fbbdf9a5c5bd", - "reference": "8be50c3992107dc837b17da4d140fbbdf9a5c5bd", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/e5cc34d491a90e79c216d824f60fe21fd4d93bd6", + "reference": "e5cc34d491a90e79c216d824f60fe21fd4d93bd6", "shasum": "" }, "require": { @@ -8602,6 +8772,17 @@ "license": [ "MIT" ], + "authors": [ + { + "name": "Ondřej Mirtes" + }, + { + "name": "Markus Staab" + }, + { + "name": "Vincent Langlet" + } + ], "description": "PHPStan - PHP Static Analysis Tool", "keywords": [ "dev", @@ -8624,20 +8805,20 @@ "type": "github" } ], - "time": "2026-04-29T13:31:09+00:00" + "time": "2026-06-05T09:00:01+00:00" }, { "name": "phpunit/php-code-coverage", - "version": "12.5.6", + "version": "12.5.7", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-code-coverage.git", - "reference": "876099a072646c7745f673d7aeab5382c4439691" + "reference": "186dab580576598076de6818596d12b61801880e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/876099a072646c7745f673d7aeab5382c4439691", - "reference": "876099a072646c7745f673d7aeab5382c4439691", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/186dab580576598076de6818596d12b61801880e", + "reference": "186dab580576598076de6818596d12b61801880e", "shasum": "" }, "require": { @@ -8648,13 +8829,13 @@ "php": ">=8.3", "phpunit/php-text-template": "^5.0", "sebastian/complexity": "^5.0", - "sebastian/environment": "^8.0.3", - "sebastian/lines-of-code": "^4.0", + "sebastian/environment": "^8.1.2", + "sebastian/lines-of-code": "^4.0.1", "sebastian/version": "^6.0", "theseer/tokenizer": "^2.0.1" }, "require-dev": { - "phpunit/phpunit": "^12.5.1" + "phpunit/phpunit": "^12.5.28" }, "suggest": { "ext-pcov": "PHP extension that provides line coverage", @@ -8692,7 +8873,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", - "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/12.5.6" + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/12.5.7" }, "funding": [ { @@ -8712,7 +8893,7 @@ "type": "tidelift" } ], - "time": "2026-04-15T08:23:17+00:00" + "time": "2026-06-01T13:24:19+00:00" }, { "name": "phpunit/php-file-iterator", @@ -8973,16 +9154,16 @@ }, { "name": "phpunit/phpunit", - "version": "12.5.24", + "version": "12.5.28", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "d75dd30597caa80e72fad2ef7904601a30ef1046" + "reference": "5895d05f5bf421ed230fbd76e1277e4b8955def4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/d75dd30597caa80e72fad2ef7904601a30ef1046", - "reference": "d75dd30597caa80e72fad2ef7904601a30ef1046", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/5895d05f5bf421ed230fbd76e1277e4b8955def4", + "reference": "5895d05f5bf421ed230fbd76e1277e4b8955def4", "shasum": "" }, "require": { @@ -9001,15 +9182,15 @@ "phpunit/php-invoker": "^6.0.0", "phpunit/php-text-template": "^5.0.0", "phpunit/php-timer": "^8.0.0", - "sebastian/cli-parser": "^4.2.0", - "sebastian/comparator": "^7.1.6", + "sebastian/cli-parser": "^4.2.1", + "sebastian/comparator": "^7.1.8", "sebastian/diff": "^7.0.0", - "sebastian/environment": "^8.1.0", - "sebastian/exporter": "^7.0.2", + "sebastian/environment": "^8.1.2", + "sebastian/exporter": "^7.0.3", "sebastian/global-state": "^8.0.2", "sebastian/object-enumerator": "^7.0.0", "sebastian/recursion-context": "^7.0.1", - "sebastian/type": "^6.0.3", + "sebastian/type": "^6.0.4", "sebastian/version": "^6.0.0", "staabm/side-effects-detector": "^1.0.5" }, @@ -9051,7 +9232,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.24" + "source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.28" }, "funding": [ { @@ -9059,25 +9240,25 @@ "type": "other" } ], - "time": "2026-05-01T04:21:04+00:00" + "time": "2026-05-27T14:01:10+00:00" }, { "name": "rector/rector", - "version": "2.4.2", + "version": "2.4.5", "source": { "type": "git", "url": "https://github.com/rectorphp/rector.git", - "reference": "e645b6463c6a88ea5b44b17d3387d35a912c7946" + "reference": "cbd86024be5014d3c14d9f0b3f7aae8ecbffd62c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/rectorphp/rector/zipball/e645b6463c6a88ea5b44b17d3387d35a912c7946", - "reference": "e645b6463c6a88ea5b44b17d3387d35a912c7946", + "url": "https://api.github.com/repos/rectorphp/rector/zipball/cbd86024be5014d3c14d9f0b3f7aae8ecbffd62c", + "reference": "cbd86024be5014d3c14d9f0b3f7aae8ecbffd62c", "shasum": "" }, "require": { "php": "^7.4|^8.0", - "phpstan/phpstan": "^2.1.48" + "phpstan/phpstan": "^2.1.56" }, "conflict": { "rector/rector-doctrine": "*", @@ -9111,7 +9292,7 @@ ], "support": { "issues": "https://github.com/rectorphp/rector/issues", - "source": "https://github.com/rectorphp/rector/tree/2.4.2" + "source": "https://github.com/rectorphp/rector/tree/2.4.5" }, "funding": [ { @@ -9119,27 +9300,27 @@ "type": "github" } ], - "time": "2026-04-16T13:07:34+00:00" + "time": "2026-05-26T21:03:22+00:00" }, { "name": "sebastian/cli-parser", - "version": "4.2.0", + "version": "4.2.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/cli-parser.git", - "reference": "90f41072d220e5c40df6e8635f5dafba2d9d4d04" + "reference": "7d05781b13f7dec9043a629a21d086ed74582a15" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/90f41072d220e5c40df6e8635f5dafba2d9d4d04", - "reference": "90f41072d220e5c40df6e8635f5dafba2d9d4d04", + "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/7d05781b13f7dec9043a629a21d086ed74582a15", + "reference": "7d05781b13f7dec9043a629a21d086ed74582a15", "shasum": "" }, "require": { "php": ">=8.3" }, "require-dev": { - "phpunit/phpunit": "^12.0" + "phpunit/phpunit": "^12.5.25" }, "type": "library", "extra": { @@ -9168,7 +9349,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/cli-parser/issues", "security": "https://github.com/sebastianbergmann/cli-parser/security/policy", - "source": "https://github.com/sebastianbergmann/cli-parser/tree/4.2.0" + "source": "https://github.com/sebastianbergmann/cli-parser/tree/4.2.1" }, "funding": [ { @@ -9188,20 +9369,20 @@ "type": "tidelift" } ], - "time": "2025-09-14T09:36:45+00:00" + "time": "2026-05-17T05:29:34+00:00" }, { "name": "sebastian/comparator", - "version": "7.1.6", + "version": "7.1.8", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/comparator.git", - "reference": "c769009dee98f494e0edc3fd4f4087501688f11e" + "reference": "7c65c1e79836812819705b473a90c12399542485" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/c769009dee98f494e0edc3fd4f4087501688f11e", - "reference": "c769009dee98f494e0edc3fd4f4087501688f11e", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/7c65c1e79836812819705b473a90c12399542485", + "reference": "7c65c1e79836812819705b473a90c12399542485", "shasum": "" }, "require": { @@ -9209,10 +9390,10 @@ "ext-mbstring": "*", "php": ">=8.3", "sebastian/diff": "^7.0", - "sebastian/exporter": "^7.0" + "sebastian/exporter": "^7.0.3" }, "require-dev": { - "phpunit/phpunit": "^12.2" + "phpunit/phpunit": "^12.5.25" }, "suggest": { "ext-bcmath": "For comparing BcMath\\Number objects" @@ -9260,7 +9441,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/comparator/issues", "security": "https://github.com/sebastianbergmann/comparator/security/policy", - "source": "https://github.com/sebastianbergmann/comparator/tree/7.1.6" + "source": "https://github.com/sebastianbergmann/comparator/tree/7.1.8" }, "funding": [ { @@ -9280,7 +9461,7 @@ "type": "tidelift" } ], - "time": "2026-04-14T08:23:15+00:00" + "time": "2026-05-21T04:45:25+00:00" }, { "name": "sebastian/complexity", @@ -9409,23 +9590,23 @@ }, { "name": "sebastian/environment", - "version": "8.1.0", + "version": "8.1.2", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/environment.git", - "reference": "b121608b28a13f721e76ffbbd386d08eff58f3f6" + "reference": "9d32c685773823b1983e256ae4ecd48a10d6e439" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/b121608b28a13f721e76ffbbd386d08eff58f3f6", - "reference": "b121608b28a13f721e76ffbbd386d08eff58f3f6", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/9d32c685773823b1983e256ae4ecd48a10d6e439", + "reference": "9d32c685773823b1983e256ae4ecd48a10d6e439", "shasum": "" }, "require": { "php": ">=8.3" }, "require-dev": { - "phpunit/phpunit": "^12.0" + "phpunit/phpunit": "^12.5.26" }, "suggest": { "ext-posix": "*" @@ -9461,7 +9642,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/environment/issues", "security": "https://github.com/sebastianbergmann/environment/security/policy", - "source": "https://github.com/sebastianbergmann/environment/tree/8.1.0" + "source": "https://github.com/sebastianbergmann/environment/tree/8.1.2" }, "funding": [ { @@ -9481,29 +9662,29 @@ "type": "tidelift" } ], - "time": "2026-04-15T12:13:01+00:00" + "time": "2026-05-25T13:40:20+00:00" }, { "name": "sebastian/exporter", - "version": "7.0.2", + "version": "7.0.3", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/exporter.git", - "reference": "016951ae10980765e4e7aee491eb288c64e505b7" + "reference": "c5e21b5de653ce0a769fb36f5cdfcb5e7a32cf23" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/016951ae10980765e4e7aee491eb288c64e505b7", - "reference": "016951ae10980765e4e7aee491eb288c64e505b7", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/c5e21b5de653ce0a769fb36f5cdfcb5e7a32cf23", + "reference": "c5e21b5de653ce0a769fb36f5cdfcb5e7a32cf23", "shasum": "" }, "require": { "ext-mbstring": "*", "php": ">=8.3", - "sebastian/recursion-context": "^7.0" + "sebastian/recursion-context": "^7.0.1" }, "require-dev": { - "phpunit/phpunit": "^12.0" + "phpunit/phpunit": "^12.5.25" }, "type": "library", "extra": { @@ -9551,7 +9732,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/exporter/issues", "security": "https://github.com/sebastianbergmann/exporter/security/policy", - "source": "https://github.com/sebastianbergmann/exporter/tree/7.0.2" + "source": "https://github.com/sebastianbergmann/exporter/tree/7.0.3" }, "funding": [ { @@ -9571,30 +9752,30 @@ "type": "tidelift" } ], - "time": "2025-09-24T06:16:11+00:00" + "time": "2026-05-20T04:37:17+00:00" }, { "name": "sebastian/global-state", - "version": "8.0.2", + "version": "8.0.3", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/global-state.git", - "reference": "ef1377171613d09edd25b7816f05be8313f9115d" + "reference": "b164d3274d6537ab462591c5755f76a8f5b1aae9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/ef1377171613d09edd25b7816f05be8313f9115d", - "reference": "ef1377171613d09edd25b7816f05be8313f9115d", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/b164d3274d6537ab462591c5755f76a8f5b1aae9", + "reference": "b164d3274d6537ab462591c5755f76a8f5b1aae9", "shasum": "" }, "require": { "php": ">=8.3", "sebastian/object-reflector": "^5.0", - "sebastian/recursion-context": "^7.0" + "sebastian/recursion-context": "^7.0.1" }, "require-dev": { "ext-dom": "*", - "phpunit/phpunit": "^12.0" + "phpunit/phpunit": "^12.5.28" }, "type": "library", "extra": { @@ -9625,7 +9806,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/global-state/issues", "security": "https://github.com/sebastianbergmann/global-state/security/policy", - "source": "https://github.com/sebastianbergmann/global-state/tree/8.0.2" + "source": "https://github.com/sebastianbergmann/global-state/tree/8.0.3" }, "funding": [ { @@ -9645,28 +9826,28 @@ "type": "tidelift" } ], - "time": "2025-08-29T11:29:25+00:00" + "time": "2026-06-01T15:10:33+00:00" }, { "name": "sebastian/lines-of-code", - "version": "4.0.0", + "version": "4.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/lines-of-code.git", - "reference": "97ffee3bcfb5805568d6af7f0f893678fc076d2f" + "reference": "d543b8ef219dcd8da262cbb958639a96bedba10e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/97ffee3bcfb5805568d6af7f0f893678fc076d2f", - "reference": "97ffee3bcfb5805568d6af7f0f893678fc076d2f", + "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/d543b8ef219dcd8da262cbb958639a96bedba10e", + "reference": "d543b8ef219dcd8da262cbb958639a96bedba10e", "shasum": "" }, "require": { - "nikic/php-parser": "^5.0", + "nikic/php-parser": "^5.7.0", "php": ">=8.3" }, "require-dev": { - "phpunit/phpunit": "^12.0" + "phpunit/phpunit": "^12.5.25" }, "type": "library", "extra": { @@ -9695,15 +9876,27 @@ "support": { "issues": "https://github.com/sebastianbergmann/lines-of-code/issues", "security": "https://github.com/sebastianbergmann/lines-of-code/security/policy", - "source": "https://github.com/sebastianbergmann/lines-of-code/tree/4.0.0" + "source": "https://github.com/sebastianbergmann/lines-of-code/tree/4.0.1" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/lines-of-code", + "type": "tidelift" } ], - "time": "2025-02-07T04:57:28+00:00" + "time": "2026-05-19T16:22:07+00:00" }, { "name": "sebastian/object-enumerator", @@ -9897,23 +10090,23 @@ }, { "name": "sebastian/type", - "version": "6.0.3", + "version": "6.0.4", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/type.git", - "reference": "e549163b9760b8f71f191651d22acf32d56d6d4d" + "reference": "82ff822c2edc46724be9f7411d3163021f602773" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/e549163b9760b8f71f191651d22acf32d56d6d4d", - "reference": "e549163b9760b8f71f191651d22acf32d56d6d4d", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/82ff822c2edc46724be9f7411d3163021f602773", + "reference": "82ff822c2edc46724be9f7411d3163021f602773", "shasum": "" }, "require": { "php": ">=8.3" }, "require-dev": { - "phpunit/phpunit": "^12.0" + "phpunit/phpunit": "^12.5.25" }, "type": "library", "extra": { @@ -9942,7 +10135,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/type/issues", "security": "https://github.com/sebastianbergmann/type/security/policy", - "source": "https://github.com/sebastianbergmann/type/tree/6.0.3" + "source": "https://github.com/sebastianbergmann/type/tree/6.0.4" }, "funding": [ { @@ -9962,7 +10155,7 @@ "type": "tidelift" } ], - "time": "2025-08-09T06:57:12+00:00" + "time": "2026-05-20T06:45:45+00:00" }, { "name": "sebastian/version", @@ -10072,27 +10265,28 @@ }, { "name": "symfony/yaml", - "version": "v8.0.10", + "version": "v8.1.0", "source": { "type": "git", "url": "https://github.com/symfony/yaml.git", - "reference": "aa9ee60c41d9b20a2468c41ff0a32e2a7405ac05" + "reference": "efb42bd2c6f4f3ccfd4683583449938b5fc146b0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/yaml/zipball/aa9ee60c41d9b20a2468c41ff0a32e2a7405ac05", - "reference": "aa9ee60c41d9b20a2468c41ff0a32e2a7405ac05", + "url": "https://api.github.com/repos/symfony/yaml/zipball/efb42bd2c6f4f3ccfd4683583449938b5fc146b0", + "reference": "efb42bd2c6f4f3ccfd4683583449938b5fc146b0", "shasum": "" }, "require": { - "php": ">=8.4", + "php": ">=8.4.1", "symfony/polyfill-ctype": "^1.8" }, "conflict": { "symfony/console": "<7.4" }, "require-dev": { - "symfony/console": "^7.4|^8.0" + "symfony/console": "^7.4|^8.0", + "yaml/yaml-test-suite": "*" }, "bin": [ "Resources/bin/yaml-lint" @@ -10123,7 +10317,7 @@ "description": "Loads and dumps YAML files", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/yaml/tree/v8.0.10" + "source": "https://github.com/symfony/yaml/tree/v8.1.0" }, "funding": [ { @@ -10143,7 +10337,7 @@ "type": "tidelift" } ], - "time": "2026-05-05T08:10:04+00:00" + "time": "2026-05-29T05:06:50+00:00" }, { "name": "ta-tikoma/phpunit-architecture-test", @@ -10256,22 +10450,22 @@ }, { "name": "tomasvotruba/type-coverage", - "version": "2.1.0", + "version": "2.2.1", "source": { "type": "git", "url": "https://github.com/TomasVotruba/type-coverage.git", - "reference": "468354b3964120806a69890cbeb3fcf005876391" + "reference": "4087caa4639bdd4c646f2984bc333efeddf69e4b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/TomasVotruba/type-coverage/zipball/468354b3964120806a69890cbeb3fcf005876391", - "reference": "468354b3964120806a69890cbeb3fcf005876391", + "url": "https://api.github.com/repos/TomasVotruba/type-coverage/zipball/4087caa4639bdd4c646f2984bc333efeddf69e4b", + "reference": "4087caa4639bdd4c646f2984bc333efeddf69e4b", "shasum": "" }, "require": { "nette/utils": "^3.2 || ^4.0", "php": "^7.4 || ^8.0", - "phpstan/phpstan": "^2.0" + "phpstan/phpstan": "^2.1.33" }, "type": "phpstan-extension", "extra": { @@ -10297,7 +10491,7 @@ ], "support": { "issues": "https://github.com/TomasVotruba/type-coverage/issues", - "source": "https://github.com/TomasVotruba/type-coverage/tree/2.1.0" + "source": "https://github.com/TomasVotruba/type-coverage/tree/2.2.1" }, "funding": [ { @@ -10309,20 +10503,20 @@ "type": "github" } ], - "time": "2025-12-05T16:38:02+00:00" + "time": "2026-05-26T08:14:01+00:00" }, { "name": "webmozart/assert", - "version": "2.3.0", + "version": "2.4.0", "source": { "type": "git", "url": "https://github.com/webmozarts/assert.git", - "reference": "eb0d790f735ba6cff25c683a85a1da0eadeff9e4" + "reference": "9007ea6f45ecf352a9422b36644e4bfc039b9155" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/webmozarts/assert/zipball/eb0d790f735ba6cff25c683a85a1da0eadeff9e4", - "reference": "eb0d790f735ba6cff25c683a85a1da0eadeff9e4", + "url": "https://api.github.com/repos/webmozarts/assert/zipball/9007ea6f45ecf352a9422b36644e4bfc039b9155", + "reference": "9007ea6f45ecf352a9422b36644e4bfc039b9155", "shasum": "" }, "require": { @@ -10338,7 +10532,11 @@ }, "type": "library", "extra": { + "psalm": { + "pluginClass": "Webmozart\\Assert\\PsalmPlugin" + }, "branch-alias": { + "dev-master": "2.0-dev", "dev-feature/2-0": "2.0-dev" } }, @@ -10369,9 +10567,9 @@ ], "support": { "issues": "https://github.com/webmozarts/assert/issues", - "source": "https://github.com/webmozarts/assert/tree/2.3.0" + "source": "https://github.com/webmozarts/assert/tree/2.4.0" }, - "time": "2026-04-11T10:33:05+00:00" + "time": "2026-05-20T13:07:01+00:00" } ], "aliases": [], diff --git a/package-lock.json b/package-lock.json index 87bfd92..e7552ac 100644 --- a/package-lock.json +++ b/package-lock.json @@ -506,9 +506,9 @@ } }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.3.tgz", - "integrity": "sha512-x35CNW/ANXG3hE/EZpRU8MXX1JDN86hBb2wMGAtltkz7pc6cxgjpy1OMMfDosOQ+2hWqIkag/fGok1Yady9nGw==", + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.61.1.tgz", + "integrity": "sha512-JnBB8MdXj45cajvTuO5FmPlvFVJRQgvrz1uSEl3NwqFnReAPGwb8EanbGi4z2nRaqLzjJSv5/JmycoTKlRZxHA==", "cpu": [ "arm" ], @@ -520,9 +520,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.3.tgz", - "integrity": "sha512-xw3xtkDApIOGayehp2+Rz4zimfkaX65r4t47iy+ymQB2G4iJCBBfj0ogVg5jpvjpn8UWn/+q9tprxleYeNp3Hw==", + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.61.1.tgz", + "integrity": "sha512-Jx2g7iSjw4AOT0HDPHM9RV3GNjRXwybWtSFZiZAYUTjUwjVrYIwq3kBf+LnhqJlzXFAqTAh2F7IGI+O568exPw==", "cpu": [ "arm64" ], @@ -534,9 +534,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.3.tgz", - "integrity": "sha512-vo6Y5Qfpx7/5EaamIwi0WqW2+zfiusVihKatLvtN1VFVy3D13uERk/6gZLU1UiHRL6fDXqj/ELIeVRGnvcTE1g==", + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.61.1.tgz", + "integrity": "sha512-0F1L/Z3Eqv8mT2n3dCpeO8GcTvHvVqkP5/t6DMsn0KzhYVcg+s7Ncl5DS8qjKYEeio6Az0Gt6nyBORay5qIlCA==", "cpu": [ "arm64" ], @@ -548,9 +548,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.3.tgz", - "integrity": "sha512-D+0QGcZhBzTN82weOnsSlY7V7+RMmPuF1CkbxyMAGE8+ZHeUjyb76ZiWmBlCu//AQQONvxcqRbwZTajZKqjuOw==", + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.61.1.tgz", + "integrity": "sha512-qLttcH871ujY4YcVfUSShhOw+CsoTatYz8gRbHO7Bb92QH059/P0y5do1KMs41fY0BpD2x4AJH/gID0zFiqVKQ==", "cpu": [ "x64" ], @@ -562,9 +562,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.3.tgz", - "integrity": "sha512-6HnvHCT7fDyj6R0Ph7A6x8dQS/S38MClRWeDLqc0MdfWkxjiu1HSDYrdPhqSILzjTIC/pnXbbJbo+ft+gy/9hQ==", + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.61.1.tgz", + "integrity": "sha512-fUI4RapGE0Oh3mb8mgfvC1O2nU1RpDZUKnDQm3xB1Ipg7C2wTs5Kstz7G2uWK99a8S2yTMq8/P4uycwNa0nJyw==", "cpu": [ "arm64" ], @@ -576,9 +576,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.3.tgz", - "integrity": "sha512-KHLgC3WKlUYW3ShFKnnosZDOJ0xjg9zp7au3sIm2bs/tGBeC2ipmvRh/N7JKi0t9Ue20C0dpEshi8WUubg+cnA==", + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.61.1.tgz", + "integrity": "sha512-H5YrdvJaDtI/U9/emrD4b++xkvp3y/JvOe4rizHbxvkyMfRS/CiRYdji+Pl8D0brEaNFWUh1drQxgAGIl6Xudw==", "cpu": [ "x64" ], @@ -590,9 +590,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.3.tgz", - "integrity": "sha512-DV6fJoxEYWJOvaZIsok7KrYl0tPvga5OZ2yvKHNNYyk/2roMLqQAbGhr78EQ5YhHpnhLKJD3S1WFusAkmUuV5g==", + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.61.1.tgz", + "integrity": "sha512-Q8CBCCQtDFrYtXoeUXSrnFXKOnyUhx6bz+SkL6A0E7V8kAiCJ5pamq1WtbfpVGhR5TSpXY6ak3avmDc5fHTyJA==", "cpu": [ "arm" ], @@ -604,9 +604,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.3.tgz", - "integrity": "sha512-mQKoJAzvuOs6F+TZybQO4GOTSMUu7v0WdxEk24krQ/uUxXoPTtHjuaUuPmFhtBcM4K0ons8nrE3JyhTuCFtT/w==", + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.61.1.tgz", + "integrity": "sha512-nwnhk1581l0FBVellGcVCAT0Oi06onEA3WB53sf01VO3I0UPBkMH9sXONYME2K0ovXcNayJfNtHfm6mpJElatQ==", "cpu": [ "arm" ], @@ -618,9 +618,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.3.tgz", - "integrity": "sha512-Whjj2qoiJ6+OOJMGptTYazaJvjOJm+iKHpXQM1P3LzGjt7Ff++Tp7nH4N8J/BUA7R9IHfDyx4DJIflifwnbmIA==", + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.61.1.tgz", + "integrity": "sha512-x5Xr49hwt3hdW75UOZm3395YwwzPyauktslv29KpWL/T+vVAzoT3azLcTWv0eMciBNrx+DYjH4paehHoLpPvpg==", "cpu": [ "arm64" ], @@ -632,9 +632,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.3.tgz", - "integrity": "sha512-4YTNHKqGng5+yiZt3mg77nmyuCfmNfX4fPmyUapBcIk+BdwSwmCWGXOUxhXbBEkFHtoN5boLj/5NON+u5QC9tg==", + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.61.1.tgz", + "integrity": "sha512-unMS3H73DpaoPyyEVPjGKleM/s0mkmsauTENpw4INQY8y4+IuLNjkueQ5QCtC0D3N38Y38yhAU8OoZ20S2Tm6w==", "cpu": [ "arm64" ], @@ -646,9 +646,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.3.tgz", - "integrity": "sha512-SU3kNlhkpI4UqlUc2VXPGK9o886ZsSeGfMAX2ba2b8DKmMXq4AL7KUrkSWVbb7koVqx41Yczx6dx5PNargIrEA==", + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.61.1.tgz", + "integrity": "sha512-zNZzGRnAhwjFEYmvphJRV5XaQGjs62cCmeYYHUT//NbvEnHauw+I85nGG+SiVg5ld4GX8D1IbKIX+ozITQnhMQ==", "cpu": [ "loong64" ], @@ -660,9 +660,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.3.tgz", - "integrity": "sha512-6lDLl5h4TXpB1mTf2rQWnAk/LcXrx9vBfu/DT5TIPhvMhRWaZ5MxkIc8u4lJAmBo6klTe1ywXIUHFjylW505sg==", + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.61.1.tgz", + "integrity": "sha512-LdpWGL8X209B2SIvWjqlc8VZgM6PKfontSerGepuldQmHYrAOtnMCXeJkxXGbC+PPZVOuu5czJo7fNV6aeW8rQ==", "cpu": [ "loong64" ], @@ -674,9 +674,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.3.tgz", - "integrity": "sha512-BMo8bOw8evlup/8G+cj5xWtPyp93xPdyoSN16Zy90Q2QZ0ZYRhCt6ZJSwbrRzG9HApFabjwj2p25TUPDWrhzqQ==", + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.61.1.tgz", + "integrity": "sha512-EC5kTtNaNGOmbMGqar8dvJy6y/hg99GAwjfBz++pxZhQATXGcRjd6c5en5wcbru0vkRmiMGsQKdMJOOf6sza4g==", "cpu": [ "ppc64" ], @@ -688,9 +688,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.3.tgz", - "integrity": "sha512-E0L8X1dZN1/Rph+5VPF6Xj2G7JJvMACVXtamTJIDrVI44Y3K+G8gQaMEAavbqCGTa16InptiVrX6eM6pmJ+7qA==", + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.61.1.tgz", + "integrity": "sha512-8hiwp6D4acEcNK78I4rP0/XtS1sknWIAMJBPdR4l6zUtyTm5KiTDr5bXmWt4foY7nAN7AThDHgkLIEZOWKbzWw==", "cpu": [ "ppc64" ], @@ -702,9 +702,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.3.tgz", - "integrity": "sha512-oZJ/WHaVfHUiRAtmTAeo3DcevNsVvH8mbvodjZy7D5QKvCefO371SiKRpxoDcCxB3PTRTLayWBkvmDQKTcX/sw==", + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.61.1.tgz", + "integrity": "sha512-10dh/h/BqA7DuMPWSxkR8uks18FRwnwOEqr5zOTEl+NOwP/OMzKX8OFR/Of9xxDA7D5qef1Nzar5WDD2kCCr1g==", "cpu": [ "riscv64" ], @@ -716,9 +716,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.3.tgz", - "integrity": "sha512-Dhbyh7j9FybM3YaTgaHmVALwA8AkUwTPccyCQ79TG9AJUsMQqgN1DDEZNr4+QUfwiWvLDumW5vdwzoeUF+TNxQ==", + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.61.1.tgz", + "integrity": "sha512-YKJ5lg35DP17gcAOggnihe+APw9HLyj1Xn7gsmGumBJAUDa6NGXNixJzmkWLhcK9TOuuyQjdamzvJefkO7qHZQ==", "cpu": [ "riscv64" ], @@ -730,9 +730,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.3.tgz", - "integrity": "sha512-cJd1X5XhHHlltkaypz1UcWLA8AcoIi1aWhsvaWDskD1oz2eKCypnqvTQ8ykMNI0RSmm7NkTdSqSSD7zM0xa6Ig==", + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.61.1.tgz", + "integrity": "sha512-Mlil5G2Jj6a7B3LWGctg+XPL9vdXYuzCtNXfxOQ0nPjc2m6ueUktocPGH9bnAM0bNRKb/bAWTujUU7IJQdQA+g==", "cpu": [ "s390x" ], @@ -744,9 +744,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.3.tgz", - "integrity": "sha512-DAZDBHQfG2oQuhY7mc6I3/qB4LU2fQCjRvxbDwd/Jdvb9fypP4IJ4qmtu6lNjes6B531AI8cg1aKC2di97bUxA==", + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.61.1.tgz", + "integrity": "sha512-bVWIOIk6pV01p4CdUbPP7CJ/434z+OooYjDuFcR+44N35YvKUC66G8MGnvcWx5mWKW3g61J+t74l3Kj15Kwn2Q==", "cpu": [ "x64" ], @@ -758,9 +758,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.3.tgz", - "integrity": "sha512-cRxsE8c13mZOh3vP+wLDxpQBRrOHDIGOWyDL93Sy0Ga8y515fBcC2pjUfFwUe5T7tqvTvWbCpg1URM/AXdWIXA==", + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.61.1.tgz", + "integrity": "sha512-qy5pBvZbqNFheBz61R1rzsezjm0J7O2oNGoWtGoY89SZYLUfxAJTBAqDChqAIdB4rCiIbi9nF7yZ83GnNiLwSw==", "cpu": [ "x64" ], @@ -772,9 +772,9 @@ ] }, "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.3.tgz", - "integrity": "sha512-QaWcIgRxqEdQdhJqW4DJctsH6HCmo5vHxY0krHSX4jMtOqfzC+dqDGuHM87bu4H8JBeibWx7jFz+h6/4C8wA5Q==", + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.61.1.tgz", + "integrity": "sha512-E83TXjI4zm0+5f2qO+UOudaCYIhYwpJ5jq6YCZNIZ+6CbfhKrkAGezeiASBL9ElxAxFsRS9ZhESv8mfnj6TKeg==", "cpu": [ "x64" ], @@ -786,9 +786,9 @@ ] }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.3.tgz", - "integrity": "sha512-AaXwSvUi3QIPtroAUw1t5yHGIyqKEXwH54WUocFolZhpGDruJcs8c+xPNDRn4XiQsS7MEwnYsHW2l0MBLDMkWg==", + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.61.1.tgz", + "integrity": "sha512-fbWnKqVkjrJN38vNe3ahkbk6iejS/3b0Nt7EEtPpE6RBacZcGXNKbzfHN3GUUlXOPghUg0j6XUGrtjX9z1sIvA==", "cpu": [ "arm64" ], @@ -800,9 +800,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.3.tgz", - "integrity": "sha512-65LAKM/bAWDqKNEelHlcHvm2V+Vfb8C6INFxQXRHCvaVN1rJfwr4NvdP4FyzUaLqWfaCGaadf6UbTm8xJeYfEg==", + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.61.1.tgz", + "integrity": "sha512-ArMl38iVAbk0New1ogihQNY6iphLi4ZaRsa037gUzv5yeKPY8TD3Dmy4x2RNC1VztU/uqm+G+/RwFrSka3Oy2g==", "cpu": [ "arm64" ], @@ -814,9 +814,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.3.tgz", - "integrity": "sha512-EEM2gyhBF5MFnI6vMKdX1LAosE627RGBzIoGMdLloPZkXrUN0Ckqgr2Qi8+J3zip/8NVVro3/FjB+tjhZUgUHA==", + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.61.1.tgz", + "integrity": "sha512-0mYtjHS9ucAbcATycCNK9IGBk/cCe/ma7EmSLGZdsxnOA8cjRIyU04wDpVAD9NiOfLUR9KTxdiO53uOkherqjQ==", "cpu": [ "ia32" ], @@ -828,9 +828,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.3.tgz", - "integrity": "sha512-E5Eb5H/DpxaoXH++Qkv28RcUJboMopmdDUALBczvHMf7hNIxaDZqwY5lK12UK1BHacSmvupoEWGu+n993Z0y1A==", + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.61.1.tgz", + "integrity": "sha512-gK1iCEPfpoSG9wfBihXxvBMi8ZfcWffYkEsC/Eih+iFENTaewvNcrEQ69lIOWYO5pePHKLHHO7nq5AILGO/HQQ==", "cpu": [ "x64" ], @@ -842,9 +842,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.3.tgz", - "integrity": "sha512-hPt/bgL5cE+Qp+/TPHBqptcAgPzgj46mPcg/16zNUmbQk0j+mOEQV/+Lqu8QRtDV3Ek95Q6FeFITpuhl6OTsAA==", + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.61.1.tgz", + "integrity": "sha512-X+zaP2x+j4RXGfbp/seSoRHWnPxzApilDszisZxbYH5C/jTxFhCtDNdPGZb9lJyYPs24wGxruPF7Y+sIXt9Gzw==", "cpu": [ "x64" ], @@ -1128,12 +1128,25 @@ } }, "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", "dev": true, "license": "MIT" }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -1168,14 +1181,15 @@ "license": "MIT" }, "node_modules/axios": { - "version": "1.16.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.16.0.tgz", - "integrity": "sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w==", + "version": "1.17.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.17.0.tgz", + "integrity": "sha512-J8SwNxprqqpbfenehxWYXE7CW+wM1BB4w3+N+g+/Wx40xM4rsLrfPmHHxSWIxJLYDgSY/HqlFPIYb2/S3rxafw==", "dev": true, "license": "MIT", "dependencies": { "follow-redirects": "^1.16.0", "form-data": "^4.0.5", + "https-proxy-agent": "^5.0.1", "proxy-from-env": "^2.1.0" } }, @@ -1296,6 +1310,24 @@ "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" } }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -1339,9 +1371,9 @@ "license": "MIT" }, "node_modules/enhanced-resolve": { - "version": "5.21.2", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.2.tgz", - "integrity": "sha512-xe9vQb5kReirPUxgQrXA3ihgbCqssmTiM7cOZ+Gzu+VeGWgpV98lLZvp0dl4yriyAePcewxGUs9UpKD8PET9KQ==", + "version": "5.23.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.23.0.tgz", + "integrity": "sha512-yJN/BOOLxcOW2aQgeif9mSnaUB8KtvmMMp56oA1kx1CRfBKbhZm2pJ+NBY+3eOboHxix8lfjWpHE0Ei5U8RbSA==", "dev": true, "license": "MIT", "dependencies": { @@ -1373,9 +1405,9 @@ } }, "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", "dev": true, "license": "MIT", "dependencies": { @@ -1643,9 +1675,9 @@ } }, "node_modules/hasown": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", - "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", "dev": true, "license": "MIT", "dependencies": { @@ -1655,6 +1687,20 @@ "node": ">= 0.4" } }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", @@ -1999,6 +2045,13 @@ "node": ">= 0.6" } }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, "node_modules/nanoid": { "version": "3.3.12", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", @@ -2039,9 +2092,9 @@ } }, "node_modules/postcss": { - "version": "8.5.14", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", - "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", "dev": true, "funding": [ { @@ -2059,7 +2112,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -2088,13 +2141,13 @@ } }, "node_modules/rollup": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.3.tgz", - "integrity": "sha512-pAQK9HalE84QSm4Po3EmWIZPd3FnjkShVkiMlz1iligWYkWQ7wHYd1PF/T7QZ5TVSD6uSTon5gBVMSM4JfBV+A==", + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.61.1.tgz", + "integrity": "sha512-I4KW6iuRpuu2uHBLraZ1wNZe0DP7lnRha+VJ9tNaYVaVgKhW0aI3h4RYnoRPeql0flHm/Co55b7snEDcOfOJrA==", "dev": true, "license": "MIT", "dependencies": { - "@types/estree": "1.0.8" + "@types/estree": "1.0.9" }, "bin": { "rollup": "dist/bin/rollup" @@ -2104,31 +2157,31 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.60.3", - "@rollup/rollup-android-arm64": "4.60.3", - "@rollup/rollup-darwin-arm64": "4.60.3", - "@rollup/rollup-darwin-x64": "4.60.3", - "@rollup/rollup-freebsd-arm64": "4.60.3", - "@rollup/rollup-freebsd-x64": "4.60.3", - "@rollup/rollup-linux-arm-gnueabihf": "4.60.3", - "@rollup/rollup-linux-arm-musleabihf": "4.60.3", - "@rollup/rollup-linux-arm64-gnu": "4.60.3", - "@rollup/rollup-linux-arm64-musl": "4.60.3", - "@rollup/rollup-linux-loong64-gnu": "4.60.3", - "@rollup/rollup-linux-loong64-musl": "4.60.3", - "@rollup/rollup-linux-ppc64-gnu": "4.60.3", - "@rollup/rollup-linux-ppc64-musl": "4.60.3", - "@rollup/rollup-linux-riscv64-gnu": "4.60.3", - "@rollup/rollup-linux-riscv64-musl": "4.60.3", - "@rollup/rollup-linux-s390x-gnu": "4.60.3", - "@rollup/rollup-linux-x64-gnu": "4.60.3", - "@rollup/rollup-linux-x64-musl": "4.60.3", - "@rollup/rollup-openbsd-x64": "4.60.3", - "@rollup/rollup-openharmony-arm64": "4.60.3", - "@rollup/rollup-win32-arm64-msvc": "4.60.3", - "@rollup/rollup-win32-ia32-msvc": "4.60.3", - "@rollup/rollup-win32-x64-gnu": "4.60.3", - "@rollup/rollup-win32-x64-msvc": "4.60.3", + "@rollup/rollup-android-arm-eabi": "4.61.1", + "@rollup/rollup-android-arm64": "4.61.1", + "@rollup/rollup-darwin-arm64": "4.61.1", + "@rollup/rollup-darwin-x64": "4.61.1", + "@rollup/rollup-freebsd-arm64": "4.61.1", + "@rollup/rollup-freebsd-x64": "4.61.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.61.1", + "@rollup/rollup-linux-arm-musleabihf": "4.61.1", + "@rollup/rollup-linux-arm64-gnu": "4.61.1", + "@rollup/rollup-linux-arm64-musl": "4.61.1", + "@rollup/rollup-linux-loong64-gnu": "4.61.1", + "@rollup/rollup-linux-loong64-musl": "4.61.1", + "@rollup/rollup-linux-ppc64-gnu": "4.61.1", + "@rollup/rollup-linux-ppc64-musl": "4.61.1", + "@rollup/rollup-linux-riscv64-gnu": "4.61.1", + "@rollup/rollup-linux-riscv64-musl": "4.61.1", + "@rollup/rollup-linux-s390x-gnu": "4.61.1", + "@rollup/rollup-linux-x64-gnu": "4.61.1", + "@rollup/rollup-linux-x64-musl": "4.61.1", + "@rollup/rollup-openbsd-x64": "4.61.1", + "@rollup/rollup-openharmony-arm64": "4.61.1", + "@rollup/rollup-win32-arm64-msvc": "4.61.1", + "@rollup/rollup-win32-ia32-msvc": "4.61.1", + "@rollup/rollup-win32-x64-gnu": "4.61.1", + "@rollup/rollup-win32-x64-msvc": "4.61.1", "fsevents": "~2.3.2" } }, @@ -2231,9 +2284,9 @@ } }, "node_modules/tinyglobby": { - "version": "0.2.16", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", - "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "dev": true, "license": "MIT", "dependencies": { @@ -2265,9 +2318,9 @@ "license": "0BSD" }, "node_modules/vite": { - "version": "7.3.3", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.3.tgz", - "integrity": "sha512-/4XH147Ui7OGTjg3HbdWe5arnZQSbfuRzdr9Ec7TQi5I7R+ir0Rlc9GIvD4v0XZurELqA035KVXJXpR61xhiTA==", + "version": "7.3.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.5.tgz", + "integrity": "sha512-KuOaNhcnGFN2zIPGA7wRmzF+lJA1sea7rHq17aiJ++9lzY1WWG6Jpwqwe1KNbRVPIqHmr8GLYx7jbrQcN/7/ww==", "dev": true, "license": "MIT", "dependencies": { From dd2a0b715a2fde11ec05389d6ca4c3ce394bb5ce Mon Sep 17 00:00:00 2001 From: Giulio Troccoli-Allard Date: Sun, 7 Jun 2026 10:30:43 +0100 Subject: [PATCH 62/63] fix: linted files after upgrading packages --- app/Console/Commands/GeneratePdf.php | 20 ++++---------------- app/Livewire/RosterSubmission.php | 27 +++++++++++++++------------ 2 files changed, 19 insertions(+), 28 deletions(-) diff --git a/app/Console/Commands/GeneratePdf.php b/app/Console/Commands/GeneratePdf.php index 8877b9c..b1700d5 100644 --- a/app/Console/Commands/GeneratePdf.php +++ b/app/Console/Commands/GeneratePdf.php @@ -6,26 +6,14 @@ use App\Models\Game; use App\Services\Scoresheet\ScoresheetGenerator; +use Illuminate\Console\Attributes\Description; +use Illuminate\Console\Attributes\Signature; use Illuminate\Console\Command; +#[Description('Command description')] +#[Signature('app:generate-pdf')] class GeneratePdf extends Command { - /** - * The name and signature of the console command. - * - * @var string - */ - #[\Override] - protected $signature = 'app:generate-pdf'; - - /** - * The console command description. - * - * @var string - */ - #[\Override] - protected $description = 'Command description'; - public function handle(ScoresheetGenerator $generator): void { /** @var Game $game */ diff --git a/app/Livewire/RosterSubmission.php b/app/Livewire/RosterSubmission.php index c42738d..1db698c 100644 --- a/app/Livewire/RosterSubmission.php +++ b/app/Livewire/RosterSubmission.php @@ -187,10 +187,10 @@ public function returnToRoster(): void public function render(): View { $activeGame = $this->activeGame(); - $homePlayers = $activeGame?->homeTeam->players()->orderBy('id')->get() ?? collect(); - $awayPlayers = $activeGame?->awayTeam->players()->orderBy('id')->get() ?? collect(); - $homeStaff = $activeGame?->homeTeam->staff()->orderBy('id')->get() ?? collect(); - $awayStaff = $activeGame?->awayTeam->staff()->orderBy('id')->get() ?? collect(); + $homePlayers = $activeGame?->homeTeam->players()->orderBy('id')->get() ?? new EloquentCollection; + $awayPlayers = $activeGame?->awayTeam->players()->orderBy('id')->get() ?? new EloquentCollection; + $homeStaff = $activeGame?->homeTeam->staff()->orderBy('id')->get() ?? new EloquentCollection; + $awayStaff = $activeGame?->awayTeam->staff()->orderBy('id')->get() ?? new EloquentCollection; return view('livewire.roster-submission', [ 'hasSubmittedRosters' => $this->hasSubmittedRosters($activeGame), @@ -611,16 +611,9 @@ private function confirmationRosterForTeam( $selectedStaff = $selectedStaff ->map(function (Staff $staff) use (&$assistantCoachCount): array { - $roleCode = match ($staff->role) { - StaffRole::Coach => 'C', - StaffRole::AssistantCoach => 'AC'.(++$assistantCoachCount), - StaffRole::Therapist => 'T', - StaffRole::Doctor => 'D', - }; - return [ 'name' => $this->formattedPersonName($staff->last_name, $staff->first_name), - 'role' => $roleCode, + 'role' => $this->staffRoleCode($staff->role, $assistantCoachCount), ]; }) ->values(); @@ -661,4 +654,14 @@ private function staffOrder(StaffRole $role): int StaffRole::Doctor => 3, }; } + + private function staffRoleCode(StaffRole $role, int &$assistantCoachCount): string + { + return match ($role) { + StaffRole::Coach => 'C', + StaffRole::AssistantCoach => 'AC'.(++$assistantCoachCount), + StaffRole::Therapist => 'T', + StaffRole::Doctor => 'D', + }; + } } From a2c548f224b8f20f4d4202e06161db1418cdd70a Mon Sep 17 00:00:00 2001 From: Giulio Troccoli-Allard Date: Sun, 7 Jun 2026 10:34:10 +0100 Subject: [PATCH 63/63] fix: repair toss result test setup --- tests/Feature/TossResultSubmissionTest.php | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/tests/Feature/TossResultSubmissionTest.php b/tests/Feature/TossResultSubmissionTest.php index ef053b1..ab45ff2 100644 --- a/tests/Feature/TossResultSubmissionTest.php +++ b/tests/Feature/TossResultSubmissionTest.php @@ -24,7 +24,7 @@ test('submitting toss result creates a toss completed event', function (): void { $game = Game::factory()->create(); - seedRosterableTeams($game); + seedTossResultRosterableTeams($game); Livewire::test(TossResultSubmission::class, ['gameId' => $game->getKey()]) ->set('teamA', TeamSide::Away->value) @@ -71,7 +71,7 @@ test('submitting toss result fails when toss has already been recorded', function (): void { $game = Game::factory()->create(); - seedRosterableTeams($game); + seedTossResultRosterableTeams($game); $game->recordToss(TeamSide::Home, TeamAB::TeamA); Livewire::test(TossResultSubmission::class, ['gameId' => $game->getKey()]) @@ -86,7 +86,7 @@ test('submitting toss result records the event against the provided game id', function (): void { $targetGame = Game::factory()->create(); $otherGame = Game::factory()->create(); - seedRosterableTeams($targetGame); + seedTossResultRosterableTeams($targetGame); Livewire::test(TossResultSubmission::class, ['gameId' => $targetGame->getKey()]) ->set('teamA', TeamSide::Away->value) @@ -164,7 +164,7 @@ ->assertDontSee('Save Toss Result'); }); -function seedRosterableTeams(Game $game): void +function seedTossResultRosterableTeams(Game $game): void { $homePlayers = Player::factory()->for($game->homeTeam)->count(6)->create(); $awayPlayers = Player::factory()->for($game->awayTeam)->count(6)->create(); @@ -179,7 +179,11 @@ function seedRosterableTeams(Game $game): void $component->set("awayRosterInputs.{$player->getKey()}", (string) ($index + 11)); } - $component->call('submitRosters')->assertHasNoErrors(); + $component + ->set('homeCaptainSelection', (string) $homePlayers[0]->getKey()) + ->set('awayCaptainSelection', (string) $awayPlayers[0]->getKey()) + ->call('confirmRosters') + ->assertHasNoErrors(); } function tiedGameReadyForFifthSetToss(): Game