# Separação Timeline Gantt — Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Add a horizontal Gantt chart to the Separação tab showing each separation mission as a time bar (route name on Y-axis, time window on X-axis), coloured by status.

**Architecture:** New Sonda method → new Laboratorio → new Handler → registered route in Routes.php + Api.php dispatch → card in dashboard.php → render + API call in dashboard.js.

**Tech Stack:** PHP 8 (Sondas V2, rawSql), ApexCharts rangeBar (already loaded), vanilla JS.

---

## File Map

| File | Action | Responsibility |
|------|--------|---------------|
| `componentes/Missoes/Logistica/sinais/Dashboard/Sondas/MissaoLogisticaSonda.php` | Modify | Add `listarTimeline()` raw SQL method |
| `componentes/Missoes/Logistica/sinais/Dashboard/Laboratorios/TimelineSeparacaoLaboratorio.php` | Create | Map Sonda rows → response shape |
| `componentes/Missoes/Plataforma/sinais/Api/Handlers/Dashboard/SeparacaoTimelineHandler.php` | Create | Validate params, call Lab, return JSON |
| `componentes/Missoes/Plataforma/sinais/Api/Routes.php` | Modify | Register GET route |
| `componentes/Missoes/Plataforma/sinais/Api/Api.php` | Modify | Import handler + dispatch method |
| `componentes/Missoes/Logistica/sinais/Dashboard/Testes/Handlers/SeparacaoTimelineHandlerSmokeTddTest.php` | Create | Smoke test (no DB) for the Handler |
| `componentes/Missoes/Logistica/sinais/Dashboard/html/dashboard.php` | Modify | Timeline card HTML in aba-separacao |
| `componentes/Missoes/Logistica/sinais/Dashboard/html/dashboard.js` | Modify | DASHBOARD_API_SEP_AWARE_PATHS + render + carregar() |

---

## Task 1: Write the failing smoke test

**Files:**
- Create: `componentes/Missoes/Logistica/sinais/Dashboard/Testes/Handlers/SeparacaoTimelineHandlerSmokeTddTest.php`

- [ ] **Step 1: Create the test file**

