diff --git a/backend/config/packages/messenger.yaml b/backend/config/packages/messenger.yaml
new file mode 100644
index 0000000..7dc610a
--- /dev/null
+++ b/backend/config/packages/messenger.yaml
@@ -0,0 +1,20 @@
+framework:
+ messenger:
+ transports:
+ # Transport du scheduler — traité en synchrone dans le worker scheduler_main
+ scheduler_main:
+ dsn: 'scheduler://main'
+
+ # Transport async Redis pour les messages lourds (ingestion)
+ async:
+ dsn: '%env(REDIS_URL)%'
+ options:
+ stream: rs971_async
+ retry_strategy:
+ max_retries: 3
+ delay: 60000 # 1 min entre chaque retry
+ multiplier: 2
+
+ routing:
+ # L'ingestion planifiée tourne dans le worker async (pas dans le worker scheduler)
+ App\Message\IngestionScheduleMessage: async
diff --git a/backend/src/Controller/StatusController.php b/backend/src/Controller/StatusController.php
new file mode 100644
index 0000000..9dac2c7
--- /dev/null
+++ b/backend/src/Controller/StatusController.php
@@ -0,0 +1,34 @@
+connection->fetchAssociative(
+ 'SELECT detected_at, source, coverage_area
+ FROM sargassum_observation
+ ORDER BY detected_at DESC LIMIT 1'
+ );
+
+ return $this->json([
+ 'lastObservation' => $row !== false ? [
+ 'detectedAt' => $row['detected_at'],
+ 'source' => $row['source'],
+ 'coverageKm2' => $row['coverage_area'] !== null ? (float) $row['coverage_area'] : null,
+ ] : null,
+ ]);
+ }
+}
diff --git a/backend/src/Message/IngestionScheduleMessage.php b/backend/src/Message/IngestionScheduleMessage.php
new file mode 100644
index 0000000..56cfb76
--- /dev/null
+++ b/backend/src/Message/IngestionScheduleMessage.php
@@ -0,0 +1,9 @@
+ [-61.30, 14.30, -60.70, 14.90],
+ 'guadeloupe' => [-61.90, 15.80, -61.00, 16.60],
+ 'sainte-lucie' => [-61.10, 13.70, -60.80, 14.20],
+ 'barbade' => [-59.80, 13.00, -59.30, 13.40],
+ 'saint-martin' => [-63.20, 17.80, -62.90, 18.20],
+ ];
+
+ public function __construct(
+ private IngestionService $ingestion,
+ private DriftSimulationService $drift,
+ private ImpactScoreService $scores,
+ private EntityManagerInterface $em,
+ private LoggerInterface $logger,
+ ) {}
+
+ public function __invoke(IngestionScheduleMessage $message): void
+ {
+ $date = new \DateTimeImmutable('yesterday');
+ $this->logger->info('Scheduled ingestion started', ['date' => $date->format('Y-m-d')]);
+
+ // Étape 1 : ingestion de toutes les zones
+ foreach (self::ZONES as $name => $bbox) {
+ try {
+ $this->ingestion->ingestWithFallback($bbox, $date);
+ $this->logger->info('Zone ingested', ['zone' => $name]);
+ } catch (\Throwable $e) {
+ $this->logger->error('Ingestion failed', ['zone' => $name, 'error' => $e->getMessage()]);
+ }
+ }
+
+ // Étape 2 : forecasts pour les observations sans prévision
+ $observations = $this->em->getRepository(SargassumObservation::class)
+ ->createQueryBuilder('o')
+ ->leftJoin('o.forecasts', 'f')
+ ->where('f.id IS NULL')
+ ->orderBy('o.detectedAt', 'DESC')
+ ->setMaxResults(10)
+ ->getQuery()
+ ->getResult();
+
+ foreach ($observations as $observation) {
+ try {
+ $forecasts = $this->drift->computeForecasts($observation);
+ $this->scores->computeForObservation($observation, $forecasts);
+ $this->logger->info('Forecasts computed', ['observation' => (string) $observation->getId()]);
+ } catch (\Throwable $e) {
+ $this->logger->error('Forecast failed', [
+ 'observation' => (string) $observation->getId(),
+ 'error' => $e->getMessage(),
+ ]);
+ }
+ }
+
+ $this->logger->info('Scheduled ingestion completed');
+ }
+}
diff --git a/backend/src/Scheduler/MainSchedule.php b/backend/src/Scheduler/MainSchedule.php
new file mode 100644
index 0000000..2d0ced2
--- /dev/null
+++ b/backend/src/Scheduler/MainSchedule.php
@@ -0,0 +1,32 @@
+schedule ??= (new Schedule())
+ ->add(
+ // 06:00, 12:00, 18:00, 00:00 UTC — données satellite disponibles ~2h après le passage
+ RecurringMessage::cron('0 0,6,12,18 * * *', new IngestionScheduleMessage())
+ )
+ ->stateful($this->cache);
+ }
+}
diff --git a/docker/php/entrypoint.sh b/docker/php/entrypoint.sh
index 9ee5d53..bf97b32 100644
--- a/docker/php/entrypoint.sh
+++ b/docker/php/entrypoint.sh
@@ -4,5 +4,15 @@ set -e
# Warmup du cache Symfony au démarrage (les vars d'env sont disponibles ici)
php bin/console cache:warmup --env=prod --no-debug 2>&1 || true
+# Worker scheduler — réveille le pipeline toutes les 6h
+php bin/console messenger:consume scheduler_main \
+ --env=prod --no-debug --memory-limit=64M \
+ 2>&1 | tee -a var/log/scheduler.log &
+
+# Worker async — exécute les messages d'ingestion déclenchés par le scheduler
+php bin/console messenger:consume async \
+ --env=prod --no-debug --memory-limit=256M \
+ 2>&1 | tee -a var/log/worker.log &
+
# Lance FrankenPHP (comportement par défaut de l'image de base)
exec frankenphp run --config /etc/caddy/Caddyfile "$@"
diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js
index 0a3b583..09d4a03 100644
--- a/frontend/src/api/client.js
+++ b/frontend/src/api/client.js
@@ -40,6 +40,9 @@ export const api = {
feedback: {
create: (data) => post('/feedback', data),
},
+ status: {
+ get: () => get('/status'),
+ },
push: {
vapidKey: () => get('/push/vapid-public-key'),
subscribe: (data) => post('/push/subscribe', data),
diff --git a/frontend/src/components/Map/SargassesMap.css b/frontend/src/components/Map/SargassesMap.css
index c116488..771db57 100644
--- a/frontend/src/components/Map/SargassesMap.css
+++ b/frontend/src/components/Map/SargassesMap.css
@@ -1,3 +1,18 @@
+/* ── Indicateur fraîcheur données ── */
+.smap-freshness {
+ position: absolute;
+ bottom: 28px;
+ left: 10px;
+ background: rgba(255, 255, 255, 0.82);
+ backdrop-filter: blur(4px);
+ border-radius: 6px;
+ padding: 4px 10px;
+ font-size: 11px;
+ color: #555;
+ pointer-events: none;
+ z-index: 10;
+}
+
/* ── Spot markers ── */
.smap-marker {
cursor: pointer;
diff --git a/frontend/src/components/Map/SargassesMap.jsx b/frontend/src/components/Map/SargassesMap.jsx
index adfe5b5..2065327 100644
--- a/frontend/src/components/Map/SargassesMap.jsx
+++ b/frontend/src/components/Map/SargassesMap.jsx
@@ -156,6 +156,18 @@ export default function SargassesMap({ onSpotSelect, selectedSpotId, activeStep,
const [viewState, setViewState] = useState(INITIAL_VIEW);
const [layerOpacity, setLayerOpacity] = useState(1);
const [mapStyle, setMapStyle] = useState(FALLBACK_STYLE);
+ const [dataDate, setDataDate] = useState(null);
+
+ // Fraîcheur des données
+ useEffect(() => {
+ api.status.get()
+ .then(s => {
+ if (s.lastObservation?.detectedAt) {
+ setDataDate(new Date(s.lastObservation.detectedAt));
+ }
+ })
+ .catch(() => {});
+ }, []);
// Load cartoon map style
useEffect(() => {
@@ -254,6 +266,14 @@ export default function SargassesMap({ onSpotSelect, selectedSpotId, activeStep,
>