# Documentação Completa — Rally API Integration

## Índice

1. [Visão Geral](#visão-geral)
2. [Autenticação](#autenticação)
3. [GPS Token Store](#gps-token-store)
4. [Configuração](#configuração)
5. [Endpoints de Dados](#endpoints-de-dados)
6. [Troços (Traçado dos Troços)](#troços-traçado-dos-troços)
   - [Distância ao Traçado](#distância-ao-traçado)
7. [Info (Endpoint Agregado)](#info-endpoint-agregado)
8. [Sistema de Gravação](#sistema-de-gravação)
9. [Sistema de Replay](#sistema-de-replay)
10. [Estrutura de Dados](#estrutura-de-dados)
11. [Cache](#cache)
12. [Troubleshooting](#troubleshooting)

---

## Visão Geral

Servidor Node.js/Express que actua como proxy e agregador das APIs da Anube para dados de rally. Funcionalidades principais:

- Proxy para as APIs REST da Anube (`rest3.anube.es` e `rest.anube.es`)
- `GET /health` — estado do processo (uptime, rally activo, gravação, replay)
- Enriquecimento de dados: timing + GPS + participantes combinados num único endpoint
- Cache de 12h para dados estáticos
- Gravação completa de provas (snapshots a cada 60s)
- Replay de gravações com controlo total de playback

**URL base:** `http://localhost:10200` (configurável via `PORT`)

**Persistência de configuração:** `recordings/config.json` — carregada automaticamente ao iniciar o servidor

---

## Autenticação

O sistema tem dois mecanismos de autenticação:

### Sessão Web (interface)

Usado pelos endpoints de configuração e pela interface web. Requer login prévio.

```
POST /api/auth/login
Body: { "username": "admin", "password": "admin123" }
Response: { "success": true, "message": "Login efetuado com sucesso" }
```

```
POST /api/auth/logout
Response: { "success": true }
```

```
GET /api/auth/check
Response: { "authenticated": true, "username": "admin", "apiKey": "..." }
# apiKey só retornado se autenticado
```

Credenciais definidas em `.env` via `WEB_USERNAME` e `WEB_PASSWORD`.
Sessão válida por 24 horas. Cookie `httpOnly`; `secure` apenas em produção.

### API Key (endpoints externos)

Enviada no header `X-API-Key` ou query param `api_key`. Definida em `.env` via `API_KEY`.

```bash
curl -H "X-API-Key: rally-2024-key" http://localhost:10200/api/recording/start
# ou
curl http://localhost:10200/api/recording/start?api_key=rally-2024-key
```

Endpoints que **não requerem autenticação**: todos os GET de dados, `/api/config` (GET), `/api/auth/login`.

---

## GPS Token Store

O sistema mantém um store de GPS tokens por `subId` (subrally ID). Permite que múltiplos rallies tenham tokens diferentes, e que outros utilizadores da API beneficiem de tokens já registados.

**Persistência:** `recordings/gps-tokens.json` — carregado em RAM ao iniciar.

**Resolução automática:** Todos os endpoints que pedem GPS (`participants`, `chronos2`, `gps`) resolvem automaticamente o token pelo `subId` — não é necessário estar "selecionado" para funcionar.

**Sincronização com config:** Quando a configuração é guardada com `gpsToken`, o token é automaticamente adicionado ao store para o `subId` configurado.

### GET /api/gps-tokens

Lista todos os tokens registados (valores mascarados). **Requer API key.**

```json
{
  "tokens": {
    "8512": { "token": "abc***xyz", "rallyId": "320" },
    "8513": { "token": "def***uvw", "rallyId": "321" }
  },
  "count": 2
}
```

### POST /api/gps-tokens

Adiciona ou atualiza um token. Basta enviar o `rallyId` — o `subId` é resolvido automaticamente via race data. **Requer API key.**

```bash
curl -X POST -H "X-API-Key: KEY" -H "Content-Type: application/json" \
  -d '{"rallyId": "320", "token": "meu-token-gps"}' \
  http://localhost:10200/api/gps-tokens
```

Resposta:
```json
{
  "success": true,
  "rallyId": "320",
  "subId": "8512",
  "message": "Token guardado para rally 320 (subId 8512)"
}
```

### DELETE /api/gps-tokens/:rallyId

Remove um token. Resolve o `subId` pelo `rallyId`. **Requer API key.**

```bash
curl -X DELETE -H "X-API-Key: KEY" http://localhost:10200/api/gps-tokens/320
```

---

## Configuração

### GET /api/config

Retorna a configuração activa no servidor.

```json
{
  "rallyId": "312",
  "subId": "456",
  "itineraryId": "789",
  "pecId": "101",
  "rallyName": "Rally de Portugal",
  "specialName": "SS1",
  "mode": "manual",
  "gpsToken": null
}
```

### POST /api/config

**Auth:** Sessão web

Guarda configuração completa. Quando `rallyId` muda, actualiza automaticamente o cache estático (race, itineraries, participants, specials). Persiste em `recordings/config.json`.

```json
// Body
{
  "rallyId": "312",
  "subId": "456",
  "itineraryId": "789",
  "pecId": "101",
  "rallyName": "Rally de Portugal",
  "specialName": "SS1",
  "mode": "manual",
  "gpsToken": ""
}

// Response
{ "success": true, "config": { ... } }
```

Campos opcionais: `gpsToken` (token para GPS protegido), `specialName` (nome da especial para sections).

### POST /api/config/pec

**Auth:** Sessão web

Actualiza apenas a especial activa (PEC), sem alterar o resto da configuração. Requer rally já configurado.

```json
// Body
{ "pecId": "102" }

// Response
{ "success": true, "config": { ... }, "message": "Especial ID atualizado com sucesso" }
```

---

## Endpoints de Dados

### Comportamento do Replay

Quando um replay está activo, **todos** os endpoints de dados verificam se o `rallyId` pedido coincide com o da gravação em replay. Se coincidir, retornam dados gravados em vez de ir à API da Anube. Esta verificação aplica-se tanto aos endpoints `/selected.json` como aos genéricos `/:rallyId.json`.

---

### Race

**GET /api/race/selected.json**

Race info do rally configurado. Cache: 12h (estático).

**GET /api/race/:rallyId.json**

Race info para qualquer rally. Suporta replay se `rallyId` coincide.

```json
// Response (estrutura Anube)
{
  "event": {
    "data": {
      "id": "312",
      "name": "Rally de Portugal 2024",
      "ut_ini": 1705320000,
      "ut_fin": 1705406400,
      "main_subrally": { "id": "456" },
      ...
    }
  }
}
```

---

### Itineraries

**GET /api/itineraries/selected.json**

Itinerários do rally configurado. Cache: 12h.

**GET /api/itineraries/:rallyId.json**

Itinerários para qualquer rally. Suporta replay.

```json
{
  "event": {
    "data": [
      { "id": "789", "name": "Itinerário 1", ... }
    ]
  }
}
```

---

### Specials (Etapas)

**GET /api/specials/selected.json**

Especiais do itinerário configurado. Cache: 12h.

**GET /api/specials/:rallyId.json?itinerary_id=789**

Especiais para qualquer rally/itinerário. Suporta replay.

```json
{
  "event": {
    "data": [
      {
        "id": "101",
        "special_name": "SS1",
        "name_extra": "Monte da Falperra",
        "meters": 18500,
        "ut_ini": 1705330000,
        "ut_deactivation": 1705337200,
        "state": "active"
      }
    ]
  }
}
```

---

### Participants

**GET /api/participants/selected.json**

Participantes enriquecidos com GPS em tempo real e flag `in_race`.

**GET /api/participants/:rallyId.json**

Mesmo enriquecimento para qualquer rally. Suporta replay.

```json
{
  "event": {
    "data": [
      {
        "id": "1001",
        "ident": "1",
        "pilot": "Carlos Sainz",
        "copilot": "Luis Moya",
        "vehicle": "Toyota GR Yaris",
        "latitude": 41.5234,
        "longitude": -8.4521,
        "state": "racing",
        "speed": 92.3,
        "bearing": 145,
        "color": "#00ff00",
        "in_race": true
      }
    ]
  }
}
```

`in_race: true` indica que o participante iniciou a especial mas ainda não terminou.

---

### GPS

**GET /api/gps/selected.json**

Dados GPS em tempo real. Suporta replay. Requer `subId` na configuração. GPS Token opcional via `gpsToken` na config.

```json
{
  "event": {
    "data": {
      "participants": [
        {
          "dorsal": "1",
          "latitude": 41.5234,
          "longitude": -8.4521,
          "state": "racing",
          "unixtime_gps": 1705331500,
          "bearing": 145,
          "speed": 92.3,
          "color": "#00ff00"
        }
      ]
    }
  }
}
```

---

### Sections / Scorings

**GET /api/sections/selected.json**

Tempos por secção/ponto de controlo da especial activa. Suporta replay (requer especial seleccionada via `/api/replay/select-special`). Requer `subId` e `specialName` na configuração.

```json
{
  "event": {
    "data": {
      "controls": [ { "id": "C1", "name": "WP1", "meters": 5200 } ],
      "participants": [
        {
          "dorsal": "1",
          "sections": [
            { "control_id": "C1", "time": 185.3, "speed": 105.2 }
          ]
        }
      ]
    }
  }
}
```

---

### Penalizations

**GET /api/penalizations/selected.json**
**GET /api/penalizations/:rallyId.json**

Penalizações dos participantes. Suporta replay.

```json
{
  "event": {
    "data": [
      { "participant_id": "1001", "time": 60, "reason": "Late arrival", "special_id": "101" }
    ]
  }
}
```

---

### Classification

**GET /api/classification/selected.json**

Classificação usando `rallyId`, `itineraryId` e `pecId` da configuração. Suporta replay.

**GET /api/classification/:rallyId.json?itinerary_id=789&special_id=101**

Classificação para qualquer rally/especial. Suporta replay.

```json
{
  "event": {
    "data": [
      {
        "position": 1,
        "participant_id": "1001",
        "total_time": 15234.5,
        "gap": 0,
        "special_time": 1823.4
      }
    ]
  }
}
```

---

### Chronos2 (Timing Enriquecido)

O endpoint mais completo. Combina timing + participantes + GPS + secções + tempos parciais.

**GET /api/chronos2/selected.json**

Query params opcionais:
- `?ident=1` — filtrar por dorsal
- `?participant_id=1001` — filtrar por ID de participante

**GET /api/chronos2/:rallyId.json?special_id=101**

`special_id` é obrigatório. Mesmos query params opcionais. Suporta replay.

```json
{
  "chronos": {
    "type": "chronos",
    "data": [
      {
        "participant_id": "1001",
        "ident": "1",
        "pilot": "Carlos Sainz",
        "copilot": "Luis Moya",
        "vehicle": "Toyota GR Yaris",
        "pilot_nat": "ES",
        "copilot_nat": "ES",
        "time": 1823400,
        "time_str": "30:23.4",
        "ut_start_original": 1705330600,
        "ut_finish_original": 1705332423,
        "latitude": 41.5234,
        "longitude": -8.4521,
        "state": "finished",
        "speed": 0,
        "bearing": 0,
        "color": "#00ff00",
        "in_race": false,
        "section_scorings": [1705330785, 1705331342],
        "section_scorings_raw": "1705330785,1705331342",
        "partials": {
          "run": 1,
          "ut_start": 1705330600,
          "uts_partial": [1705330785, 1705331100],
          "ut_finish": 1705332423
        }
      }
    ]
  },
  "special": {
    "type": "special",
    "data": {
      "id": "101",
      "special_name": "SS1",
      "name_extra": "Monte da Falperra",
      "meters": 18500,
      "decimals": 1,
      "ut_ini": 1705330000,
      "runs": 45,
      "compute": true,
      "state": "finished"
    }
  },
  "section_controls": ["WP1", "WP2"],
  "intermediates_meter": [5200, 11800]
}
```

`partials` é `null` se não houver dados de tempos parciais para o participante.
`intermediates_meter` contém as distâncias em metros de cada ponto intermediário (mesmo array para todos os participantes).

---

### Partials (Tempos Intermediários)

Dados de tempos parciais enriquecidos com informação dos participantes. Fonte: `https://rest3.anube.es/rallyrest/timing/api/partials/{rallyId}.json?special_id={id}`

**GET /api/partials/selected.json**

Usa `rallyId` e `pecId` da configuração activa. Suporta replay.

**GET /api/partials/:rallyId.json?special_id=101**

`special_id` é obrigatório. Usa cache de 12h para participantes. Suporta replay.

```json
{
  "intermediates_meter": [5200, 11800],
  "times": {
    "1001": {
      "run": 1,
      "ut_start": 1705330600,
      "uts_partial": [1705330785, 1705331100],
      "ut_finish": 1705332423,
      "ident": "1",
      "pilot": "Carlos Sainz",
      "copilot": "Luis Moya",
      "competitor": "Toyota Gazoo Racing",
      "vehicle": "Toyota GR Yaris",
      "pilot_nat": "ES",
      "copilot_nat": "ES"
    }
  }
}
```

O objecto `times` é indexado por `participant_id`. `intermediates_meter[i]` corresponde a `uts_partial[i]` — são paralelos.

---

## Troços (Traçado dos Troços)

Traçado GPS dos troços marcados na Anube (`section_path`), agregado e filtrado. Sem este endpoint, cada cliente que desenha o mapa faz `1 + N` pedidos à Anube (subrally + um por troço) e repete-os a cada refresh.

### GET /api/tracks/selected.json
### GET /api/tracks/:rallyId.json?sub_id=X

`sub_id` é opcional na variante por rally — sem ele é resolvido a partir do `subrally_id` do race.

**Exemplos:**

```bash
curl http://localhost:10200/api/tracks/selected.json
curl http://localhost:10200/api/tracks/324.json
curl "http://localhost:10200/api/tracks/324.json?sub_id=8512&summary=1"
curl "http://localhost:10200/api/tracks/324.json?sub_id=8512&format=geojson"
```

| Parâmetro | Efeito |
|---|---|
| `sub_id` | Subrally a usar. Sem ele, usa o `subrally_id` do race |
| `summary=1` | Só os metadados de cada troço, sem `points`/`start`/`finish` |
| `format=geojson` | Em vez de `sections`, devolve `geojson` (FeatureCollection) |

```json
{
  "event": {
    "type": "tracks",
    "data": {
      "sub_id": "8512",
      "rally_id": 6797,
      "rally_name": "Rally de Ourique Capital do Porco Alentejano 2026",
      "gps_location": "unlocked",
      "token_used": false,
      "sections_total": 7,
      "sections_count": 4,
      "points_count": 2628,
      "fetched_at": "2026-10-03T12:54:50.511Z",
      "sections": [
        {
          "mapid": "420015",
          "name": "T01 Super especial Ourique 0925",
          "name_timing": "PEC1",
          "geometry": "propria",
          "reversed": false,
          "track_visible": true,
          "oculto": false,
          "color": "#00ff00",
          "opacity": 0.67,
          "width": "5",
          "points_count": 158,
          "discarded": 0,
          "start": { "lat": 37.65156818, "lng": -8.22316082, "alt": 257, "name": "START" },
          "finish": { "lat": 37.65177484, "lng": -8.22608971, "alt": 264, "name": "FINISH" },
          "points": [
            { "lat": 37.65156818, "lng": -8.22316082, "alt": 257 }
          ]
        }
      ],
      "skipped": [
        { "mapid": "420019", "name": null, "reason": "sem pontos" }
      ]
    }
  }
}
```

### `also_hidden=1` — porque é obrigatório

O pedido do traçado leva sempre `also_hidden=1`. O `track_visible` da Anube é o
**interruptor de apresentação** que a organização mexe durante a prova — costuma
deixar visível só o troço em curso. Sem o parâmetro, a Anube responde
`{points: []}` e sem `name` para todos os outros, e qualquer filtro os descarta
como "sem pontos".

Medido no Algarve 2026 (subId 9136): 4 dos 10 troços tinham `track_visible=0` e
desapareciam. O portal em `C:\www\paulo` já fazia isto bem; o
`rally-map-control` ainda não passa o parâmetro.

As flags `track_visible` e `oculto` vão na resposta de cada troço, mas **não são
aplicadas** — esta API alimenta as nossas aplicações e o dado tem de estar lá.
Quem desenha para o público é que decide (no subId 8600 os três troços estão
`oculto` e todos têm traçado completo).

### Troços repetidos e geometria no troço errado

Quando a Anube dá as coordenadas oficiais de partida e chegada de cada troço
(`latitude_ini`/`latitude_fin` da lista de troços), compara-se o traçado com elas:

- **Troço corrido duas vezes** — a geometria vem só numa das repetições. O SS6 e o
  SS8 do Algarve chegavam vazios, mas têm os mesmos extremos oficiais do SS3 e do
  SS5; passam a usar essa geometria. No Ourique eram 3 dos 7 troços.
- **Geometria pendurada no troço errado** — o SS4 do Algarve recebia o percurso do
  SS5, a 10 km dos seus próprios extremos. Passa a receber o correcto.
- **Sentido invertido** — testam-se as duas orientações e inverte-se se for preciso.

Tolerância: 300 m entre as pontas. A geometria própria de um troço tem prioridade
enquanto estiver dentro da tolerância, por isso um erro nas coordenadas oficiais
nunca troca um traçado que já estava certo. Sem coordenadas oficiais não se mexe
em nada.

Cada troço diz de onde veio a sua geometria em `geometry` (`"propria"` ou
`"de <mapid>"`) e se foi invertida em `reversed`.

### Filtros aplicados

São os mesmos critérios do `rally-map-control`, aplicados aqui uma vez em vez de em cada cliente:

- **Coordenadas inválidas descartadas** — nulls, não-números, `|lat| > 90`, `|lng| > 180` e pares `(0,0)`. A Anube devolve-os no meio de alguns traçados; sem o filtro geravam marcadores com `lat`/`lng` por resolver.
- **Troços sem traçado utilizável ficam fora de `sections`** — menos de 2 pontos válidos (não dá linha para desenhar nem contra a qual projectar distâncias) ou sem `name`.
- **Nada desaparece em silêncio** — o que ficou de fora vai para `skipped` com `mapid`, `name` e `reason` (`sem pontos`, `sem pontos válidos`, `só 1 ponto válido`, `sem nome na Anube`, `erro: ...`).
- `discarded` por troço diz quantas coordenadas foram descartadas num troço que passou.

### Campo `gps_location`

Presente aqui e em `/api/info`. Responde a uma pergunta só: **a Anube exige token
para dar o traçado deste rally?**

| Valor | Significado |
|---|---|
| `"unlocked"` | Não exige token |
| `"locked"` | Exige token |
| `null` | Indeterminado — a Anube não respondeu |

Sonda-se a lista de troços (`/sections/{subId}.json`) **sem token**, mesmo quando
há um guardado no [GPS Token Store](#gps-token-store): a pergunta é se a Anube o
exige, não se nós o temos. Nos rallies restritos essa lista responde `403`; nos
abertos responde `200`. Medido em dez rallies, a separação é exacta — e todos os
que dão `403` são os que a Anube nomeia "TRACKING DISABLED".

`unlocked` **não** garante que haja traçado: um rally aberto pode ainda não o ter
carregado (Famalicão 2026 tem os 9 troços na lista e nenhum com pontos). Isso
vê-se em `sections_count` e em `skipped`, não aqui.

O resultado fica 12h em cache por `subId`. Um erro de rede não é guardado: não
prova que o traçado esteja fechado.

`token_used` (só em `/api/tracks`) diz se o token do store foi usado para ler os traçados. Um rally pode portanto vir com `gps_location: "locked"` e `token_used: true` — é o caso normal de um rally restrito a que temos acesso.

### Cache

| Dado | TTL |
|---|---|
| Traçados por `subId` (`tracksCache`) | 12h |
| Estado `gps_location` por `subId` (`gpsLockCache`) | 12h |
| Subrally (`subrallyCache`) | 12h |

Os traçados não mudam durante a prova. Para os forçar antes do TTL: `GET /api/refreshCache/:rallyId.json` (limpa as três caches; os traçados são re-lidos na chamada seguinte, não ali — um rally com 30 troços são 30 pedidos à Anube).

A primeira chamada lê os troços da Anube com até 6 pedidos em paralelo; em série um rally grande levava mais de meio minuto a responder.


### Distância ao Traçado

### GET /distance?ident=X
### GET /api/distance?ident=X&rallyid=Y

Onde está um participante: o troço cujo traçado está mais próximo do GPS, a distância lateral a esse traçado e a percentagem do traçado já percorrida. Os dois caminhos são o mesmo endpoint.

| Parâmetro | Efeito |
|---|---|
| `ident` | **Obrigatório.** Dorsal do participante (o `ident` de `/api/participants`) |
| `rallyid` | Opcional. Prova onde procurar o dorsal |

**Resolução da prova.** O dorsal só é único dentro de uma prova, por isso segue-se a regra dos `/selected`, sem adivinhar:

1. `?rallyid=X` → essa prova (com a gravação, se for a do replay activo);
2. sem `rallyid` → a gravação em replay, se houver; senão o rally selecionado em `/api/config`;
3. nenhum dos dois → `400 Prova não determinável`.

**Origem dos dados.** Os mesmos de `/api/participants` (lista de inscritos + GPS da Anube por dorsal; em replay, os da gravação) e de `/api/tracks` (traçado sempre da Anube, 12h em cache).

**Cálculo** (o mesmo do site):

1. Cada traçado é passado a metros com uma projecção equiretangular local, com origem no seu primeiro ponto (`R = 6371008.8 m`).
2. O GPS é projectado sobre cada segmento; a projecção é limitada aos extremos do segmento.
3. Fica a projecção com a menor distância lateral; `along_m` é a distância acumulada no traçado até ela.
4. Escolhe-se o troço com a menor distância lateral.
5. `percent = along_m / length_m × 100`, limitado a 0–100. `length_m` é o comprimento geométrico do traçado (soma dos segmentos), **não** os `meters` oficiais da especial.

```json
{
  "event": {
    "type": "distance",
    "data": {
      "ident": "12",
      "participant_id": 1012,
      "rally_id": "324",
      "sub_id": "8512",
      "latitude": 37.6501655,
      "longitude": -8.2120490,
      "unixtime_gps": 1775938500,
      "section": { "mapid": "420015", "name": "T01 Super especial Ourique 0925" },
      "distance_m": 18.4,
      "along_m": 700,
      "length_m": 2000,
      "percent": 35,
      "source": "live"
    }
  }
}
```

- `section` usa os campos de `/api/tracks` (`mapid`, `name`). Os traçados da Anube não trazem o id da especial de timing, por isso não há ligação a `special_name`/`special_id`.
- `distance_m`, `along_m`, `length_m` e `percent` vêm arredondados a 1 casa decimal.
- `source`: `"live"` ou `"replay"`.
- Um GPS longe de todos os traçados **não** é erro: devolve-se o troço mais próximo com a distância real. A API não tem um limite de proximidade definido, por isso não há campo "está no traçado" — quem consome decide a partir de `distance_m`.
- `unixtime_gps` diz a idade do fix; não há filtro de GPS antigo.

**Erros** (forma `{ "error", "message", ... }`):

| Status | `error` | Quando |
|---|---|---|
| 400 | `ident em falta` / `ident inválido` | Sem `ident`, vazio, repetido na query ou com caracteres fora de `[A-Za-z0-9_-]` (máx. 20) |
| 400 | `rallyid inválido` | `rallyid` não numérico |
| 400 | `Prova não determinável` | Sem `rallyid`, sem replay e sem rally selecionado |
| 400 | `subId não encontrado` | A prova não tem subrally |
| 404 | `Participante não encontrado` | Nenhum inscrito com esse `ident` na prova |
| 409 | `Participante ambíguo` | Mais de um inscrito com esse `ident` (traz `participant_ids`) |
| 422 | `Sem GPS válido` | Sem fix: coordenadas ausentes, não numéricas, fora de gama ou `(0,0)` |
| 404 | `Sem traçados utilizáveis` | Nenhum troço com ≥ 2 pontos válidos (traz `gps_location`, `sections_total` e `skipped`) |
| 502 | `GPS indisponível` / `Participantes indisponíveis` | A Anube não respondeu |

---

## Info (Endpoint Agregado)

Agrega numa única chamada todos os dados estáticos da prova: race, localização GPS, itinerários com especiais, e meteorologia actual.

### GET /api/info/selected.json

Usa a configuração activa (`rallyId`, `subId`). Requer cache inicializado.

### GET /api/info/:rallyId.json?sub_id=X

Para qualquer rally. `sub_id` é opcional — necessário para obter `location` e `weather`.

**Exemplo:** `GET /api/info/324.json?sub_id=8512`

```json
{
  "event": {
    "type": "info",
    "data": {
      "name": "RALLY DE OURIQUE 2026",
      "ut_ini": 1775890800,
      "ut_fin": 1776034799,
      "timezone": "Europe/Lisbon",
      "country": "Portugal",
      "subrally_id": 8512,
      "location": {
        "lat": 37.697094,
        "lon": -8.326692,
        "zoom": 11
      },
      "gps_location": "unlocked",
      "itineraries": [
        {
          "id": 1279,
          "name": "MAIN Itinerary",
          "short_name": "MAIN",
          "specials": [
            {
              "id": 8441,
              "special_name": "PEC1",
              "name_extra": "SE OURIQUE",
              "meters": 1530,
              "ut_ini": 1775938080,
              "display_name": "PEC1 - SE OURIQUE (1,5km) - 10:08H",
              "state": 2
            }
          ]
        }
      ],
      "weather": {
        "description": "céu limpo",
        "icon": "01d",
        "icon_link": "https://maps.gstatic.com/weather/v1/CLEAR.png",
        "temp": 16.5,
        "feels_like": 15.5,
        "temp_min": 16.5,
        "temp_max": 16.5,
        "humidity": 47,
        "pressure": 1017,
        "wind_speed": 9.51,
        "wind_deg": 332,
        "clouds": 5,
        "visibility": 10000,
        "city": "Ourique Municipality",
        "country": "PT",
        "sunrise": 1775973754,
        "sunset": 1776020714,
        "dt": 1776003613
      }
    }
  }
}
```

### Campo `display_name`

Cada especial inclui um `display_name` formatado automaticamente:

```
{special_name} - {name_extra} ({metros/1000}km) - {HH:MM}H
```

Exemplos:
- `"PEC1 - SE OURIQUE (1,5km) - 10:08H"`
- `"SS2 (12,3km) - 14:30H"` (sem `name_extra`)

A hora é apresentada no fuso horário da prova (`timezone` do campo `race`).

### Cache

| Dado | TTL |
|---|---|
| Dados estáticos (race, itineraries, specials) | 12h (`multiRallyCache`) |
| Subrally (location) | 12h (`subrallyCache`) |
| Meteorologia (weather) | 30 min (`weatherCache`) |
| Estado `gps_location` | 12h (`gpsLockCache`) |

`sub_id` é necessário para `location`, `weather` e `gps_location`. Se omitido ou se as APIs externas falharem, esses campos ficam `null`.

O campo `gps_location` (`"locked"` / `"unlocked"` / `null`) diz se a Anube exige token para dar o traçado dos troços — ver [Troços](#troços-traçado-dos-troços).

---

## Sistema de Gravação

### Iniciar / Parar

```bash
# Iniciar (requer API key + config com rallyId, subId, itineraryId)
POST /api/recording/start
Response: {
  "recordingId": "race_312_2024-01-15T10-30-00",
  "status": "recording",
  "rallyName": "Rally de Portugal",
  "timestamp": "2024-01-15T10:30:00.000Z"
}

# Parar
POST /api/recording/stop
Response: { "success": true, "recordingId": "race_312_2024-01-15T10-30-00", "duration": "6h 15m" }

# Estado
GET /api/recording/status
Response: { "isRecording": true, "recordingId": "...", "startTime": "...", "rallyName": "..." }

# Listar
GET /api/recording/list
Response: {
  "recordings": [
    {
      "recordingId": "race_312_2024-01-15T10-30-00",
      "rallyName": "Rally de Portugal",
      "status": "completed",
      "startTime": "2024-01-15T10:30:00.000Z",
      "endTime": "2024-01-15T16:45:00.000Z",
      "snapshotCount": 375
    }
  ]
}

# Eliminar (API key)
DELETE /api/recording/:recordingId
```

### Auto-Start

O auto-start detecta automaticamente quando uma prova começa e inicia a gravação sem intervenção manual.

```bash
POST /api/recording/auto-start/enable    # Auth: API key
POST /api/recording/auto-start/disable   # Auth: API key
GET  /api/recording/auto-start/status
```

### O que é gravado

**Dados estáticos** (capturados uma vez no início):

| Ficheiro | Fonte |
|---|---|
| `static/race.json` | `https://rest3.anube.es/rallyrest/timing/api/race/{rallyId}.json` |
| `static/itineraries.json` | `https://rest3.anube.es/rallyrest/timing/api/itineraries/{rallyId}.json` |
| `static/specials.json` | `https://rest3.anube.es/rallyrest/timing/api/specials/{rallyId}.json?itinerary_id={id}` |
| `static/participants.json` | `https://rest3.anube.es/rallyrest/timing/api/participants/{rallyId}.json` |

**Dados dinâmicos** (cada 60 segundos, por snapshot):

| Chave no snapshot | Fonte |
|---|---|
| `participants` (GPS) | `https://rest.anube.es/rallyrest/default/api/participants/{subId}.json` |
| `penalizations` | `https://rest3.anube.es/rallyrest/timing/api/penalizations/{rallyId}.json` |
| `chronos2_special_{id}` | `https://rest3.anube.es/rallyrest/timing/api/chronos2/{rallyId}.json?special_id={id}` |
| `classification_special_{id}` | `https://rest3.anube.es/rallyrest/timing/api/classification/{rallyId}.json?...` |
| `sections_special_{id}` | `https://rest3.anube.es/rallyrest/default/api/section_scorings/{subId}/{specialName}.json` |
| `partials_special_{id}` | `https://rest3.anube.es/rallyrest/timing/api/partials/{rallyId}.json?special_id={id}` |

### Lógica de gravação

```
1. Início → captura dados estáticos, inicia timer de 60s
2. Cada 60s:
   ├── Grava GPS/participants
   ├── Grava penalizations
   └── Para cada especial activa (já iniciada):
       ├── Grava chronos2
       ├── Grava classification
       ├── Grava sections (se specialName disponível)
       ├── Grava partials
       └── Verifica se completou (todos com time != 0, ou timeout 2h)
3. Termina quando todas as especiais completam ou passa ut_fin da prova
```

### Estrutura de ficheiros

```
recordings/
├── config.json                          # Configuração persistida
├── index.json                           # Índice global de gravações
└── races/
    └── race_312_2024-01-15T10-30-00/
        ├── metadata.json
        ├── static/
        │   ├── race.json
        │   ├── itineraries.json
        │   ├── specials.json
        │   └── participants.json
        └── timeline/
            ├── 1705320000_000.json      # Minuto 0
            ├── 1705320060_001.json      # Minuto 1
            └── ...
```

### metadata.json

```json
{
  "recordingId": "race_312_2024-01-15T10-30-00",
  "rallyId": "312",
  "rallyName": "Rally de Portugal",
  "subId": "456",
  "itineraryId": "789",
  "recordingStarted": "2024-01-15T10:30:00.000Z",
  "recordingCompleted": "2024-01-15T16:45:00.000Z",
  "status": "completed",
  "totalMinutes": 375,
  "specials": [
    { "id": "101", "name": "SS1", "ut_ini": 1705330000 },
    { "id": "102", "name": "SS2", "ut_ini": 1705340000 }
  ]
}
```

---

## Sistema de Replay

### Carregar e controlar

```bash
# Carregar gravação (requer API key)
POST /api/replay/start
Body: { "recordingId": "race_312_2024-01-15T10-30-00", "startMinute": 0 }
Response: {
  "success": true,
  "recordingId": "...",
  "rallyName": "Rally de Portugal",
  "maxMinutes": 22500,          # total em segundos reais
  "specials": [...],
  "pois": [...]                 # pontos de interesse (início/fim de especiais)
}

POST /api/replay/play           # Iniciar/retomar reprodução
POST /api/replay/pause          # Pausar
POST /api/replay/seek           Body: { "minute": 3600 }    # Saltar para segundo
POST /api/replay/speed          Body: { "speed": 2 }        # 1, 2, 5 ou 10
POST /api/replay/select-special Body: { "specialId": "101" }
POST /api/replay/stop           # Terminar replay

GET /api/replay/status
Response: {
  "enabled": true,
  "recordingId": "...",
  "rallyName": "Rally de Portugal",
  "currentMinute": 3600,        # segundo actual
  "maxMinutes": 22500,          # total segundos
  "playbackSpeed": 2,
  "isPlaying": true,
  "selectedSpecialId": "101",
  "specials": [...],
  "progress": 16                # percentagem
}
```

### Comportamento dos endpoints em modo replay

Quando replay está activo, qualquer pedido GET cujo `rallyId` coincida com a gravação retorna dados gravados:

| Endpoint | Dados retornados |
|---|---|
| `/api/race/[selected\|:rallyId].json` | `static/race.json` |
| `/api/itineraries/[selected\|:rallyId].json` | `static/itineraries.json` |
| `/api/specials/[selected\|:rallyId].json` | `static/specials.json` |
| `/api/participants/[selected\|:rallyId].json` | `static/participants.json` + GPS do snapshot actual |
| `/api/gps/selected.json` | GPS do snapshot actual |
| `/api/penalizations/[selected\|:rallyId].json` | `penalizations` do snapshot actual |
| `/api/classification/[selected\|:rallyId].json` | `classification_special_{id}` do snapshot actual |
| `/api/chronos2/[selected\|:rallyId].json` | `chronos2_special_{id}` do snapshot actual + enriquecimento (incl. partials) |
| `/api/sections/selected.json` | `sections_special_{id}` do snapshot actual |
| `/api/partials/[selected\|:rallyId].json` | `partials_special_{id}` do snapshot actual + enriquecimento |

A "posição actual" é determinada pelo segundo em curso no playback: o sistema faz uma busca binária no array de timestamps para encontrar o snapshot mais recente que não ultrapassa o segundo actual.

### Velocidades de replay

| Velocidade | Avanço real |
|---|---|
| 1x | 1 segundo a cada 1000ms |
| 2x | 1 segundo a cada 500ms |
| 5x | 1 segundo a cada 200ms |
| 10x | 1 segundo a cada 100ms |

---

## Estrutura de Dados

### Chronos — campos `in_race` e `time`

- `in_race: true` — participante tem `ut_start_original` preenchido mas `ut_finish_original` a null (está na especial)
- `time: 0` — participante ainda não terminou (ou não iniciou)
- `time > 0` — tempo final em milissegundos

### GPS — campo `state`

Valores comuns: `"racing"`, `"stopped"`, `"sos"`, `"finished"`, `"retired"`

### Enriquecimento do Chronos2

O endpoint `chronos2` faz o seguinte pipeline de enriquecimento:

```
chronos (participant_id)
    → participants cache (ident/dorsal, pilot, copilot, vehicle, nat)
    → GPS data (latitude, longitude, state, speed, bearing, color)
    → sections data (tempos por ponto de controlo)
    → partials data (run, ut_start, uts_partial[], ut_finish)
    → in_race flag (ut_start_original && !ut_finish_original)
    → intermediates_meter (distâncias dos pontos intermediários, topo da resposta)
```

Tanto `partials` como `section_scorings` são não-fatais: uma falha na API Anube retorna `null` nesses campos sem afectar o resto da resposta.

---

## Cache

| Cache | TTL | Conteúdo |
|---|---|---|
| `rallyStaticCache` | Até rally mudar | race, itineraries, participants, specials do rally seleccionado |
| `multiRallyCache` | 12h | dados estáticos por rallyId (para endpoints genéricos) |
| `subrallyCache` | 12h | dados subrally por subId (localização GPS da prova, stages) |
| `weatherCache` | 30 min | meteorologia por lat/lon arredondado (OpenWeatherMap) |
| `tracksCache` | 12h | traçado dos troços por subId (`/api/tracks`) |
| `gpsLockCache` | 12h | estado `gps_location` (locked/unlocked) por subId |
| participantes | 60s | lista de inscritos (`PARTICIPANTS_TTL_MS`) |
| `inRaceCache` | 5s | só o flag derivado `in_race` (`IN_RACE_TTL_S`, 0 desliga) |

### O que NÃO tem cache, de propósito

Os dados ao vivo são sempre lidos na hora: `chronos2`, `classification`,
`penalizations`, `partials`, `section_scorings`, GPS e o `state` das especiais.

Nestes aplica-se apenas **deduplicação de pedidos simultâneos** (`fetchDedup`):
se três aplicações pedirem o mesmo recurso ao mesmo tempo, abre-se **uma** ligação
à Anube e as três recebem essa resposta. Não fica nada guardado — é o que permite
cortar pedidos sem nunca pôr no ar um valor velho.

A única excepção é o flag `in_race` (5s), que é derivado e não um tempo: uma
especial dura minutos, e calculá-lo custava um pedido `chronos2` por *cada*
especial do rally em *cada* leitura dos participantes.

O `rallyStaticCache` é invalidado e reconstruído quando:
- `rallyId` muda no `POST /api/config`
- `itineraryId` muda (actualiza apenas specials)

---

## Troubleshooting

**"Nenhum rally selecionado"**
- Verifica `GET /api/config` — se `rallyId` for null, faz `POST /api/config` com os dados correctos.
- Após reinício do servidor, a config é carregada de `recordings/config.json` automaticamente.

**"Cache ainda não inicializado"**
- Faz `POST /api/config` com um `rallyId` válido para popular o cache estático.

**"Gravação não encontrada"**
- Verifica se a pasta `recordings/races/{recordingId}` existe e tem `metadata.json`.

**GPS sem dados**
- Confirma que `subId` está correcto na configuração.
- Se o GPS for protegido, fornece o `gpsToken` na configuração.

**Replay não retorna dados gravados nos endpoints genéricos**
- O `rallyId` na URL tem de coincidir exactamente com o `rallyId` da gravação em replay.
- Verifica `GET /api/replay/status` para confirmar qual o `rallyId` da gravação activa.

**Puppeteer falha no Docker**
- Define `PUPPETEER_EXECUTABLE_PATH` no `.env` apontando para o Chrome instalado.
- O servidor continua a funcionar com a lista de rallies em fallback.