```php
<?php

/**
 * TDD — SeparacaoTimelineHandler smoke test (unit, sem DB)
 *
 * Testa que SeparacaoTimelineHandler:
 *   - Retorna 422 quando from/to ausentes
 *   - Retorna 422 quando tipo_separacao inválido
 *   - Retorna 200 com data.rotas (array) quando válido
 *
 * Standalone: php Testes/Handlers/SeparacaoTimelineHandlerSmokeTddTest.php
 */

// ---- Stubs: Sonda ----
namespace GalaxiaMissoes\Logistica\sinais\Dashboard\Sondas {

    class MissaoLogisticaSonda
    {
        public function listarTimeline(string $from, string $to, string $tipoSep = 'todos'): array
        {
            return [
                (object)[
                    'rota'        => 'Rota Teste A',
                    'inicio'      => '2026-05-14T08:30:00',
                    'fim'         => '2026-05-14T09:00:00',
                    'status'      => 'concluida',
                    'duracao_min' => 30.0,
                ],
                (object)[
                    'rota'        => 'Rota Teste B',
                    'inicio'      => '2026-05-14T09:00:00',
                    'fim'         => null,
                    'status'      => 'andamento',
                    'duracao_min' => null,
                ],
            ];
        }
    }
}

// ---- Stubs: infra Api ----
namespace GalaxiaMissoes\Plataforma\sinais\Api {

    class Request
    {
        public string $method       = 'GET';
        public string $path         = '/api/v1/dashboard/separacao/timeline';
        public string $routePattern = '/dashboard/separacao/timeline';
        public array  $routeParams  = [];
        public array  $body         = [];
        public array  $query        = [];
        public string $rawBody      = '';
        public array  $headers      = [];
        public string $ip           = '';
        public string $userAgent    = '';
        public string $requestId    = 'test-smoke';
        public ?array $jwtClaims    = null;

        public static function make(array $query = []): self
        {
            $r = new self();
            $r->query = $query;
            return $r;
        }
    }

    class Response
    {
        public int   $status = 200;
        public mixed $body   = null;
        public bool  $sent   = false;
        public array $headers = ['Content-Type' => 'application/json'];

        public function json(mixed $data, int $status = 200): self
        {
            $this->body   = $data;
            $this->status = $status;
            return $this;
        }
    }
}

// ---- Stubs: BaseHandler ----
namespace GalaxiaMissoes\Plataforma\sinais\Api\Handlers {

    use GalaxiaMissoes\Plataforma\sinais\Api\Request;
    use GalaxiaMissoes\Plataforma\sinais\Api\Response;

    abstract class BaseHandler
    {
        abstract public function handle(Request $req, Response $res): void;

        protected function erro(Request $req, string $code, string $message, int $status = 400): array
        {
            return [['error' => ['code' => $code, 'message' => $message], 'request_id' => $req->requestId], $status];
        }

        protected function ok(Request $req, mixed $data, int $status = 200): array
        {
            return [['data' => $data, 'request_id' => $req->requestId], $status];
        }
    }
}

// ---- Carrega classes reais ----
namespace GalaxiaMissoes\Logistica\sinais\Dashboard\Validators {
    if (!class_exists(FiltroPeriodoValidator::class)) {
        require_once __DIR__ . '/../../Validators/FiltroPeriodoValidator.php';
    }
    if (!class_exists(TipoSeparacaoValidator::class)) {
        require_once __DIR__ . '/../../Validators/TipoSeparacaoValidator.php';
    }
}

namespace {

    require_once __DIR__ . '/../../Validators/FiltroPeriodoValidator.php';
    require_once __DIR__ . '/../../Validators/TipoSeparacaoValidator.php';
    require_once __DIR__ . '/../../Laboratorios/TimelineSeparacaoLaboratorio.php';

    // Carrega Handler após todos os stubs
    require_once __DIR__ . '/../../../../../../Missoes/Plataforma/sinais/Api/Handlers/Dashboard/SeparacaoTimelineHandler.php';

    use GalaxiaMissoes\Plataforma\sinais\Api\Handlers\Dashboard\SeparacaoTimelineHandler;
    use GalaxiaMissoes\Plataforma\sinais\Api\Request;
    use GalaxiaMissoes\Plataforma\sinais\Api\Response;

    $__pass = 0;
    $__fail = 0;

    function sth(bool $cond, string $msg): void
    {
        global $__pass, $__fail;
        if ($cond) {
            $__pass++;
            echo "[PASS] {$msg}\n";
        } else {
            $__fail++;
            echo "[FAIL] {$msg}\n";
        }
    }

    echo "====================================================\n";
    echo "TDD — SeparacaoTimelineHandler smoke (unit)\n";
    echo "====================================================\n\n";

    $handler = new SeparacaoTimelineHandler();

    // ==================================================================
    // 1. Sem from/to → 422
    // ==================================================================
    echo "--- 1. Sem from/to → 422 ---\n";

    $req = Request::make([]);
    $res = new Response();
    $handler->handle($req, $res);

    sth($res->status === 422, "status=422 sem from/to (got {$res->status})");
    sth(isset($res->body['error']['code']), "body tem 'error.code'");
    sth(($res->body['error']['code'] ?? '') === 'invalid_input', "code='invalid_input'");
    sth(isset($res->body['request_id']), "body tem 'request_id'");

    // ==================================================================
    // 2. tipo_separacao inválido → 422
    // ==================================================================
    echo "\n--- 2. tipo_separacao inválido → 422 ---\n";

    $req = Request::make(['from' => '2026-05-14', 'to' => '2026-05-14', 'tipo_separacao' => 'invalido']);
    $res = new Response();
    $handler->handle($req, $res);

    sth($res->status === 422, "status=422 tipo_separacao inválido (got {$res->status})");
    sth(($res->body['error']['code'] ?? '') === 'invalid_input', "code='invalid_input'");

    // ==================================================================
    // 3. Válido → 200 com shape correto
    // ==================================================================
    echo "\n--- 3. Válido → 200 + shape ---\n";

    $req = Request::make(['from' => '2026-05-14', 'to' => '2026-05-14']);
    $res = new Response();
    $handler->handle($req, $res);

    sth($res->status === 200, "status=200 com params válidos (got {$res->status})");
    sth(isset($res->body['data']), "body tem 'data'");
    sth(isset($res->body['request_id']), "body tem 'request_id'");

    $data = $res->body['data'] ?? [];
    sth(array_key_exists('rotas', $data), "data.rotas presente");
    sth(is_array($data['rotas']), "data.rotas é array");
    sth(count($data['rotas']) === 2, "data.rotas tem 2 itens (stub)");

    $r0 = $data['rotas'][0] ?? [];
    sth(array_key_exists('rota',        $r0), "rotas[0].rota presente");
    sth(array_key_exists('inicio',      $r0), "rotas[0].inicio presente");
    sth(array_key_exists('fim',         $r0), "rotas[0].fim presente");
    sth(array_key_exists('status',      $r0), "rotas[0].status presente");
    sth(array_key_exists('duracao_min', $r0), "rotas[0].duracao_min presente");
    sth($r0['rota']   === 'Rota Teste A', "rotas[0].rota correto");
    sth($r0['status'] === 'concluida',    "rotas[0].status correto");
    sth($r0['duracao_min'] === 30.0,      "rotas[0].duracao_min correto");

    $r1 = $data['rotas'][1] ?? [];
    sth($r1['fim']         === null, "rotas[1].fim é null (andamento)");
    sth($r1['duracao_min'] === null, "rotas[1].duracao_min é null");

    // ==================================================================
    // 4. tipo_separacao=retira → 200
    // ==================================================================
    echo "\n--- 4. tipo_separacao=retira → 200 ---\n";

    $req = Request::make(['from' => '2026-05-14', 'to' => '2026-05-14', 'tipo_separacao' => 'retira']);
    $res = new Response();
    $handler->handle($req, $res);

    sth($res->status === 200, "status=200 com tipo_separacao=retira");

    // ==================================================================
    // Resultado
    // ==================================================================
    echo "\n====================================================\n";
    echo "RESULTADO: PASS={$__pass}  FAIL={$__fail}\n";
    if ($__fail > 0) {
        exit(1);
    }
    echo "TODOS OS TESTES PASSARAM\n";
}
```

