# v2.md — Refonte : carte des aires de pique-nique (Node.js + MongoDB)

Ce document décrit comment reconstruire l'application actuelle (un prototype en
deux scripts PHP) en une **v2** propre et intégrée :

- un **backend Node.js / Express** unique,
- une base **MongoDB** (la vraie base de données, fini le « `data/` est la base »),
- toutes les routes API préfixées par **`/api`**,
- les pages HTML servies statiquement depuis **`./public`** à la racine **`/`**.

Le but n'est **pas** d'ajouter des fonctionnalités mais de reprendre
**exactement** celles du prototype, mieux structurées : un vrai modèle de données,
une API REST, un frontend découplé, et un grabber (scraper) planifié.

> Deux documents voisins (`API.md`, `APP.md`) décrivent une variante « JSON sur
> disque » et un portage mobile. **Ce `v2.md` les remplace pour la cible web** :
> il assume MongoDB comme source de vérité. Les règles métier (contrat
> équipements, normalisation, algorithmes d'itinéraire) y sont **identiques**.

---

## 1. Inventaire exhaustif des fonctionnalités actuelles

Tout ce que fait le prototype aujourd'hui, à reproduire à l'identique en v2.
Source : `index.php` (frontend + rendu inline), `note.php` (notes), `lieux.php`
(scraper).

### 1.1 Carte
- **C1** — Carte plein écran, fond de tuiles **OpenStreetMap**, clustering des
  markers (`leaflet.markercluster`).
- **C2** — Vue initiale **France entière** : centre `[46.5, 2]`, zoom `6`.
- **C3** — **Permalink de l'état carte** : à chaque `moveend`, l'URL est mise à
  jour (`?lat&lon&z`) via `history.replaceState`. Au chargement, si `lat/lon/z`
  présents, on restaure cette vue.
- **C4** — Un marker par aire. Au clic → **popup** contenant :
  - le **titre** (lien vers `pique-nique.info/AireDetail.php?lieu={id}`),
  - la **1ʳᵉ photo** (`width=150`) si disponible,
  - la **liste des équipements actifs** (texte),
  - un lien **📷 Street View** (`google.com/maps/@?api=1&map_action=pano&viewpoint={lat},{lon}`),
  - un bloc **« étape »** (distance/durée depuis le départ d'itinéraire, si itinéraire actif),
  - un bloc **notation** (moyenne + vote).
- **C5** — À l'ouverture d'une popup, l'URL reçoit `?lieu={id}` ; à la fermeture,
  le paramètre est retiré.
- **C6** — **Restauration du lieu ouvert** au chargement : `?lieu={id}` →
  `clusterGroup.zoomToShowLayer(marker, …)` puis ouverture de la popup.
- **C7** — Bouton **géolocalisation 📍** : recentre (zoom 14) + marker
  « 📍 Vous êtes ici » (via `navigator.geolocation`, alerte si refus/erreur).

### 1.2 Recherche (barre en haut à droite)
> **Changement UX v2** — dans le prototype, cette barre et le panneau itinéraire
> (§1.6) sont **affichés en même temps**. En v2 on **unifie** en un seul panneau à
> deux modes exclusifs (« Lieu » / « Itinéraire »), cf. §6.1. Le comportement de
> chaque champ reste identique.

- **S1** — Champ « Rechercher un lieu ou une ville… ».
- **S2** — **Autocomplete combiné** (debounce 300 ms, min 2 caractères) :
  - lieux locaux dont le `nom` contient la saisie (préfixe 🍽️, max 5),
    match **insensible aux accents** (`normaliser` = NFD + suppression
    diacritiques + lowercase),
  - villes via **Photon (komoot)** filtrées `country === 'France'` (préfixe 🏙️, max 5).
- **S3** — Bouton **×** pour vider (sur la barre **et** les champs Départ/Arrivée),
  navigation clavier (↑/↓/Entrée/Échap).
- **S4** — Sélection d'une suggestion → recentre (zoom 14) + popup au point avec
  bouton **« 🧭 Itinéraire depuis ici »** qui pré-remplit le **Départ**.
- **S5** — Validation au clavier (`change`) sans suggestion : match local d'abord
  (zoom 15), sinon fallback **Nominatim** (`countrycodes=fr`, zoom 13 + popup),
  sinon alerte « Ville non trouvée ».

### 1.3 Filtres équipements (toolbar verticale)
- **E1** — 9 boutons emoji, **contrat figé** (ordre + emoji), cf. §3.4.
- **E2** — Filtrage **AND** : un lieu n'apparaît que s'il possède **tous** les
  équipements sélectionnés.
- **E3** — État des filtres dans l'URL : `?eq=` **répété**, toggle par bouton,
  bouton actif visuellement marqué.

### 1.4 Filtre par note (bouton ⭐)
- **N1** — Ouvre une **modale** « note min / note max » (1–5, défaut min 3 / max 5).
- **N2** — Validation → ne garde que les lieux dont `note_avg ∈ [min, max]` ; les
  lieux **sans avis** (`note_avg == null`) sont **exclus**. `min`/`max` réordonnés
  si inversés.
- **N3** — Re-clic sur ⭐ quand le filtre est actif → le **retire** (pas de modale).

### 1.5 Notation (dans la popup d'un lieu)
- **R1** — À l'ouverture : `GET note.php?id=` → moyenne en **étoiles** (`★`/`☆`,
  arrondi) + `X/5 (N avis)` ou « pas encore d'avis ».
- **R2** — Rangée « Votre note » : 5 étoiles, survol → highlight progressif.
- **R3** — Clic sur une étoile → `POST note.php {id, note}` → réaffiche la moyenne
  **mise à jour**.

### 1.6 Itinéraire (panneau bas-gauche, repliable)
> **Changement UX v2** — fusionné avec la barre de recherche §1.2 en un panneau à
> deux modes exclusifs (cf. §6.1) : on ne voit jamais les deux formulaires en même
> temps. Le pliage/dépliage `localStorage` (I0) est remplacé par la bascule de mode.

- **I0** — Panneau **repliable**, état persisté (`localStorage 'itineraire-collapsed'`).
- **I1** — Champ **Départ** + autocomplete Photon + ×.
- **I2** — Bouton **swap ⇅** (échange Départ/Arrivée).
- **I3** — Champ **Arrivée** + autocomplete Photon + ×.
- **I4** — **Mode** voiture 🚗 / vélo 🚲 (radio). Changer de mode **recalcule**
  l'itinéraire si Départ + Arrivée renseignés.
- **I5** — Lien « **Indiquer une heure de départ** » → révèle « Heure de départ » +
  « Position à ».
- **I6** — **Slider distance** 5→50 km (pas 5, défaut 10) : « Aires à moins de X km »
  (+ suffixe « de ma position à HH:MM » quand le filtre temporel est actif).
- **I7** — Bouton **🧭 Itinéraire** (état loading).
- **I8** — **Liste des itinéraires** : chaque ligne = `X km` + durée ; clic →
  sélectionne (les autres tracés passent à opacité 0.25).
- **I9** — Lien **« Voir dans maps »** → Google Maps directions
  (`travelmode` = `driving`/`bicycling` selon le mode).
- **I10** — **Filtrage des aires par la ligne** : seules les aires à **moins de
  X km de la polyligne** restent visibles (`turf.pointToLineDistance`).
- **I11** — **Filtre temporel** : si « heure départ » + « position à » renseignées,
  on estime la position sur le trajet à l'heure cible et on ne garde que les aires
  à **moins de X km de CE point** ; un marker « 📍 HH:MM » est posé sur le trajet.
- **I12** — Bloc **« étape »** dans chaque popup quand un itinéraire existe :
  distance/durée OSRM depuis le **départ** du trajet jusqu'à l'aire.
- **I13** — **Permalink itinéraire** : `?from&to&mode&dist&start&target` (+ `lieu`),
  restauré au chargement (recalcule l'itinéraire).

### 1.7 Correction de coordonnées
- **O1** — Si un **override** de coordonnées existe pour un lieu, il **remplace**
  la lat/lon issue du scraping. (Aujourd'hui : `data/coords/{id}.json`.)

### 1.8 Scraper (`lieux.php`)
- **G1** — Parcourt tous les départements (`01..19`, `21..95`, `2A`, `2B`, `971`,
  `972`, `973`, `974`, `976`).
- **G2** — **Phase 1** : télécharge la page de recherche de chaque département,
  extrait les ids via regex `AireDetail\.php\?lieu=(\d+)`.
- **G3** — **Phase 2** : pour chaque id inconnu, scrape la fiche détail (nom,
  description, latitude, longitude, photos, équipements) via XPath sur des labels
  texte précis.
- **G4** — **Idempotent & incrémental** : la présence d'un fichier fait sauter le
  fetch. Throttle **200 ms** entre deux fiches.
- **G5** — Scraping **fragile par nature** : un fetch raté renvoie `null` et la
  fiche est ignorée (jamais d'écriture partielle).

---

## 2. Architecture cible v2

Un **seul process Node** :
1. sert l'**API REST** sous `/api/*`,
2. sert le **frontend statique** (`public/`) à la racine `/`,
3. héberge le **grabber** planifié (cron in-process) qui alimente MongoDB.

```
                         ┌───────────────────────────────────────────┐
   Navigateur            │            Serveur Node / Express          │
  ┌─────────────┐  GET / │  ┌──────────────┐   ┌────────────────────┐ │
  │ public/     │◄───────┤  │ static files │   │  Routes /api/*     │ │
  │ index.html  │        │  │ (./public)   │   │  lieux, notes,     │ │
  │ + app.js    │───────►│  └──────────────┘   │  equipements,      │ │
  │ (Leaflet)   │ /api/… │                     │  (geocode/route)   │ │
  └─────┬───────┘  JSON  │        ┌────────────┴─────────┐          │ │
        │                │        │   Services (store,   │          │ │
        │                │        │   notes, grabber)    │──────────┼─┼──► MongoDB
        │                │        └──────────────────────┘          │ │   (lieux, notes,
        │                │        ┌──────────────────────┐          │ │    departements)
        │                │        │  Cron → grabber      │──scrape──┼─┼──► pique-nique.info
        │                │        └──────────────────────┘          │ │
        │  services tiers (directs OU proxifiés via /api)           │ │
        ▼                └───────────────────────────────────────────┘
  Photon / Nominatim / OSRM / tuiles OSM
```

Le backend absorbe les **3 points d'I/O** du prototype :
1. le **dump inline** de `index.php` → `GET /api/lieux`,
2. **`note.php`** (lecture/écriture des notes) → `/api/lieux/:id/notes`,
3. la **normalisation** PHP (équipements forcés, override coords, moyenne,
   exclusion `Logo Pinterest`) → désormais dans MongoDB / la couche service.

---

## 3. Modèle de données MongoDB

C'est le **changement structurant** de la v2 : les JSON-sur-disque deviennent des
collections MongoDB. On conserve `id` (l'identifiant source de pique-nique.info,
une **chaîne** numérique) comme `_id`.

### 3.1 Collection `lieux`

Un document par aire. On **dénormalise** ce qui est coûteux à recalculer (coords
effectives, équipements actifs triés, moyenne des notes) pour que l'API réponde
sans agrégation lourde.

```jsonc
{
  "_id": "1",                                   // id source (string)
  "nom": "Jardins de la Corderie Royale",
  "description": "Quatre tables sont installées...",
  "latitude": 45.941205,                        // number (parsé depuis le scrap)
  "longitude": -0.954939,                        // number
  "location": { "type": "Point",                // GeoJSON pour requêtes bbox/geo
                "coordinates": [-0.954939, 45.941205] },  // [lon, lat]
  "photos": ["https://www.pique-nique.info/photos/corderie.jpg", "..."],

  "equipements": {                              // map complète nom → bool (inclut inactifs)
    "Table & banc": true, "Wc": true, "Parking": true, "Poubelle": true,
    "Barbecue": false, "Camping-car": true, "Point eau": true,
    "Aire de jeux": true, "Acces handicape": true
  },
  "equipementsActifs": ["Acces handicape","Camping-car","Parking","..."], // triés, sans "Logo Pinterest"

  "coordsOverride": { "latitude": 45.9412, "longitude": -0.9549 }, // null si aucun override
  "noteAvg": 4.0,                               // dénormalisé ; null si 0 avis
  "noteCount": 1,                               // dénormalisé

  "source": "pique-nique.info",
  "scrapedAt": "2026-05-10T07:32:00.000Z",
  "updatedAt": "2026-05-10T07:32:00.000Z"       // pour ?updated_since=
}
```

**Règles de normalisation (identiques à `index.php`, appliquées à l'écriture) :**
- `latitude/longitude` = override s'il existe, sinon valeurs scrapées ; converties
  en `number`. `location.coordinates = [lon, lat]`.
- `equipements` : les **9 clés du contrat** (§3.4) sont **forcées** (à `false` si
  absentes).
- `equipementsActifs` = clés `true`, **`Logo Pinterest` exclu**, **triées A→Z**.
- Un lieu **sans latitude/longitude** exploitable est marqué invalide et
  **exclu** des réponses liste (équivalent du `continue` PHP). On peut soit ne pas
  l'insérer, soit poser un flag `valid: false` et filtrer dans les requêtes.

**Index :**
```js
db.lieux.createIndex({ location: "2dsphere" });        // bbox / near
db.lieux.createIndex({ equipementsActifs: 1 });        // filtre équipements
db.lieux.createIndex({ noteAvg: 1 });                  // filtre note
db.lieux.createIndex({ updatedAt: 1 });                // sync incrémentale
// nom : index texte optionnel ; la recherche accent-insensitive reste surtout côté client
```

### 3.2 Collection `notes`

Un document **par avis** (remplace `data/notes/{id}.json`). Plus propre qu'un
tableau embarqué : insertion concurrente sûre, agrégation simple.

```jsonc
{ "_id": ObjectId, "lieuId": "1", "note": 4, "date": "2026-05-10T07:04:28.000Z" }
```

```js
db.notes.createIndex({ lieuId: 1 });
```

> À chaque insertion d'avis, on **recalcule** `noteAvg`/`noteCount` et on les
> **écrit sur le document `lieux`** correspondant (dénormalisation, §5.3). La
> collection `notes` reste la source de vérité.

### 3.3 Collection `departements` (cache du scraper)

Remplace `data/departements/{NN}.json`. Sert le cache de Phase 1 avec un **TTL**
pour redécouvrir les nouvelles aires.

```jsonc
{
  "_id": "17",                     // code département
  "ids": ["1","2","3","..."],      // ids extraits par la regex
  "html": "…",                     // (optionnel) HTML brut, pour re-extraire sans refetch
  "fetchedAt": "2026-05-10T06:47:00.000Z"
}
```

> **Différence assumée avec `lieux.php`** : le PHP cache la page département « pour
> toujours ». En v2 on applique un **TTL** (défaut 7 jours) sur `fetchedAt` : au-delà,
> on refetch pour découvrir les aires ajoutées en amont. Les fiches `lieux`, elles,
> restent en cache permanent (présence = skip).

### 3.4 Contrat des équipements (FIGÉ — ne pas modifier sans intention)

Ordre et emojis **identiques** à `$equipements_importants` de `index.php`. Ces 9
clés sont garanties sur chaque lieu et pilotent la toolbar.

```js
// backend + frontend partagent cette constante (exposée aussi via GET /api/equipements)
export const EQUIPEMENTS = [
  { key: "Table & banc",    emoji: "🪑" },
  { key: "Wc",              emoji: "🚻" },
  { key: "Parking",         emoji: "🅿️" },
  { key: "Poubelle",        emoji: "🗑️" },
  { key: "Barbecue",        emoji: "🔥" },
  { key: "Camping-car",     emoji: "🚐" },
  { key: "Point eau",       emoji: "🚰" },
  { key: "Aire de jeux",    emoji: "🎠" },
  { key: "Acces handicape", emoji: "♿" },
];
```

---

## 4. Stack technique

| Domaine | Choix | Rôle |
|---|---|---|
| Runtime | **Node.js ≥ 20** (fetch natif) | |
| Serveur HTTP | **Express** | API `/api/*` + static `./public` |
| Base de données | **MongoDB** (driver officiel `mongodb`, ou **Mongoose**) | Source de vérité |
| Validation | **zod** | Params/bodies (note, bbox, eq…) |
| Scraping HTML | **cheerio** | Remplace `DOMDocument`/`DOMXPath` |
| Planification | **node-cron** (in-process) | Lance le grabber |
| Sécurité | `helmet`, `cors`, `express-rate-limit` | |
| Logs | `pino` (+ `pino-http`) | |

> **Driver `mongodb` vs Mongoose** : le modèle est simple et l'écriture est faite
> par le grabber. Le driver natif suffit et évite une couche. Mongoose est un
> confort si l'on veut des schémas/validation centralisés — au choix de l'équipe.
> Les exemples ci-dessous utilisent le **driver natif**.

---

## 5. API REST (`/api/*`)

Toutes les réponses en `application/json; charset=utf-8`. CORS ouvert (à
restreindre en prod).

### 5.1 `GET /api/lieux`

Liste normalisée (équivalent du dump de `index.php`). Par défaut renvoie **tout**
le dataset (le frontend actuel charge tout d'un coup), avec des filtres serveur
optionnels.

Query params (tous optionnels) :

| Param | Type | Effet |
|---|---|---|
| `bbox` | `minLon,minLat,maxLon,maxLat` | Restreint à l'emprise (`$geoWithin`/`2dsphere`) |
| `eq` | répétable (`?eq=Wc&eq=Parking`) | Lieux ayant **TOUS** ces équipements (`$all`) |
| `note_min` / `note_max` | 1–5 | Filtre sur `noteAvg` (exclut `null`) |
| `updated_since` | ISO 8601 | `updatedAt >= …` (sync incrémentale) |

Réponse (objet « format liste », strictement ce que le front consomme) :
```jsonc
{
  "count": 6045,
  "lieux": [
    {
      "id": "1", "nom": "...", "lat": 45.941205, "lon": -0.954939,
      "photos": ["..."],
      "equipements": ["Acces handicape","Camping-car","Parking","..."], // = equipementsActifs
      "note_avg": 4.0, "note_count": 1
    }
  ]
}
```

Projection Mongo → objet liste (mapping `_id→id`, `latitude→lat`, etc.) fait dans
la couche service.

### 5.2 `GET /api/lieux/:id`
Détail (ajoute `description`). `404` si l'id n'existe pas.
```jsonc
{ "id":"1","nom":"...","description":"...","lat":45.94,"lon":-0.95,
  "photos":["..."],"equipements":["Parking","Wc","..."],
  "note_avg":4.0,"note_count":1 }
```

### 5.3 `GET /api/lieux/:id/notes`  (équivalent `note.php?id=`, GET)
```jsonc
{ "id": "1", "count": 1, "average": 4.0 }
```

### 5.4 `POST /api/lieux/:id/notes`  (équivalent `note.php`, POST)
Body JSON : `{ "note": 4 }` (entier **1–5**).
- `400` si `id` non numérique (`/^\d+$/`) ou `note` hors [1,5].
- Insère `{ lieuId, note, date: new Date() }` dans `notes`, **recalcule**
  `average`/`count`, met à jour `noteAvg`/`noteCount` sur le doc `lieux`, renvoie :
```jsonc
{ "id": "1", "count": 2, "average": 4.5 }
```
> `average` = moyenne arrondie à **2 décimales**, `null` si aucun avis (identique
> au PHP).

### 5.5 `GET /api/equipements`
Renvoie la liste `EQUIPEMENTS` (§3.4) pour piloter la toolbar sans la coder en dur.
```jsonc
[ { "key": "Table & banc", "emoji": "🪑" }, { "key": "Wc", "emoji": "🚻" }, ... ]
```

### 5.6 (Optionnel) Proxys géocodage / routing
Le prototype appelle Photon / Nominatim / OSRM **directement** depuis le
navigateur. On peut le **garder tel quel** en v2 (simple) ou proxifier via `/api`
pour respecter les quotas et ajouter du cache :
- `GET /api/geocode?q=...&kind=auto|exact` → Photon (autocomplete) / Nominatim (résolution).
- `GET /api/route?profile=car|bike&coords=lon,lat;lon,lat[;...]` → OSRM, applique
  le `durationFactor` (car 0.9 / bike 1.0) et le **fallback d'endpoints** (§7.2).

> Recommandation : commencer **sans proxy** (appels directs, comme aujourd'hui),
> ajouter le proxy plus tard si les quotas posent problème.

### 5.7 (Optionnel) `POST /api/admin/grab`
Déclenche un grab manuel (protégé par `ADMIN_TOKEN`). Utile pour le premier
remplissage et les tests.

---

## 6. Frontend statique (`./public`)

Le `index.php` actuel mélange **rendu serveur** (le `foreach` PHP qui construit
`$lieux` et l'injecte dans `const lieux = …`) et **frontend** (Leaflet, filtres,
itinéraire). En v2 on **sépare** :

- Le PHP disparaît : `public/index.html` est une page statique.
- Le bloc `const lieux = <?= json_encode(...) ?>` est remplacé par un
  **`fetch('/api/lieux')`** au démarrage.
- La toolbar (aujourd'hui rendue par le `foreach` PHP sur `$equipements_importants`)
  est construite en JS depuis `GET /api/equipements` (ou une constante partagée).
- Les appels `note.php` deviennent `/api/lieux/:id/notes`.
- **Tout le reste du JS est repris tel quel** : Leaflet + markercluster, Turf,
  autocomplete Photon, `computeRoute`, filtre temporel, permalinks (`history.replaceState`).
  Aucune logique métier ne change.

### 6.1 Recherche unifiée à deux modes (une seule zone à la fois)

Le prototype affiche **deux formulaires de recherche simultanément** : la barre
« Lieu/ville » (haut-droite) et le panneau « Itinéraire » (bas-gauche). En v2 on
les **fusionne en un panneau unique** avec une bascule ; **un seul mode est visible
à la fois** :

- **Mode « 🔍 Lieu »** (par défaut) : le champ de recherche lieu/ville seul
  (autocomplete combiné 🍽️/🏙️, cf. S2). Le formulaire itinéraire est masqué.
- **Mode « 🧭 Itinéraire »** : les champs Départ / swap / Arrivée / mode / heures /
  slider / liste des tracés (cf. §1.6). La barre de recherche lieu est masquée.

```
┌──────────────────────────────┐
│  [ 🔍 Lieu ]  [ 🧭 Itinéraire ]│  ← segmented control (une seule zone active)
├──────────────────────────────┤
│  … contenu du mode courant …  │
└──────────────────────────────┘
```

Règles de bascule :
- Un **segmented control** (2 onglets) en tête du panneau bascule le mode ; l'autre
  formulaire est retiré du DOM/masqué (`hidden`), il n'existe **jamais** deux champs
  de saisie actifs en même temps.
- Le bouton **« 🧭 Itinéraire depuis ici »** d'une popup de lieu/ville (S4) bascule
  automatiquement en mode Itinéraire **et** pré-remplit le Départ (remplace l'ancien
  `setAsDeparture` qui dépliait le panneau).
- Le mode courant remplace l'ancien état `itineraire-collapsed` : il est **persisté**
  (`localStorage 'search-mode' = 'lieu' | 'itineraire'`) et ajouté au **permalink**
  (`?panel=lieu|itineraire`) pour restauration au chargement (I13).
- Passer d'un mode à l'autre **ne réinitialise pas** l'état de l'autre : les champs
  Départ/Arrivée et le tracé calculé sont conservés (juste masqués), donc revenir en
  mode Itinéraire réaffiche le dernier itinéraire. De même, un itinéraire actif
  **continue de filtrer les markers** même en mode Lieu (le filtrage §1.6 I10/I11
  n'est pas lié à la visibilité du formulaire).

Impact sur le code repris de `index.php` :
- Les blocs `#search` et `#itineraire` deviennent deux panneaux enfants d'un même
  conteneur `#searchPanel` ; on supprime `#btnCollapse`/`#btnExpand` au profit du
  segmented control.
- `setupAutocomplete` reste inchangé ; on l'initialise sur les trois champs comme
  avant (le masquage n'empêche pas l'init).
- `restoreItineraireFromURL()` lit en plus `?panel=` pour choisir le mode initial ;
  si `from`/`to` sont présents dans l'URL, on force le mode Itinéraire.

`public/` :
```
public/
  index.html        # ex-index.php, sans PHP ; <head> + toolbar + panneau itinéraire + #map
  app.js            # tout le <script> de index.php, adapté (voir ci-dessous)
  styles.css        # le <style> de index.php extrait
```

Adaptations JS minimales (le diff conceptuel avec `index.php`) :
```js
// AVANT (PHP) : const lieux = <?= json_encode($lieux) ?>;
// APRÈS :
let lieux = [];
async function boot() {
  const res = await fetch('/api/lieux');
  const data = await res.json();
  lieux = data.lieux;                 // format liste identique à l'ancien dump
  buildToolbar(await (await fetch('/api/equipements')).json());
  updateToolbarUI();
  updateMarkers();
  restoreOpenLieu();
  restoreItineraireFromURL();
}
boot();

// note.php → /api/lieux/:id/notes
async function loadNotation(id, node) {
  const res = await fetch(`/api/lieux/${encodeURIComponent(id)}/notes`);
  renderNotation(node, id, await res.json());
}
// vote : POST JSON au lieu de application/x-www-form-urlencoded
const res = await fetch(`/api/lieux/${encodeURIComponent(id)}/notes`, {
  method: 'POST', headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ note: n }),
});
```

> ⚠️ **Ordre d'initialisation** : le prototype exécute `updateMarkers()` et les
> restaurations d'URL de façon synchrone car les données sont inline. En v2 elles
> deviennent **asynchrones** (après le `fetch`) — d'où le `boot()` ci-dessus qui
> séquence tout après réception des lieux.

Les CDN restent identiques (Leaflet 1.9.4, markercluster 1.5.3, Turf 6.5.0,
Bulma) : aucun build front nécessaire.

---

## 7. Le grabber (portage de `lieux.php`)

Même logique en deux phases, **idempotente et incrémentale**, mais qui écrit dans
**MongoDB** au lieu de fichiers.

### 7.1 Départements
```ts
export function getDepartements(): string[] {
  const pad = (n: number) => String(n).padStart(2, "0");
  const range = (a: number, b: number) =>
    Array.from({ length: b - a + 1 }, (_, i) => pad(a + i));
  return [...range(1, 19), ...range(21, 95), "2A", "2B", "971", "972", "973", "974", "976"];
}
```

### 7.2 Phase 1 — collecte des ids (cache Mongo + TTL)
```ts
const URL_DEP = (dep: string) =>
  `https://www.pique-nique.info/AireRecherche.php?departement=${dep}`;

async function collectIds(dep: string, ttlMs: number): Promise<string[]> {
  const cached = await db.collection("departements").findOne({ _id: dep });
  const fresh = cached && (Date.now() - +new Date(cached.fetchedAt)) < ttlMs;
  if (fresh) return cached.ids;

  const html = await fetchText(URL_DEP(dep));
  if (!html) return cached?.ids ?? [];
  const ids = [...html.matchAll(/AireDetail\.php\?lieu=(\d+)/g)].map(m => m[1]); // regex identique
  await db.collection("departements").updateOne(
    { _id: dep },
    { $set: { ids, html, fetchedAt: new Date() } },
    { upsert: true },
  );
  return ids;
}
```

### 7.3 Phase 2 — scraping des fiches (cheerio, port de `scrape_aire_detail`)
```ts
import * as cheerio from "cheerio";

const EQ_KNOWN = EQUIPEMENTS.map(e => e.key);

export async function scrapeAireDetail(id: string) {
  const html = await fetchText(`https://www.pique-nique.info/AireDetail.php?lieu=${id}`);
  if (!html) return null;                       // échec → skip (comme le PHP)
  const $ = cheerio.load(html);

  const afterStrong = (label: string) => {      // <strong>Latitude</strong> "…: 45.94"
    const el = $("strong").filter((_, e) => $(e).text().includes(label)).first();
    const txt = el.length ? ((el[0].nextSibling as any)?.data ?? "") : "";
    return txt.replace(":", "").trim();
  };

  const photos: string[] = [];
  $('img[src*="photos/"]').each((_, img) => {
    const src = ($(img).attr("src") || "").trim();
    if (src && !photos.includes(src)) photos.push(src);
  });

  const equipements: Record<string, boolean> = {};
  const h4 = $("h4").filter((_, e) => $(e).text().includes("Equipements")).first();
  h4.nextAll().find("img").addBack("img").each((_, img) => {
    const alt = ($(img).attr("alt") || "").trim();
    const src = $(img).attr("src") || "";
    if (!alt || !EQ_KNOWN.includes(alt)) return;
    equipements[alt] = !src.includes("off");    // actif = src ne contient PAS "off"
  });

  return {
    id,
    nom: $("h2").first().text().trim(),
    description: $("h4").filter((_, e) => $(e).text().includes("Description"))
                        .first().nextAll("p").first().text().trim(),
    latitude: afterStrong("Latitude"),          // string brute
    longitude: afterStrong("Longitude"),
    photos, equipements,
  };
}
```

### 7.4 Orchestration + upsert Mongo (avec normalisation)
```ts
export async function runGrab({ depTtlMs = 7*24*3600*1000, throttleMs = 200 } = {}) {
  const ids = new Set<string>();
  for (const dep of getDepartements())
    (await collectIds(dep, depTtlMs)).forEach(i => ids.add(i));

  for (const id of ids) {
    const exists = await db.collection("lieux").findOne({ _id: id }, { projection: { _id: 1 } });
    if (exists) continue;                        // incrémental : présence = skip
    const raw = await scrapeAireDetail(id);
    if (raw) await db.collection("lieux").updateOne(
      { _id: id }, { $set: normalizeLieu(raw) }, { upsert: true },
    );
    await sleep(throttleMs);                      // throttle 200 ms
  }
}
```

`normalizeLieu(raw)` applique le contrat §3.1 : parse lat/lon en number, force les
9 clés, calcule `equipementsActifs` (exclut `Logo Pinterest`, tri A→Z), applique
l'override coords éventuel, construit `location` GeoJSON, pose `scrapedAt`/`updatedAt`.

### 7.5 Robustesse
- `fetchText` : timeout (~15 s), 1–2 retries, **User-Agent explicite**, `null` à l'échec.
- Jamais d'upsert sur un scrap `null` (pas de doc partiel).
- Premier remplissage long : **6045 fiches × 200 ms ≈ 20 min**. Un **lock**
  (§8) évite les chevauchements de cron.

---

## 8. Planification (node-cron) + lock

```ts
import cron from "node-cron";

let grabbing = false;                            // lock in-process (un seul process)
export function startCron() {
  cron.schedule(process.env.CRON_SCHEDULE ?? "0 4 * * *", async () => {
    if (grabbing) { logger.warn("grab déjà en cours, skip"); return; }
    grabbing = true;
    try { await runGrab(); }
    catch (err) { logger.error({ err }, "grab failed"); }
    finally { grabbing = false; }
  });
}
```

> Si plusieurs instances du serveur tournent (scale horizontal), remplacer le
> booléen par un **lock Mongo** (collection `locks` avec `findOneAndUpdate`
> conditionnel + TTL) pour qu'un seul process grabbe à la fois.

---

## 9. Migration des données existantes (JSON disque → MongoDB)

Le prototype a déjà **6045 lieux**, des **overrides coords** et des **notes** sur
disque. Script one-shot pour importer sans re-scraper :

```ts
// scripts/migrate.ts
// 1. lieux : lire data/lieux/*.json → normalizeLieu → upsert dans `lieux`
// 2. coords : lire data/coords/*.json → poser coordsOverride + recalculer lat/lon/location
// 3. notes  : lire data/notes/{id}.json (tableau) → insertMany dans `notes`,
//             puis recalculer noteAvg/noteCount sur chaque `lieux`
// 4. departements : (optionnel) importer les caches HTML existants
```

> À conserver précieusement : `data/coords` et `data/notes` sont du contenu
> **produit par les utilisateurs / corrigé à la main**, non régénérable par le
> grab. La migration doit les préserver intégralement.

---

## 10. Arborescence du projet

```
.
├── public/                      # servi à la racine "/"
│   ├── index.html               # ex-index.php sans PHP
│   ├── app.js                   # ex-<script> de index.php, fetch('/api/...')
│   └── styles.css
├── src/
│   ├── server.ts                # bootstrap Express : static + /api + cron
│   ├── config.ts                # env (PORT, MONGO_URI, CRON_SCHEDULE, ...)
│   ├── db.ts                    # connexion MongoDB + création des index
│   ├── constants/
│   │   └── equipements.ts       # EQUIPEMENTS (contrat §3.4)
│   ├── routes/
│   │   ├── lieux.ts             # GET /api/lieux, GET /api/lieux/:id
│   │   ├── notes.ts             # GET/POST /api/lieux/:id/notes
│   │   ├── equipements.ts       # GET /api/equipements
│   │   └── proxy.ts             # (option) /api/geocode, /api/route
│   ├── services/
│   │   ├── store.ts             # requêtes lieux + mapping doc→format liste
│   │   ├── notes.ts             # lecture/écriture notes + dénormalisation
│   │   └── normalize.ts         # normalizeLieu() (contrat §3.1)
│   └── grabber/
│       ├── index.ts             # runGrab (2 phases)
│       ├── scrape.ts            # scrapeAireDetail (cheerio)
│       └── departements.ts      # getDepartements + collectIds
├── scripts/
│   └── migrate.ts               # import JSON disque → Mongo
├── data/                        # (legacy) source de la migration, plus utilisé au runtime
├── package.json
└── tsconfig.json                # si TypeScript
```

---

## 11. Bootstrap serveur

```ts
// src/server.ts
import express from "express";
import path from "node:path";
import helmet from "helmet";
import cors from "cors";
import rateLimit from "express-rate-limit";
import { connect } from "./db";
import { lieuxRouter, notesRouter, equipementsRouter } from "./routes";
import { startCron } from "./grabber/cron";

const app = express();
app.use(helmet({ contentSecurityPolicy: false }));  // CSP à régler pour les CDN Leaflet/Turf
app.use(cors());
app.use(express.json());

// API
app.use("/api/lieux", rateLimit({ windowMs: 60_000, max: 120 })); // protège POST notes
app.use("/api/lieux", lieuxRouter);
app.use("/api/lieux", notesRouter);                 // /:id/notes
app.use("/api/equipements", equipementsRouter);
// app.use("/api", proxyRouter);                    // option géocodage/routing

// Frontend statique à la racine
app.use("/", express.static(path.join(__dirname, "..", "public")));

connect(process.env.MONGO_URI!).then(() => {
  app.listen(Number(process.env.PORT ?? 3000), () => startCron());
});
```

---

## 12. Configuration (env)

| Variable | Défaut | Rôle |
|---|---|---|
| `PORT` | `3000` | Port HTTP |
| `MONGO_URI` | `mongodb://localhost:27017/picnic` | Connexion MongoDB |
| `CRON_SCHEDULE` | `0 4 * * *` | Cron du grabber (5 champs) |
| `DEP_TTL_DAYS` | `7` | Fraîcheur du cache des pages département |
| `GRAB_THROTTLE_MS` | `200` | Délai entre 2 fiches |
| `CORS_ORIGIN` | `*` | Origine autorisée |
| `ADMIN_TOKEN` | — | (option) protège `POST /api/admin/grab` |

---

## 13. Setup & commandes

```bash
npm init -y
npm i express cors helmet express-rate-limit zod cheerio node-cron pino pino-http mongodb
npm i -D typescript tsx @types/express @types/node @types/cors   # si TypeScript

# scripts package.json :
#   "dev":     "tsx watch src/server.ts"
#   "build":   "tsc -p ."
#   "start":   "node dist/server.js"
#   "grab":    "tsx src/grabber/index.ts"   # grab manuel one-shot
#   "migrate": "tsx scripts/migrate.ts"     # import des JSON disque existants

# MongoDB local (docker)
docker run -d -p 27017:27017 --name picnic-mongo mongo:7

npm run migrate      # importe les 6045 lieux + coords + notes existants
npm run dev          # API + static + cron
# → http://localhost:3000/  (frontend)   http://localhost:3000/api/lieux
```

---

## 14. Roadmap (lots de livraison)

1. **Lot 0 — Socle** : Express + MongoDB + `express.static('public')`, `GET /api/equipements`.
2. **Lot 1 — Données & migration** : schéma `lieux`, `normalizeLieu`, script de
   migration, `GET /api/lieux` (+ bbox/eq/note).
3. **Lot 2 — Frontend porté** : `public/index.html` + `app.js` consommant l'API
   (carte, clusters, popup, toolbar, recherche, filtres, permalinks). Parité
   visuelle et comportementale avec `index.php`.
4. **Lot 3 — Notes** : collection `notes`, `GET/POST /api/lieux/:id/notes`,
   dénormalisation `noteAvg`/`noteCount`, filtre note (modale ⭐).
5. **Lot 4 — Itinéraire** : reprise intégrale du JS (OSRM multi-itinéraires,
   liste, filtre distance à la ligne, bloc « étape », filtre temporel, Google Maps).
6. **Lot 5 — Grabber planifié** : port cheerio, cron + lock, cache département TTL,
   `POST /api/admin/grab`.
7. **Lot 6 (option)** — Proxys `/api/geocode` & `/api/route` avec cache.

---

## 15. Écarts assumés vs le prototype PHP

- **MongoDB remplace le JSON-sur-disque** : `lieux`, `notes`, `departements`
  deviennent des collections ; `data/` ne sert qu'à la migration initiale.
- **Overrides coords** : ne sont plus un dossier `data/coords`, mais un champ
  `coordsOverride` appliqué à l'écriture ; l'API renvoie des coords déjà corrigées.
- **Notes dénormalisées** : `noteAvg`/`noteCount` sont maintenus sur le doc `lieux`
  pour éviter une agrégation à chaque `GET /api/lieux` (6045 lieux).
- **Recherche unifiée à un seul mode** : la barre « Lieu » et le panneau
  « Itinéraire », affichés simultanément dans le prototype, sont fusionnés en un
  panneau à deux modes exclusifs (§6.1). L'état `itineraire-collapsed` est remplacé
  par un mode persisté (`localStorage 'search-mode'` + permalink `?panel=`).
- **Rendu serveur supprimé** : plus de `json_encode` inline ; le front récupère les
  données via `fetch('/api/lieux')` (init devient asynchrone).
- **TTL sur le cache département** (7 j) pour découvrir les nouvelles aires (le PHP
  cachait indéfiniment).
- **Requêtes bbox géo** possibles via index `2dsphere` (nouveau, optionnel).
- **Inchangé** : contrat des 9 équipements, exclusion `Logo Pinterest`, tri A→Z,
  moyenne arrondie 2 décimales (`null` si 0 avis), regex d'extraction des ids,
  throttle 200 ms, logique des itinéraires/filtre temporel, permalinks et
  `localStorage`, liens externes (pique-nique.info, Street View, Google Maps).
```
