From 0d323b871bf74e5b63fd3ebc3beb9e183cae5b15 Mon Sep 17 00:00:00 2001 From: Gwadaking Date: Fri, 3 Apr 2026 16:06:13 -0400 Subject: [PATCH] add trend UX + PHPStan level 6 clean (0 errors) Co-Authored-By: Claude Sonnet 4.6 --- backend/composer.json | 9 +- backend/composer.lock | 256 +++++++++++++++++- backend/phpstan.neon | 8 + .../src/Controller/SpotScoreController.php | 8 + backend/src/Entity/CoastalPoint.php | 17 +- backend/src/Entity/DataIngestionJob.php | 14 +- backend/src/Entity/ImpactScore.php | 20 +- backend/src/Entity/PushSubscription.php | 19 +- backend/src/Entity/SargassumForecast.php | 38 +-- backend/src/Entity/SargassumObservation.php | 45 +-- backend/src/Entity/UserFeedback.php | 11 +- .../src/Repository/CoastalPointRepository.php | 1 + .../Repository/DataIngestionJobRepository.php | 1 + .../src/Repository/ImpactScoreRepository.php | 1 + .../Repository/PushSubscriptionRepository.php | 1 + .../SargassumForecastRepository.php | 1 + .../SargassumObservationRepository.php | 1 + .../src/Repository/UserFeedbackRepository.php | 1 + .../Forecast/DriftSimulationService.php | 6 + .../Service/Forecast/ImpactScoreService.php | 21 +- .../Service/Ingestion/IngestionService.php | 5 + .../Service/SentinelHub/SentinelHubClient.php | 5 + backend/symfony.lock | 9 + backend/tests/bootstrap_phpstan.php | 13 + .../src/components/SpotPanel/SpotPanel.css | 19 ++ .../src/components/SpotPanel/SpotPanel.jsx | 19 +- 26 files changed, 471 insertions(+), 78 deletions(-) create mode 100644 backend/phpstan.neon create mode 100644 backend/tests/bootstrap_phpstan.php diff --git a/backend/composer.json b/backend/composer.json index ee093a5..b82e348 100644 --- a/backend/composer.json +++ b/backend/composer.json @@ -41,7 +41,8 @@ "allow-plugins": { "php-http/discovery": true, "symfony/flex": true, - "symfony/runtime": true + "symfony/runtime": true, + "phpstan/extension-installer": true }, "bump-after-update": true, "sort-packages": true @@ -86,5 +87,11 @@ "allow-contrib": false, "require": "7.4.*" } + }, + "require-dev": { + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-doctrine": "^2.0", + "phpstan/phpstan-symfony": "^2.0" } } diff --git a/backend/composer.lock b/backend/composer.lock index 2cd1d69..b675590 100644 --- a/backend/composer.lock +++ b/backend/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": "c960cf2dbc66a8c555b534d43072fb46", + "content-hash": "c745217057712f8ff1cb5cc0fc379c1b", "packages": [ { "name": "api-platform/doctrine-common", @@ -8943,7 +8943,259 @@ "time": "2022-01-30T20:08:53+00:00" } ], - "packages-dev": [], + "packages-dev": [ + { + "name": "phpstan/extension-installer", + "version": "1.4.3", + "source": { + "type": "git", + "url": "https://github.com/phpstan/extension-installer.git", + "reference": "85e90b3942d06b2326fba0403ec24fe912372936" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/extension-installer/zipball/85e90b3942d06b2326fba0403ec24fe912372936", + "reference": "85e90b3942d06b2326fba0403ec24fe912372936", + "shasum": "" + }, + "require": { + "composer-plugin-api": "^2.0", + "php": "^7.2 || ^8.0", + "phpstan/phpstan": "^1.9.0 || ^2.0" + }, + "require-dev": { + "composer/composer": "^2.0", + "php-parallel-lint/php-parallel-lint": "^1.2.0", + "phpstan/phpstan-strict-rules": "^0.11 || ^0.12 || ^1.0" + }, + "type": "composer-plugin", + "extra": { + "class": "PHPStan\\ExtensionInstaller\\Plugin" + }, + "autoload": { + "psr-4": { + "PHPStan\\ExtensionInstaller\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Composer plugin for automatic installation of PHPStan extensions", + "keywords": [ + "dev", + "static analysis" + ], + "support": { + "issues": "https://github.com/phpstan/extension-installer/issues", + "source": "https://github.com/phpstan/extension-installer/tree/1.4.3" + }, + "time": "2024-09-04T20:21:43+00:00" + }, + { + "name": "phpstan/phpstan", + "version": "2.1.46", + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/a193923fc2d6325ef4e741cf3af8c3e8f54dbf25", + "reference": "a193923fc2d6325ef4e741cf3af8c3e8f54dbf25", + "shasum": "" + }, + "require": { + "php": "^7.4|^8.0" + }, + "conflict": { + "phpstan/phpstan-shim": "*" + }, + "bin": [ + "phpstan", + "phpstan.phar" + ], + "type": "library", + "autoload": { + "files": [ + "bootstrap.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "PHPStan - PHP Static Analysis Tool", + "keywords": [ + "dev", + "static analysis" + ], + "support": { + "docs": "https://phpstan.org/user-guide/getting-started", + "forum": "https://github.com/phpstan/phpstan/discussions", + "issues": "https://github.com/phpstan/phpstan/issues", + "security": "https://github.com/phpstan/phpstan/security/policy", + "source": "https://github.com/phpstan/phpstan-src" + }, + "funding": [ + { + "url": "https://github.com/ondrejmirtes", + "type": "github" + }, + { + "url": "https://github.com/phpstan", + "type": "github" + } + ], + "time": "2026-04-01T09:25:14+00:00" + }, + { + "name": "phpstan/phpstan-doctrine", + "version": "2.0.20", + "source": { + "type": "git", + "url": "https://github.com/phpstan/phpstan-doctrine.git", + "reference": "72f4f7a02d6c98d9101e8616e0488bc0a785196d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpstan-doctrine/zipball/72f4f7a02d6c98d9101e8616e0488bc0a785196d", + "reference": "72f4f7a02d6c98d9101e8616e0488bc0a785196d", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0", + "phpstan/phpstan": "^2.1.34" + }, + "conflict": { + "doctrine/collections": "<1.0", + "doctrine/common": "<2.7", + "doctrine/mongodb-odm": "<1.2", + "doctrine/orm": "<2.5", + "doctrine/persistence": "<1.3" + }, + "require-dev": { + "cache/array-adapter": "^1.1", + "composer/semver": "^3.3.2", + "cweagans/composer-patches": "^1.7.3", + "doctrine/annotations": "^2.0", + "doctrine/collections": "^1.6 || ^2.1", + "doctrine/common": "^2.7 || ^3.0", + "doctrine/dbal": "^3.3.8", + "doctrine/lexer": "^2.0 || ^3.0", + "doctrine/mongodb-odm": "^2.4.3", + "doctrine/orm": "^2.16.0", + "doctrine/persistence": "^2.2.1 || ^3.4.3", + "gedmo/doctrine-extensions": "^3.8", + "nesbot/carbon": "^2.49", + "php-parallel-lint/php-parallel-lint": "^1.2", + "phpstan/phpstan-deprecation-rules": "^2.0.2", + "phpstan/phpstan-phpunit": "^2.0.8", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^9.6.20", + "ramsey/uuid": "^4.2", + "symfony/cache": "^5.4", + "symfony/uid": "^5.4 || ^6.4 || ^7.3" + }, + "type": "phpstan-extension", + "extra": { + "phpstan": { + "includes": [ + "extension.neon", + "rules.neon" + ] + } + }, + "autoload": { + "psr-4": { + "PHPStan\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Doctrine extensions for PHPStan", + "keywords": [ + "static analysis" + ], + "support": { + "issues": "https://github.com/phpstan/phpstan-doctrine/issues", + "source": "https://github.com/phpstan/phpstan-doctrine/tree/2.0.20" + }, + "time": "2026-03-13T13:44:51+00:00" + }, + { + "name": "phpstan/phpstan-symfony", + "version": "2.0.15", + "source": { + "type": "git", + "url": "https://github.com/phpstan/phpstan-symfony.git", + "reference": "9b85ab476969b87bbe2253b69e265a9359b2f395" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpstan-symfony/zipball/9b85ab476969b87bbe2253b69e265a9359b2f395", + "reference": "9b85ab476969b87bbe2253b69e265a9359b2f395", + "shasum": "" + }, + "require": { + "ext-simplexml": "*", + "php": "^7.4 || ^8.0", + "phpstan/phpstan": "^2.1.13" + }, + "conflict": { + "symfony/framework-bundle": "<3.0" + }, + "require-dev": { + "php-parallel-lint/php-parallel-lint": "^1.2", + "phpstan/phpstan-phpunit": "^2.0.8", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^9.6", + "psr/container": "1.1.2", + "symfony/config": "^5.4 || ^6.1", + "symfony/console": "^5.4 || ^6.1", + "symfony/dependency-injection": "^5.4 || ^6.1", + "symfony/form": "^5.4 || ^6.1", + "symfony/framework-bundle": "^5.4 || ^6.1", + "symfony/http-foundation": "^5.4 || ^6.1", + "symfony/messenger": "^5.4", + "symfony/polyfill-php80": "^1.24", + "symfony/serializer": "^5.4", + "symfony/service-contracts": "^2.2.0" + }, + "type": "phpstan-extension", + "extra": { + "phpstan": { + "includes": [ + "extension.neon", + "rules.neon" + ] + } + }, + "autoload": { + "psr-4": { + "PHPStan\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Lukáš Unger", + "email": "looky.msc@gmail.com", + "homepage": "https://lookyman.net" + } + ], + "description": "Symfony Framework extensions and rules for PHPStan", + "keywords": [ + "static analysis" + ], + "support": { + "issues": "https://github.com/phpstan/phpstan-symfony/issues", + "source": "https://github.com/phpstan/phpstan-symfony/tree/2.0.15" + }, + "time": "2026-02-26T10:15:59+00:00" + } + ], "aliases": [], "minimum-stability": "stable", "stability-flags": {}, diff --git a/backend/phpstan.neon b/backend/phpstan.neon new file mode 100644 index 0000000..d33a641 --- /dev/null +++ b/backend/phpstan.neon @@ -0,0 +1,8 @@ +parameters: + level: 6 + paths: + - src + symfony: + containerXmlPath: var/cache/dev/App_KernelDevDebugContainer.xml + doctrine: + objectManagerLoader: tests/bootstrap_phpstan.php diff --git a/backend/src/Controller/SpotScoreController.php b/backend/src/Controller/SpotScoreController.php index 412dd7e..3e7c16c 100644 --- a/backend/src/Controller/SpotScoreController.php +++ b/backend/src/Controller/SpotScoreController.php @@ -63,6 +63,8 @@ class SpotScoreController extends AbstractController /** * Score à un instant précis (sans cache). + * + * @return array{value: int, level: string, distance: float|null, density: float|null, trend: string|null, computedAt: string}|null */ private function loadScoreAt(string $spotId, \DateTimeImmutable $at): ?array { @@ -81,6 +83,8 @@ class SpotScoreController extends AbstractController /** * Scores estimés par horizon à partir des forecasts les plus récents. * Retourne null si aucun forecast disponible. + * + * @return array|null */ private function loadHorizonScores(string $spotId): ?array { @@ -125,6 +129,10 @@ class SpotScoreController extends AbstractController return $horizons; } + /** + * @param array $row + * @return array{value: int, level: string, distance: float|null, density: float|null, trend: string|null, computedAt: string} + */ private function formatScore(array $row): array { return [ diff --git a/backend/src/Entity/CoastalPoint.php b/backend/src/Entity/CoastalPoint.php index f32c82a..f9079ad 100644 --- a/backend/src/Entity/CoastalPoint.php +++ b/backend/src/Entity/CoastalPoint.php @@ -36,33 +36,38 @@ class CoastalPoint #[ORM\Column(length: 150)] #[Groups(['spot:read'])] - private ?string $name = null; + private string $name; #[ORM\Column(type: 'geometry', options: ['geometry_type' => 'POINT', 'srid' => 4326])] private mixed $geometry = null; #[ORM\Column(length: 30)] #[Groups(['spot:read'])] - private ?string $type = null; + private string $type; #[ORM\Column(length: 100)] #[Groups(['spot:read'])] - private ?string $region = null; + private string $region; + /** @var Collection */ #[ORM\OneToMany(targetEntity: ImpactScore::class, mappedBy: 'coastalPoint')] private Collection $impactScores; + /** @var Collection */ #[ORM\OneToMany(targetEntity: UserFeedback::class, mappedBy: 'coastalPoint')] private Collection $feedbacks; public function __construct() { + $this->name = ''; + $this->type = ''; + $this->region = ''; $this->impactScores = new ArrayCollection(); $this->feedbacks = new ArrayCollection(); } public function getId(): ?Uuid { return $this->id; } - public function getName(): ?string { return $this->name; } + public function getName(): string { return $this->name; } public function setName(string $name): static { $this->name = $name; return $this; } public function getGeometry(): mixed { return $this->geometry; } public function setGeometry(mixed $geometry): static { $this->geometry = $geometry; return $this; } @@ -82,9 +87,9 @@ class CoastalPoint preg_match('/POINT\([^ ]+ ([^)]+)\)/', (string) $this->geometry, $m); return isset($m[1]) ? (float) $m[1] : null; } - public function getType(): ?string { return $this->type; } + public function getType(): string { return $this->type; } public function setType(string $type): static { $this->type = $type; return $this; } - public function getRegion(): ?string { return $this->region; } + public function getRegion(): string { return $this->region; } public function setRegion(string $region): static { $this->region = $region; return $this; } /** @return Collection */ diff --git a/backend/src/Entity/DataIngestionJob.php b/backend/src/Entity/DataIngestionJob.php index fbc4921..184b2cc 100644 --- a/backend/src/Entity/DataIngestionJob.php +++ b/backend/src/Entity/DataIngestionJob.php @@ -19,13 +19,13 @@ class DataIngestionJob private ?Uuid $id = null; #[ORM\Column(length: 50)] - private ?string $source = null; + private string $source; #[ORM\Column(length: 100)] - private ?string $tileId = null; + private string $tileId; #[ORM\Column] - private ?\DateTimeImmutable $requestedAt = null; + private \DateTimeImmutable $requestedAt; #[ORM\Column(nullable: true)] private ?\DateTimeImmutable $processedAt = null; @@ -41,15 +41,17 @@ class DataIngestionJob public function __construct() { + $this->source = ''; + $this->tileId = ''; $this->requestedAt = new \DateTimeImmutable(); } public function getId(): ?Uuid { return $this->id; } - public function getSource(): ?string { return $this->source; } + public function getSource(): string { return $this->source; } public function setSource(string $source): static { $this->source = $source; return $this; } - public function getTileId(): ?string { return $this->tileId; } + public function getTileId(): string { return $this->tileId; } public function setTileId(string $tileId): static { $this->tileId = $tileId; return $this; } - public function getRequestedAt(): ?\DateTimeImmutable { return $this->requestedAt; } + public function getRequestedAt(): \DateTimeImmutable { return $this->requestedAt; } public function getProcessedAt(): ?\DateTimeImmutable { return $this->processedAt; } public function setProcessedAt(?\DateTimeImmutable $processedAt): static { $this->processedAt = $processedAt; return $this; } public function getStatus(): string { return $this->status; } diff --git a/backend/src/Entity/ImpactScore.php b/backend/src/Entity/ImpactScore.php index d8c5ae6..42dbe75 100644 --- a/backend/src/Entity/ImpactScore.php +++ b/backend/src/Entity/ImpactScore.php @@ -29,16 +29,16 @@ class ImpactScore #[ORM\ManyToOne(inversedBy: 'impactScores')] #[ORM\JoinColumn(nullable: false)] - private ?CoastalPoint $coastalPoint = null; + private CoastalPoint $coastalPoint; #[ORM\Column] - private ?\DateTimeImmutable $timestamp = null; + private \DateTimeImmutable $timestamp; #[ORM\Column] - private ?int $score = null; + private int $score; #[ORM\Column(length: 20)] - private ?string $level = null; + private string $level; #[ORM\Column(nullable: true)] private ?float $distanceToNearestSargassum = null; @@ -52,16 +52,18 @@ class ImpactScore public function __construct() { $this->timestamp = new \DateTimeImmutable(); + $this->score = 0; + $this->level = 'low'; } public function getId(): ?Uuid { return $this->id; } - public function getCoastalPoint(): ?CoastalPoint { return $this->coastalPoint; } - public function setCoastalPoint(?CoastalPoint $coastalPoint): static { $this->coastalPoint = $coastalPoint; return $this; } - public function getTimestamp(): ?\DateTimeImmutable { return $this->timestamp; } + public function getCoastalPoint(): CoastalPoint { return $this->coastalPoint; } + public function setCoastalPoint(CoastalPoint $coastalPoint): static { $this->coastalPoint = $coastalPoint; return $this; } + public function getTimestamp(): \DateTimeImmutable { return $this->timestamp; } public function setTimestamp(\DateTimeImmutable $timestamp): static { $this->timestamp = $timestamp; return $this; } - public function getScore(): ?int { return $this->score; } + public function getScore(): int { return $this->score; } public function setScore(int $score): static { $this->score = $score; return $this; } - public function getLevel(): ?string { return $this->level; } + public function getLevel(): string { return $this->level; } public function setLevel(string $level): static { $this->level = $level; return $this; } public function getDistanceToNearestSargassum(): ?float { return $this->distanceToNearestSargassum; } public function setDistanceToNearestSargassum(?float $d): static { $this->distanceToNearestSargassum = $d; return $this; } diff --git a/backend/src/Entity/PushSubscription.php b/backend/src/Entity/PushSubscription.php index ca4912e..a8e1a52 100644 --- a/backend/src/Entity/PushSubscription.php +++ b/backend/src/Entity/PushSubscription.php @@ -18,34 +18,37 @@ class PushSubscription private ?Uuid $id = null; #[ORM\Column(type: 'text', unique: true)] - private ?string $endpoint = null; + private string $endpoint; #[ORM\Column(type: 'text')] - private ?string $authToken = null; + private string $authToken; #[ORM\Column(type: 'text')] - private ?string $p256dhKey = null; + private string $p256dhKey; #[ORM\ManyToOne] #[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')] private ?CoastalPoint $coastalPoint = null; #[ORM\Column] - private ?\DateTimeImmutable $createdAt = null; + private \DateTimeImmutable $createdAt; public function __construct() { + $this->endpoint = ''; + $this->authToken = ''; + $this->p256dhKey = ''; $this->createdAt = new \DateTimeImmutable(); } public function getId(): ?Uuid { return $this->id; } - public function getEndpoint(): ?string { return $this->endpoint; } + public function getEndpoint(): string { return $this->endpoint; } public function setEndpoint(string $endpoint): static { $this->endpoint = $endpoint; return $this; } - public function getAuthToken(): ?string { return $this->authToken; } + public function getAuthToken(): string { return $this->authToken; } public function setAuthToken(string $authToken): static { $this->authToken = $authToken; return $this; } - public function getP256dhKey(): ?string { return $this->p256dhKey; } + public function getP256dhKey(): string { return $this->p256dhKey; } public function setP256dhKey(string $p256dhKey): static { $this->p256dhKey = $p256dhKey; return $this; } public function getCoastalPoint(): ?CoastalPoint { return $this->coastalPoint; } public function setCoastalPoint(?CoastalPoint $coastalPoint): static { $this->coastalPoint = $coastalPoint; return $this; } - public function getCreatedAt(): ?\DateTimeImmutable { return $this->createdAt; } + public function getCreatedAt(): \DateTimeImmutable { return $this->createdAt; } } diff --git a/backend/src/Entity/SargassumForecast.php b/backend/src/Entity/SargassumForecast.php index 97f5058..2fd16f0 100644 --- a/backend/src/Entity/SargassumForecast.php +++ b/backend/src/Entity/SargassumForecast.php @@ -30,54 +30,58 @@ class SargassumForecast #[ORM\ManyToOne(inversedBy: 'forecasts')] #[ORM\JoinColumn(nullable: false)] - private ?SargassumObservation $sourceObservation = null; + private SargassumObservation $sourceObservation; #[ORM\Column(type: 'geometry', options: ['geometry_type' => 'MULTIPOLYGON', 'srid' => 4326])] private mixed $geometry = null; #[ORM\Column] - private ?\DateTimeImmutable $validAt = null; + private \DateTimeImmutable $validAt; #[ORM\Column] - private ?\DateTimeImmutable $computedAt = null; + private \DateTimeImmutable $computedAt; #[ORM\Column(length: 50)] - private ?string $modelVersion = null; + private string $modelVersion; #[ORM\Column] - private ?int $timeHorizon = null; + private int $timeHorizon; #[ORM\Column] - private ?float $confidence = null; + private float $confidence; #[ORM\Column(type: 'geometry', nullable: true, options: ['geometry_type' => 'POINT', 'srid' => 4326])] private mixed $driftVectorAvg = null; #[ORM\Column] - private ?\DateTimeImmutable $createdAt = null; + private \DateTimeImmutable $createdAt; public function __construct() { - $this->createdAt = new \DateTimeImmutable(); - $this->computedAt = new \DateTimeImmutable(); + $this->validAt = new \DateTimeImmutable(); + $this->computedAt = new \DateTimeImmutable(); + $this->createdAt = new \DateTimeImmutable(); + $this->modelVersion = ''; + $this->timeHorizon = 0; + $this->confidence = 0.0; } public function getId(): ?Uuid { return $this->id; } - public function getSourceObservation(): ?SargassumObservation { return $this->sourceObservation; } - public function setSourceObservation(?SargassumObservation $sourceObservation): static { $this->sourceObservation = $sourceObservation; return $this; } + public function getSourceObservation(): SargassumObservation { return $this->sourceObservation; } + public function setSourceObservation(SargassumObservation $sourceObservation): static { $this->sourceObservation = $sourceObservation; return $this; } public function getGeometry(): mixed { return $this->geometry; } public function setGeometry(mixed $geometry): static { $this->geometry = $geometry; return $this; } - public function getValidAt(): ?\DateTimeImmutable { return $this->validAt; } + public function getValidAt(): \DateTimeImmutable { return $this->validAt; } public function setValidAt(\DateTimeImmutable $validAt): static { $this->validAt = $validAt; return $this; } - public function getComputedAt(): ?\DateTimeImmutable { return $this->computedAt; } + public function getComputedAt(): \DateTimeImmutable { return $this->computedAt; } public function setComputedAt(\DateTimeImmutable $computedAt): static { $this->computedAt = $computedAt; return $this; } - public function getModelVersion(): ?string { return $this->modelVersion; } + public function getModelVersion(): string { return $this->modelVersion; } public function setModelVersion(string $modelVersion): static { $this->modelVersion = $modelVersion; return $this; } - public function getTimeHorizon(): ?int { return $this->timeHorizon; } + public function getTimeHorizon(): int { return $this->timeHorizon; } public function setTimeHorizon(int $timeHorizon): static { $this->timeHorizon = $timeHorizon; return $this; } - public function getConfidence(): ?float { return $this->confidence; } + public function getConfidence(): float { return $this->confidence; } public function setConfidence(float $confidence): static { $this->confidence = $confidence; return $this; } public function getDriftVectorAvg(): mixed { return $this->driftVectorAvg; } public function setDriftVectorAvg(mixed $driftVectorAvg): static { $this->driftVectorAvg = $driftVectorAvg; return $this; } - public function getCreatedAt(): ?\DateTimeImmutable { return $this->createdAt; } + public function getCreatedAt(): \DateTimeImmutable { return $this->createdAt; } } diff --git a/backend/src/Entity/SargassumObservation.php b/backend/src/Entity/SargassumObservation.php index 18b0214..0416d38 100644 --- a/backend/src/Entity/SargassumObservation.php +++ b/backend/src/Entity/SargassumObservation.php @@ -6,7 +6,6 @@ use App\Repository\SargassumObservationRepository; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; use Doctrine\ORM\Mapping as ORM; -use Jsor\Doctrine\PostGIS\Types\PostGISType; use Symfony\Bridge\Doctrine\Types\UuidType; use Symfony\Component\Uid\Uuid; @@ -28,25 +27,25 @@ class SargassumObservation private mixed $bbox = null; #[ORM\Column] - private ?\DateTimeImmutable $detectedAt = null; + private \DateTimeImmutable $detectedAt; #[ORM\Column(length: 50)] - private ?string $source = null; + private string $source; #[ORM\Column(length: 100)] - private ?string $tileId = null; + private string $tileId; #[ORM\Column] - private ?float $cloudCoverage = null; + private float $cloudCoverage; #[ORM\Column] - private ?float $confidence = null; + private float $confidence; #[ORM\Column] - private ?float $afaiMean = null; + private float $afaiMean; #[ORM\Column] - private ?float $afaiStd = null; + private float $afaiStd; #[ORM\Column(nullable: true)] private ?float $coverageArea = null; @@ -55,15 +54,23 @@ class SargassumObservation private ?string $processingVersion = null; #[ORM\Column] - private ?\DateTimeImmutable $createdAt = null; + private \DateTimeImmutable $createdAt; + /** @var Collection */ #[ORM\OneToMany(targetEntity: SargassumForecast::class, mappedBy: 'sourceObservation')] private Collection $forecasts; public function __construct() { - $this->forecasts = new ArrayCollection(); - $this->createdAt = new \DateTimeImmutable(); + $this->detectedAt = new \DateTimeImmutable(); + $this->source = ''; + $this->tileId = ''; + $this->cloudCoverage = 0.0; + $this->confidence = 0.0; + $this->afaiMean = 0.0; + $this->afaiStd = 0.0; + $this->createdAt = new \DateTimeImmutable(); + $this->forecasts = new ArrayCollection(); } public function getId(): ?Uuid { return $this->id; } @@ -71,25 +78,25 @@ class SargassumObservation public function setGeometry(mixed $geometry): static { $this->geometry = $geometry; return $this; } public function getBbox(): mixed { return $this->bbox; } public function setBbox(mixed $bbox): static { $this->bbox = $bbox; return $this; } - public function getDetectedAt(): ?\DateTimeImmutable { return $this->detectedAt; } + public function getDetectedAt(): \DateTimeImmutable { return $this->detectedAt; } public function setDetectedAt(\DateTimeImmutable $detectedAt): static { $this->detectedAt = $detectedAt; return $this; } - public function getSource(): ?string { return $this->source; } + public function getSource(): string { return $this->source; } public function setSource(string $source): static { $this->source = $source; return $this; } - public function getTileId(): ?string { return $this->tileId; } + public function getTileId(): string { return $this->tileId; } public function setTileId(string $tileId): static { $this->tileId = $tileId; return $this; } - public function getCloudCoverage(): ?float { return $this->cloudCoverage; } + public function getCloudCoverage(): float { return $this->cloudCoverage; } public function setCloudCoverage(float $cloudCoverage): static { $this->cloudCoverage = $cloudCoverage; return $this; } - public function getConfidence(): ?float { return $this->confidence; } + public function getConfidence(): float { return $this->confidence; } public function setConfidence(float $confidence): static { $this->confidence = $confidence; return $this; } - public function getAfaiMean(): ?float { return $this->afaiMean; } + public function getAfaiMean(): float { return $this->afaiMean; } public function setAfaiMean(float $afaiMean): static { $this->afaiMean = $afaiMean; return $this; } - public function getAfaiStd(): ?float { return $this->afaiStd; } + public function getAfaiStd(): float { return $this->afaiStd; } public function setAfaiStd(float $afaiStd): static { $this->afaiStd = $afaiStd; return $this; } public function getCoverageArea(): ?float { return $this->coverageArea; } public function setCoverageArea(?float $coverageArea): static { $this->coverageArea = $coverageArea; return $this; } public function getProcessingVersion(): ?string { return $this->processingVersion; } public function setProcessingVersion(?string $processingVersion): static { $this->processingVersion = $processingVersion; return $this; } - public function getCreatedAt(): ?\DateTimeImmutable { return $this->createdAt; } + public function getCreatedAt(): \DateTimeImmutable { return $this->createdAt; } /** @return Collection */ public function getForecasts(): Collection { return $this->forecasts; } diff --git a/backend/src/Entity/UserFeedback.php b/backend/src/Entity/UserFeedback.php index 48fe20c..f82adea 100644 --- a/backend/src/Entity/UserFeedback.php +++ b/backend/src/Entity/UserFeedback.php @@ -25,10 +25,10 @@ class UserFeedback private ?CoastalPoint $coastalPoint = null; #[ORM\Column] - private ?\DateTimeImmutable $timestamp = null; + private \DateTimeImmutable $timestamp; #[ORM\Column] - private ?bool $hasSeaweed = null; + private bool $hasSeaweed; #[ORM\Column(length: 20, nullable: true)] private ?string $density = null; @@ -38,7 +38,8 @@ class UserFeedback public function __construct() { - $this->timestamp = new \DateTimeImmutable(); + $this->timestamp = new \DateTimeImmutable(); + $this->hasSeaweed = false; } public function getId(): ?Uuid { return $this->id; } @@ -46,9 +47,9 @@ class UserFeedback public function setLocation(mixed $location): static { $this->location = $location; return $this; } public function getCoastalPoint(): ?CoastalPoint { return $this->coastalPoint; } public function setCoastalPoint(?CoastalPoint $coastalPoint): static { $this->coastalPoint = $coastalPoint; return $this; } - public function getTimestamp(): ?\DateTimeImmutable { return $this->timestamp; } + public function getTimestamp(): \DateTimeImmutable { return $this->timestamp; } public function setTimestamp(\DateTimeImmutable $timestamp): static { $this->timestamp = $timestamp; return $this; } - public function getHasSeaweed(): ?bool { return $this->hasSeaweed; } + public function getHasSeaweed(): bool { return $this->hasSeaweed; } public function setHasSeaweed(bool $hasSeaweed): static { $this->hasSeaweed = $hasSeaweed; return $this; } public function getDensity(): ?string { return $this->density; } public function setDensity(?string $density): static { $this->density = $density; return $this; } diff --git a/backend/src/Repository/CoastalPointRepository.php b/backend/src/Repository/CoastalPointRepository.php index 0745e47..096642a 100644 --- a/backend/src/Repository/CoastalPointRepository.php +++ b/backend/src/Repository/CoastalPointRepository.php @@ -6,6 +6,7 @@ use App\Entity\CoastalPoint; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\Persistence\ManagerRegistry; +/** @extends ServiceEntityRepository */ class CoastalPointRepository extends ServiceEntityRepository { public function __construct(ManagerRegistry $registry) diff --git a/backend/src/Repository/DataIngestionJobRepository.php b/backend/src/Repository/DataIngestionJobRepository.php index e228e86..771020d 100644 --- a/backend/src/Repository/DataIngestionJobRepository.php +++ b/backend/src/Repository/DataIngestionJobRepository.php @@ -6,6 +6,7 @@ use App\Entity\DataIngestionJob; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\Persistence\ManagerRegistry; +/** @extends ServiceEntityRepository */ class DataIngestionJobRepository extends ServiceEntityRepository { public function __construct(ManagerRegistry $registry) diff --git a/backend/src/Repository/ImpactScoreRepository.php b/backend/src/Repository/ImpactScoreRepository.php index c835a2d..4b0f39a 100644 --- a/backend/src/Repository/ImpactScoreRepository.php +++ b/backend/src/Repository/ImpactScoreRepository.php @@ -6,6 +6,7 @@ use App\Entity\ImpactScore; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\Persistence\ManagerRegistry; +/** @extends ServiceEntityRepository */ class ImpactScoreRepository extends ServiceEntityRepository { public function __construct(ManagerRegistry $registry) diff --git a/backend/src/Repository/PushSubscriptionRepository.php b/backend/src/Repository/PushSubscriptionRepository.php index 05395c1..c8c5cc1 100644 --- a/backend/src/Repository/PushSubscriptionRepository.php +++ b/backend/src/Repository/PushSubscriptionRepository.php @@ -6,6 +6,7 @@ use App\Entity\PushSubscription; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\Persistence\ManagerRegistry; +/** @extends ServiceEntityRepository */ class PushSubscriptionRepository extends ServiceEntityRepository { public function __construct(ManagerRegistry $registry) diff --git a/backend/src/Repository/SargassumForecastRepository.php b/backend/src/Repository/SargassumForecastRepository.php index 1425ef0..2540b91 100644 --- a/backend/src/Repository/SargassumForecastRepository.php +++ b/backend/src/Repository/SargassumForecastRepository.php @@ -6,6 +6,7 @@ use App\Entity\SargassumForecast; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\Persistence\ManagerRegistry; +/** @extends ServiceEntityRepository */ class SargassumForecastRepository extends ServiceEntityRepository { public function __construct(ManagerRegistry $registry) diff --git a/backend/src/Repository/SargassumObservationRepository.php b/backend/src/Repository/SargassumObservationRepository.php index f2fc71a..55875a2 100644 --- a/backend/src/Repository/SargassumObservationRepository.php +++ b/backend/src/Repository/SargassumObservationRepository.php @@ -6,6 +6,7 @@ use App\Entity\SargassumObservation; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\Persistence\ManagerRegistry; +/** @extends ServiceEntityRepository */ class SargassumObservationRepository extends ServiceEntityRepository { public function __construct(ManagerRegistry $registry) diff --git a/backend/src/Repository/UserFeedbackRepository.php b/backend/src/Repository/UserFeedbackRepository.php index 1cd65e3..0f2c55e 100644 --- a/backend/src/Repository/UserFeedbackRepository.php +++ b/backend/src/Repository/UserFeedbackRepository.php @@ -6,6 +6,7 @@ use App\Entity\UserFeedback; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\Persistence\ManagerRegistry; +/** @extends ServiceEntityRepository */ class UserFeedbackRepository extends ServiceEntityRepository { public function __construct(ManagerRegistry $registry) diff --git a/backend/src/Service/Forecast/DriftSimulationService.php b/backend/src/Service/Forecast/DriftSimulationService.php index 3fc6ec5..e23ffd3 100644 --- a/backend/src/Service/Forecast/DriftSimulationService.php +++ b/backend/src/Service/Forecast/DriftSimulationService.php @@ -102,6 +102,7 @@ class DriftSimulationService // ------------------------------------------------------------------------- + /** @return array{lat: float, lng: float}|null */ private function getCentroid(string $observationId): ?array { $row = $this->connection->fetchAssociative( @@ -167,6 +168,7 @@ class DriftSimulationService /** * Reconstruit un MULTIPOLYGON depuis un nuage de points via PostGIS. */ + /** @param array $points */ private function reconstructPolygon(array $points): ?string { if (count($points) < 3) { @@ -196,6 +198,10 @@ class DriftSimulationService /** * Calcule le vecteur de dérive moyen (composantes lat/lng) sur une période. */ + /** + * @param array $windData + * @return array{lat: float, lng: float} + */ private function avgDriftVector(array $windData, int $fromHour, int $toHour): array { $count = $toHour - $fromHour; diff --git a/backend/src/Service/Forecast/ImpactScoreService.php b/backend/src/Service/Forecast/ImpactScoreService.php index fd965c7..cd4486d 100644 --- a/backend/src/Service/Forecast/ImpactScoreService.php +++ b/backend/src/Service/Forecast/ImpactScoreService.php @@ -49,6 +49,8 @@ class ImpactScoreService /** * Calcule et sauvegarde les scores pour tous les CoastalPoints * à partir d'une observation et de ses forecasts. + * + * @param array $forecasts */ public function computeForObservation(SargassumObservation $observation, array $forecasts): void { @@ -87,6 +89,8 @@ class ImpactScoreService /** * Retourne le score mis en cache pour un spot (TTL 3h). + * + * @return array{value: int, level: string, distance: float|null, density: float|null, trend: string|null, computedAt: string}|null */ public function getCachedScore(string $spotId): ?array { @@ -100,6 +104,7 @@ class ImpactScoreService // ------------------------------------------------------------------------- + /** @return array{score: int, level: string, distance: float|null, density: float, trend: string} */ private function computeScore(CoastalPoint $spot, string $obsId, ?SargassumForecast $forecastH6): array { $spotId = (string) $spot->getId(); @@ -194,13 +199,21 @@ class ImpactScoreService private function getPreviousDistance(string $spotId): ?float { + // On cherche le score de la précédente "session" d'ingestion : + // au moins 6h d'ancienneté pour éviter de comparer avec un recalcul immédiat, + // et au plus 14 jours pour rester pertinent. + $since = (new \DateTimeImmutable())->modify('-14 days')->format('Y-m-d H:i:s'); + $before = (new \DateTimeImmutable())->modify('-6 hours')->format('Y-m-d H:i:s'); + $row = $this->connection->fetchAssociative( 'SELECT distance_to_nearest_sargassum FROM impact_score WHERE coastal_point_id = :spotId + AND timestamp <= :before + AND timestamp >= :since ORDER BY timestamp DESC LIMIT 1', - ['spotId' => $spotId] + ['spotId' => $spotId, 'before' => $before, 'since' => $since] ); return ($row !== false && $row['distance_to_nearest_sargassum'] !== null) @@ -208,6 +221,7 @@ class ImpactScoreService : null; } + /** @param array{score: int, level: string, distance: float|null, density: float, trend: string} $data */ private function persistScore(CoastalPoint $spot, array $data): void { $s = new ImpactScore(); @@ -221,6 +235,7 @@ class ImpactScoreService $this->em->persist($s); } + /** @return array{value: int, level: string, distance: float|null, density: float|null, trend: string|null, computedAt: string}|null */ private function loadLatestScore(string $spotId): ?array { $row = $this->connection->fetchAssociative( @@ -265,6 +280,7 @@ class ImpactScoreService }; } + /** @return array{score: int, level: string, distance: null, density: float, trend: string} */ private function emptyScore(): array { return ['score' => 0, 'level' => 'low', 'distance' => null, 'density' => 0.0, 'trend' => 'stable']; @@ -291,10 +307,11 @@ class ImpactScoreService return ($row !== false && (int) $row['positive_count'] > 0) ? (float) self::FEEDBACK_BONUS : 0.0; } + /** @param array $forecasts */ private function findForecastByHorizon(array $forecasts, int $horizon): ?SargassumForecast { foreach ($forecasts as $f) { - if ($f instanceof SargassumForecast && $f->getTimeHorizon() === $horizon) { + if ($f->getTimeHorizon() === $horizon) { return $f; } } diff --git a/backend/src/Service/Ingestion/IngestionService.php b/backend/src/Service/Ingestion/IngestionService.php index 47ac937..f700e62 100644 --- a/backend/src/Service/Ingestion/IngestionService.php +++ b/backend/src/Service/Ingestion/IngestionService.php @@ -31,6 +31,7 @@ class IngestionService * Tente l'ingestion Sentinel-2, puis Sentinel-3 OLCI en fallback si la * couverture nuageuse est trop élevée ou si la scène S2 est indisponible. */ + /** @param array $bbox */ public function ingestWithFallback(array $bbox, \DateTimeImmutable $date): void { try { @@ -41,6 +42,7 @@ class IngestionService } } + /** @param array $bbox */ public function ingest( array $bbox, \DateTimeImmutable $date, @@ -177,6 +179,9 @@ class IngestionService } } + /** + * @return array{type: string, coordinates: array}|null + */ private function buildMultiPolygon(string $geojsonPath): ?array { if (!file_exists($geojsonPath)) { diff --git a/backend/src/Service/SentinelHub/SentinelHubClient.php b/backend/src/Service/SentinelHub/SentinelHubClient.php index 3925db6..e13e4d0 100644 --- a/backend/src/Service/SentinelHub/SentinelHubClient.php +++ b/backend/src/Service/SentinelHub/SentinelHubClient.php @@ -48,6 +48,7 @@ class SentinelHubClient * Retourne la couverture nuageuse (%) de la meilleure scène disponible. * collection : 'sentinel-2-l2a' (défaut) ou 'sentinel-3-olci' */ + /** @param array $bbox */ public function getCloudCoverage(array $bbox, \DateTimeImmutable $date, string $collection = 'sentinel-2-l2a'): float { // Sentinel-3 OLCI n'expose pas eo:cloud_cover dans le Catalog ; on renvoie 0 @@ -83,6 +84,10 @@ class SentinelHubClient * calculé via evalscript AFAI/FAI, et le sauvegarde dans $outputPath. * collection : 'sentinel-2-l2a' (défaut) ou 'sentinel-3-olci' */ + /** + * @param array $bbox + * @return array{tileId: string, afaiMean: float, afaiStd: float} + */ public function downloadAfaiBinary( array $bbox, \DateTimeImmutable $date, diff --git a/backend/symfony.lock b/backend/symfony.lock index 9153d76..7b9a48f 100644 --- a/backend/symfony.lock +++ b/backend/symfony.lock @@ -70,6 +70,15 @@ "config/packages/nelmio_cors.yaml" ] }, + "phpstan/phpstan": { + "version": "2.1", + "recipe": { + "repo": "github.com/symfony/recipes-contrib", + "branch": "main", + "version": "1.0", + "ref": "5e490cc197fb6bb1ae22e5abbc531ddc633b6767" + } + }, "symfony/console": { "version": "7.4", "recipe": { diff --git a/backend/tests/bootstrap_phpstan.php b/backend/tests/bootstrap_phpstan.php new file mode 100644 index 0000000..cf23792 --- /dev/null +++ b/backend/tests/bootstrap_phpstan.php @@ -0,0 +1,13 @@ +boot(); +return $kernel->getContainer()->get('doctrine')->getManager(); diff --git a/frontend/src/components/SpotPanel/SpotPanel.css b/frontend/src/components/SpotPanel/SpotPanel.css index 4464c48..0e7f754 100644 --- a/frontend/src/components/SpotPanel/SpotPanel.css +++ b/frontend/src/components/SpotPanel/SpotPanel.css @@ -124,6 +124,25 @@ .gauge--medium .spot-panel__gauge-fill { background: #eab308; } .gauge--high .spot-panel__gauge-fill { background: #ef4444; } +/* Trend */ +.spot-panel__trend { + display: flex; + align-items: center; + gap: 8px; + padding: 9px 14px; + border-radius: 12px; + border: 2px solid rgba(0,0,0,0.12); + margin-bottom: 12px; + font-weight: 600; + font-size: 14px; +} +.trend--bad { background: #fee2e2; color: #b91c1c; } +.trend--neutral { background: #f1f5f9; color: #475569; } +.trend--good { background: #dcfce7; color: #15803d; } + +.spot-panel__trend-icon { font-size: 18px; line-height: 1; } +.spot-panel__trend-label { font-family: 'Fredoka', sans-serif; } + /* Details */ .spot-panel__details { display: grid; diff --git a/frontend/src/components/SpotPanel/SpotPanel.jsx b/frontend/src/components/SpotPanel/SpotPanel.jsx index ce6a3d5..809a2b1 100644 --- a/frontend/src/components/SpotPanel/SpotPanel.jsx +++ b/frontend/src/components/SpotPanel/SpotPanel.jsx @@ -6,6 +6,12 @@ import './SpotPanel.css'; const LEVEL_LABEL = { low: 'OK', medium: 'Risque', high: 'Impact' }; const TYPE_ICON = { beach: '🏖', port: '⚓', surf: '🏄', fishing: '🎣' }; +const TREND_CONFIG = { + increasing: { icon: '📈', label: 'Situation en dégradation', cls: 'trend--bad' }, + stable: { icon: '➡️', label: 'Situation stable', cls: 'trend--neutral' }, + decreasing: { icon: '📉', label: 'Amélioration en cours', cls: 'trend--good' }, +}; + const HORIZON_LABELS = { now: 'Maintenant', h6: '+6h', h12: '+12h', h24: '+24h', h48: '+48h' }; export default function SpotPanel({ spot, activeStep, onClose }) { @@ -82,13 +88,20 @@ export default function SpotPanel({ spot, activeStep, onClose }) { + {activeStep === 'now' && displayScore.trend && (() => { + const t = TREND_CONFIG[displayScore.trend] ?? TREND_CONFIG.stable; + return ( +
+ {t.icon} + {t.label} +
+ ); + })()} +
{displayScore.distance != null && ( <>
Distance sargasses
{displayScore.distance} km
)} - {activeStep === 'now' && displayScore.trend && ( - <>
Tendance
{displayScore.trend}
- )} {activeStep === 'now' && displayScore.computedAt && ( <>
Calculé le
{new Date(displayScore.computedAt).toLocaleString('fr-FR')}