- [ ] **Step 2: Run test — must fail (files not yet created)**

```bash
cd /home/goldie/www/genesis/componentes/Missoes/Logistica/sinais/Dashboard
php Testes/Handlers/SeparacaoTimelineHandlerSmokeTddTest.php 2>&1 | head -20
```

Expected: PHP fatal error — `TimelineSeparacaoLaboratorio.php` or `SeparacaoTimelineHandler.php` not found.

---

## Task 2: Add `listarTimeline()` to MissaoLogisticaSonda

**Files:**
- Modify: `componentes/Missoes/Logistica/sinais/Dashboard/Sondas/MissaoLogisticaSonda.php`

- [ ] **Step 1: Add the method at the end of the class, before the closing `}`**

Find the last closing `}` of the class and insert before it:

```php
    /**
     * Lista missões de separação (tipo=2) iniciadas na janela [from, to].
     *
     * JOIN com romaneios para obter o nome da rota (descricao_romaneio).
     * Fallback para ml.descricao quando a missão não tem referência romaneio.
     *
     * @param  string $from     Y-m-d
     * @param  string $to       Y-m-d
     * @param  string $tipoSep  'todos' | 'retira' | 'entrega'
     * @return array  [['rota' => string, 'inicio' => string, 'fim' => string|null, 'status' => string, 'duracao_min' => float|null], ...]
     */
    public function listarTimeline(string $from, string $to, string $tipoSep = 'todos'): array
    {
        $customer  = $this->getCustomerId();
        $filtroSep = TipoSeparacaoValidator::sqlFragment($tipoSep, 'ml');
        $sql = "
            SELECT
                COALESCE(r.descricao_romaneio, ml.descricao)   AS rota,
                ml.iniciado_em                                  AS inicio,
                ml.finalizado_em                                AS fim,
                ml.status,
                CASE
                    WHEN ml.finalizado_em IS NOT NULL
                    THEN EXTRACT(EPOCH FROM (ml.finalizado_em - ml.iniciado_em)) / 60.0
                    ELSE NULL
                END                                             AS duracao_min
            FROM missao_logistica ml
            LEFT JOIN romaneios r
                ON r.id = ml.referencia_id
               AND ml.referencia_tipo = 'ROMANEIO'
            WHERE ml.tipo_missao_id = 2
              AND ml.iniciado_em >= :from
              AND ml.iniciado_em < (:to::date + INTERVAL '1 day')
              AND ml.block = :customer
              {$filtroSep}
            ORDER BY ml.iniciado_em ASC
        ";
        $rows = $this->rawSql($sql, ['from' => $from, 'to' => $to, 'customer' => $customer]);
        return array_map(fn($r) => [
            'rota'        => (string) ($r->rota ?? ''),
            'inicio'      => (string) ($r->inicio ?? ''),
            'fim'         => $r->fim !== null ? (string) $r->fim : null,
            'status'      => (string) ($r->status ?? ''),
            'duracao_min' => $r->duracao_min !== null ? (float) $r->duracao_min : null,
        ], $rows);
    }
```

- [ ] **Step 2: Commit**

```bash
git add componentes/Missoes/Logistica/sinais/Dashboard/Sondas/MissaoLogisticaSonda.php
git commit -m "feat: add listarTimeline() to MissaoLogisticaSonda for Gantt chart"
```

---

## Task 3: Create TimelineSeparacaoLaboratorio

**Files:**
- Create: `componentes/Missoes/Logistica/sinais/Dashboard/Laboratorios/TimelineSeparacaoLaboratorio.php`

- [ ] **Step 1: Create the file**

```php
<?php

namespace GalaxiaMissoes\Logistica\sinais\Dashboard\Laboratorios;

use GalaxiaMissoes\Logistica\sinais\Dashboard\Sondas\MissaoLogisticaSonda;

/**
 * TimelineSeparacaoLaboratorio
 *
 * Agrega missões de separação para exibição no gráfico Gantt (rangeBar).
 * Cada item representa uma missão com janela inicio→fim e status.
 */
class TimelineSeparacaoLaboratorio
{
    private MissaoLogisticaSonda $sonda;

    public function __construct(MissaoLogisticaSonda $sonda)
    {
        $this->sonda = $sonda;
    }

    /**
     * Retorna a lista de separações na janela ordenada por inicio ASC.
     *
     * @param  string $from     Y-m-d
     * @param  string $to       Y-m-d
     * @param  string $tipoSep  'todos' | 'retira' | 'entrega'
     * @return array  ['rotas' => [['rota', 'inicio', 'fim', 'status', 'duracao_min'], ...]]
     */
    public function rotas(string $from, string $to, string $tipoSep = 'todos'): array
    {
        return [
            'rotas' => $this->sonda->listarTimeline($from, $to, $tipoSep),
        ];
    }
}
```

- [ ] **Step 2: Commit**

```bash
git add componentes/Missoes/Logistica/sinais/Dashboard/Laboratorios/TimelineSeparacaoLaboratorio.php
git commit -m "feat: add TimelineSeparacaoLaboratorio"
```

---

## Task 4: Create SeparacaoTimelineHandler — run test

**Files:**
- Create: `componentes/Missoes/Plataforma/sinais/Api/Handlers/Dashboard/SeparacaoTimelineHandler.php`

- [ ] **Step 1: Create the handler**

```php
<?php

namespace GalaxiaMissoes\Plataforma\sinais\Api\Handlers\Dashboard;

use GalaxiaMissoes\Logistica\sinais\Dashboard\Laboratorios\TimelineSeparacaoLaboratorio;
use GalaxiaMissoes\Logistica\sinais\Dashboard\Sondas\MissaoLogisticaSonda;
use GalaxiaMissoes\Logistica\sinais\Dashboard\Validators\FiltroPeriodoValidator;
use GalaxiaMissoes\Logistica\sinais\Dashboard\Validators\TipoSeparacaoValidator;
use GalaxiaMissoes\Plataforma\sinais\Api\Handlers\BaseHandler;
use GalaxiaMissoes\Plataforma\sinais\Api\Request;
use GalaxiaMissoes\Plataforma\sinais\Api\Response;

/**
 * GET /api/v1/dashboard/separacao/timeline
 *
 * Parâmetros: from (Y-m-d), to (Y-m-d), tipo_separacao (todos|retira|entrega)
 */
class SeparacaoTimelineHandler extends BaseHandler
{
    public function handle(Request $req, Response $res): void
    {
        $query = $req->query;
        $erros = array_merge(
            (new FiltroPeriodoValidator())->validar($query),
            (new TipoSeparacaoValidator())->validar($query)
        );
        if (!empty($erros)) {
            [$body, $status] = $this->erro($req, 'invalid_input', implode(' ', $erros), 422);
            $res->json($body, $status);
            return;
        }

        $tipoSep = (new TipoSeparacaoValidator())->normalizar($query);
        $lab     = new TimelineSeparacaoLaboratorio(new MissaoLogisticaSonda());
        $data    = $lab->rotas($query['from'], $query['to'], $tipoSep);

        [$body, $status] = $this->ok($req, $data);
        $res->json($body, $status);
    }
}
```

- [ ] **Step 2: Run the smoke test — must PASS**

```bash
cd /home/goldie/www/genesis/componentes/Missoes/Logistica/sinais/Dashboard
php Testes/Handlers/SeparacaoTimelineHandlerSmokeTddTest.php
```

Expected output:
```
====================================================
TDD — SeparacaoTimelineHandler smoke (unit)
====================================================

--- 1. Sem from/to → 422 ---
[PASS] status=422 sem from/to ...
...
RESULTADO: PASS=XX  FAIL=0
TODOS OS TESTES PASSARAM
```

- [ ] **Step 3: Commit**

```bash
git add \
  componentes/Missoes/Plataforma/sinais/Api/Handlers/Dashboard/SeparacaoTimelineHandler.php \
  componentes/Missoes/Logistica/sinais/Dashboard/Testes/Handlers/SeparacaoTimelineHandlerSmokeTddTest.php
git commit -m "feat: add SeparacaoTimelineHandler + smoke test"
```

---

## Task 5: Wire route and Api.php dispatch

**Files:**
- Modify: `componentes/Missoes/Plataforma/sinais/Api/Routes.php`
- Modify: `componentes/Missoes/Plataforma/sinais/Api/Api.php`

- [ ] **Step 1: Add route in Routes.php**

After line:
```php
$route->get('/dashboard/separacao/cubagem-por-hora',            'Api:dashboardSeparacaoCubagemPorHora');
```

Add:
```php
$route->get('/dashboard/separacao/timeline',                    'Api:dashboardSeparacaoTimeline');
```

- [ ] **Step 2: Add import in Api.php**

After the line:
```php
use GalaxiaMissoes\Plataforma\sinais\Api\Handlers\Dashboard\SeparacaoCubagemPorHoraHandler as DashSeparacaoCubagemPorHoraHandler;
```

Add:
```php
use GalaxiaMissoes\Plataforma\sinais\Api\Handlers\Dashboard\SeparacaoTimelineHandler as DashSeparacaoTimelineHandler;
```

- [ ] **Step 3: Add dispatch method in Api.php**

After the `dashboardSeparacaoCubagemPorHora` method, add:
```php
    public function dashboardSeparacaoTimeline(?array $data = []): void
    {
        $this->dispatcher()->run('GET', '/api/v1/dashboard/separacao/timeline',
            DashSeparacaoTimelineHandler::class, $data ?? [],
            ['skipValidacaoConteudo' => true]);
    }
```

- [ ] **Step 4: Commit**

```bash
git add \
  componentes/Missoes/Plataforma/sinais/Api/Routes.php \
  componentes/Missoes/Plataforma/sinais/Api/Api.php
git commit -m "feat: register separacao/timeline route and Api dispatch"
```

---

## Task 6: Add HTML card to dashboard.php

**Files:**
- Modify: `componentes/Missoes/Logistica/sinais/Dashboard/html/dashboard.php`

- [ ] **Step 1: Insert timeline card after the KPI grid closing tag**

Find in `#aba-separacao`:
```php
            ?>
        </div>

        <!-- Layout: grid 5 colunas — Tempo Médio (1) + Ocorrências (1) + Detalhamento (3). -->
```

Replace with:
```php
            ?>
        </div>

        <!-- Timeline de Separações (Gantt rangeBar) -->
        <div id="card-sep-timeline"
            style="border-radius:0.75rem; padding:0.875rem 1rem; margin-bottom:1rem; margin-top:1.25rem;
                   background:linear-gradient(145deg,rgba(15,12,41,0.95) 0%,rgba(20,18,50,0.9) 100%);
                   border:1px solid rgba(59,130,246,0.35); box-shadow:0 4px 15px rgba(0,0,0,0.3);">
            <div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:0.75rem;">
                <div style="font-size:1.05rem; font-weight:600; color:#94a3b8; text-transform:uppercase; letter-spacing:0.08em;">
                    <i class="bi-bar-chart-steps" style="color:#60a5fa; margin-right:0.4rem;"></i>
                    <?= show('Timeline de Separações') ?>
                </div>
                <span id="sep-timeline-range" style="font-size:0.8rem; color:#64748b;"></span>
            </div>
            <div id="sep-timeline-skeleton"
                style="height:320px; background:rgba(255,255,255,0.04); border-radius:0.5rem;
                       display:flex; align-items:center; justify-content:center;">
                <span style="color:#475569; font-size:0.9rem;"><?= show('Carregando...') ?></span>
            </div>
            <div id="sep-timeline-empty"
                style="height:320px; display:none; align-items:center; justify-content:center;">
                <span style="color:#475569; font-size:0.9rem;"><?= show('Sem separações no período') ?></span>
            </div>
            <div id="sep-timeline-wrap"
                style="height:320px; overflow-y:auto; overflow-x:hidden; display:none;
                       scrollbar-width:none; -webkit-overflow-scrolling:touch;">
                <style>#sep-timeline-wrap::-webkit-scrollbar{display:none;}</style>
                <div id="sep-timeline-chart"></div>
            </div>
            <div id="sep-timeline-legenda"
                style="display:none; flex-wrap:wrap; gap:0.5rem 1rem; margin-top:0.75rem; font-size:0.78rem; color:#94a3b8;">
                <span><span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#22c55e;margin-right:4px;"></span><?= show('Concluída') ?></span>
                <span><span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#3b82f6;margin-right:4px;"></span><?= show('≤ 30min') ?></span>
                <span><span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#f59e0b;margin-right:4px;"></span><?= show('≤ 45min') ?></span>
                <span><span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#ef4444;margin-right:4px;"></span><?= show('≥ 46min') ?></span>
                <span><span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#a78bfa;margin-right:4px;"></span><?= show('+90min / Aberta') ?></span>
                <span><span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#8b5cf6;margin-right:4px;"></span><?= show('Cancelada') ?></span>
            </div>
        </div>

        <!-- Layout: grid 5 colunas — Tempo Médio (1) + Ocorrências (1) + Detalhamento (3). -->
```

- [ ] **Step 2: Commit**

```bash
git add componentes/Missoes/Logistica/sinais/Dashboard/html/dashboard.php
git commit -m "feat: add separacao timeline Gantt card HTML"
```

---

## Task 7: Frontend JS — API wiring and render function

**Files:**
- Modify: `componentes/Missoes/Logistica/sinais/Dashboard/html/dashboard.js`

### Step 1: Add path to DASHBOARD_API_SEP_AWARE_PATHS

- [ ] **Step 1: Add 'separacao/timeline' to the array**

Find:
```js
    'separacao/cubagem-por-hora',
];
```

Replace with:
```js
    'separacao/cubagem-por-hora',
    'separacao/timeline',
];
```

### Step 2: Add `_chartTimeline` property to the separacao object

- [ ] **Step 2: Add property**

Find in the `separacao:` object (around line 1572):
```js
    separacao: {
        _chartPie: null,
        _chartMes: null,
        _paginaRanking: 1,
```

Replace with:
```js
    separacao: {
        _chartPie: null,
        _chartMes: null,
        _chartTimeline: null,
        _paginaRanking: 1,
```

### Step 3: Add timeline to carregar()

- [ ] **Step 3: Modify carregar() to include the timeline fetch**

Find in `carregar()`:
```js
                const [tempo, erros, rankSep, itensHora] = await Promise.all([
                    DashboardApi.get('tempo-medio-item', periodoCards),
                    DashboardApi.get('erros-separacao', periodoCards),
                    DashboardApi.get('ranking-separadores', { ...periodo, page: 1, perPage: 5 }),
                    DashboardApi.get('separacao/itens-por-hora', periodo),
                ]);
                this._renderTempoMedio(tempo);
                this._renderErrosPie(erros);
                this._renderRanking(rankSep);
                this._renderErroSepar(erros, tempo);
                this._renderItensHora(itensHora.serie ?? []);
```

Replace with:
```js
                const [tempo, erros, rankSep, itensHora, timeline] = await Promise.all([
                    DashboardApi.get('tempo-medio-item', periodoCards),
                    DashboardApi.get('erros-separacao', periodoCards),
                    DashboardApi.get('ranking-separadores', { ...periodo, page: 1, perPage: 5 }),
                    DashboardApi.get('separacao/itens-por-hora', periodo),
                    DashboardApi.get('separacao/timeline', periodoCards),
                ]);
                this._renderTempoMedio(tempo);
                this._renderErrosPie(erros);
                this._renderRanking(rankSep);
                this._renderErroSepar(erros, tempo);
                this._renderItensHora(itensHora.serie ?? []);
                this._renderTimeline(timeline);
```

### Step 4: Add `_renderTimeline()` method

- [ ] **Step 4: Add method to separacao object**

Find after `_renderItensHora` method closing `},` and before the next method. Locate the `_renderItensHora` method by searching for its signature. Add the following method as a peer to existing render methods inside the `separacao:` object:

```js
        _renderTimeline(resp) {
            const rotas    = resp?.rotas ?? [];
            const skeleton = document.getElementById('sep-timeline-skeleton');
            const empty    = document.getElementById('sep-timeline-empty');
            const wrap     = document.getElementById('sep-timeline-wrap');
            const legenda  = document.getElementById('sep-timeline-legenda');

            if (skeleton) skeleton.style.display = 'none';

            if (rotas.length === 0) {
                if (empty)   { empty.style.display   = 'flex'; }
                if (wrap)    { wrap.style.display     = 'none'; }
                if (legenda) { legenda.style.display  = 'none'; }
                return;
            }

            if (empty)   { empty.style.display   = 'none'; }
            if (wrap)    { wrap.style.display     = 'block'; }
            if (legenda) { legenda.style.display  = 'flex'; }

            const ROW_H      = 40;
            const now        = Date.now();
            const MAX_DUR_MS = 90 * 60 * 1000;

            const durMs = (r) => {
                if (r.fim) return new Date(r.fim).getTime() - new Date(r.inicio).getTime();
                return now - new Date(r.inicio).getTime();
            };

            const cor = (r) => {
                if (r.status === 'concluida') return '#22c55e';
                if (r.status === 'cancelada') return '#8b5cf6';
                const d = r.duracao_min ?? ((now - new Date(r.inicio).getTime()) / 60000);
                if (r.fim === null || d >= 90) return '#a78bfa';
                if (d <= 30) return '#3b82f6';
                if (d <= 45) return '#f59e0b';
                return '#ef4444';
            };

            const fimMs = (r) => {
                if (r.fim) return new Date(r.fim).getTime();
                const ini     = new Date(r.inicio).getTime();
                const elapsed = now - ini;
                return ini + Math.min(elapsed, MAX_DUR_MS);
            };

            const series = [{
                data: rotas.map(r => ({
                    x:         r.rota || '—',
                    y:         [new Date(r.inicio).getTime(), fimMs(r)],
                    fillColor: cor(r),
                })),
            }];

            const chartHeight = Math.max(rotas.length * ROW_H, ROW_H);

            const opts = {
                chart: {
                    type:        'bar',
                    height:      chartHeight,
                    toolbar:     { show: false },
                    animations:  { enabled: false },
                    background:  'transparent',
                },
                plotOptions: {
                    bar: { horizontal: true, rangeBarGroupRows: true, barHeight: '70%' },
                },
                xaxis: { type: 'datetime' },
                yaxis: {
                    labels: {
                        maxWidth: 160,
                        style:    { colors: '#94a3b8', fontSize: '12px' },
                    },
                },
                grid:   { borderColor: 'rgba(255,255,255,0.06)' },
                series,
                tooltip: {
                    custom({ dataPointIndex }) {
                        const r   = rotas[dataPointIndex];
                        const ini = new Date(r.inicio);
                        const fmt = d => d.toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' });
                        const durText = r.duracao_min !== null
                            ? `${Math.round(r.duracao_min)} min`
                            : 'Em andamento';
                        const fimText = r.fim ? fmt(new Date(r.fim)) : '—';
                        return `<div style="padding:8px 12px;font-size:12px;color:#f1f5f9;background:#1e293b;border-radius:6px;line-height:1.6;">
                            <b>${r.rota}</b><br>
                            Início: ${fmt(ini)}<br>
                            Fim: ${fimText}<br>
                            Duração: ${durText}
                        </div>`;
                    },
                },
                theme: { mode: 'dark' },
            };

            if (this._chartTimeline) {
                this._chartTimeline.destroy();
                this._chartTimeline = null;
            }

            const el = document.getElementById('sep-timeline-chart');
            if (el) {
                this._chartTimeline = new ApexCharts(el, opts);
                this._chartTimeline.render();
            }
        },
```

To place this correctly: search for `_renderItensHora(` in dashboard.js. The method following it in the file is the right insertion point — add `_renderTimeline` after `_renderItensHora`'s closing `},`.

- [ ] **Step 5: Commit**

```bash
git add componentes/Missoes/Logistica/sinais/Dashboard/html/dashboard.js
git commit -m "feat: add separacao timeline Gantt render to dashboard JS"
```

---

## Task 8: Verify end-to-end

- [ ] **Step 1: Run smoke test one final time**

```bash
cd /home/goldie/www/genesis/componentes/Missoes/Logistica/sinais/Dashboard
php Testes/Handlers/SeparacaoTimelineHandlerSmokeTddTest.php
```

Expected: `RESULTADO: PASS=XX  FAIL=0 / TODOS OS TESTES PASSARAM`

- [ ] **Step 2: Verify no PHP syntax errors in modified files**

```bash
php -l componentes/Missoes/Logistica/sinais/Dashboard/Sondas/MissaoLogisticaSonda.php
php -l componentes/Missoes/Logistica/sinais/Dashboard/Laboratorios/TimelineSeparacaoLaboratorio.php
php -l componentes/Missoes/Plataforma/sinais/Api/Handlers/Dashboard/SeparacaoTimelineHandler.php
php -l componentes/Missoes/Plataforma/sinais/Api/Routes.php
php -l componentes/Missoes/Plataforma/sinais/Api/Api.php
php -l componentes/Missoes/Logistica/sinais/Dashboard/html/dashboard.php
```

Expected: `No syntax errors detected` for all.

- [ ] **Step 3: Smoke test + syntax pass → final commit if needed**

If anything was fixed in the verify step, commit the fix. Otherwise, the feature is complete.

---

## Self-Review Notes

- **Color logic in JS, not PHP** — confirmed; Laboratorio returns raw status/duracao_min.
- **`initiado_em` for filter window** — confirmed per spec (not `finalizado_em`).
- **`TipoSeparacaoValidator::sqlFragment($tipoSep, 'ml')`** — alias `'ml'` used for the JOIN query.
- **`periodoCards()`** for API call — single day when day button clicked, full range when calendar. Confirmed per spec.
- **`DASHBOARD_API_SEP_AWARE_PATHS`** — `'separacao/timeline'` added so `tipo_separacao` is auto-injected.
- **Scroll** — `wrap` div height fixed 320px (8 rows × 40px), chart height = `n_rotas × 40px`, overflow-y auto.
- **Skeleton** visible until `_renderTimeline()` is called; removed when data arrives.
- **Lilás rule** — `duracao_min >= 90` OR `fim === null` → `#a78bfa`, bar capped at `inicio + 90min` via `fimMs()`.
