# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Overview

**v2** of a clickable map of French picnic areas (aires de pique-nique). A single
**Node.js / Express** process serves a REST API under `/api/*`, serves the static
frontend from `./public` at `/`, and hosts an in-process **cron grabber** that
scrapes `pique-nique.info` into **MongoDB** (the source of truth). Full spec:
`v2.md`. The original PHP prototype (`index.php`/`lieux.php`/`note.php` + JSON on
disk) has been replaced; `API.md`/`APP.md` describe superseded variants.

Folder conventions are inspired by `d:\web\sogest-api` (plain JS ESM, root
`db.js`/`server.js`, business logic in `inc/<domain>/`, routers in `routes/`,
scripts in `tools/`).

## Commands

```bash
npm run dev        # nodemon: API + static + cron
npm start          # node server.js
npm run grab       # one-shot full grab of pique-nique.info into MongoDB (~20 min)
npm run import     # one-shot import of OSM "pseudo-aires" (playgrounds + rest/service areas)
npm run overrides  # apply manual GPS coordinate overrides (data/coords-overrides.json)
npm run migrate    # import a legacy data/ folder (lieux, coords, notes)
```

**Node ≥ 20 required** (`package.json` `engines`). A `File is not defined` crash
on startup means an old Node (undici needs the `File` global from Node 20+); use
nvm's Node (`source ~/.nvm/nvm.sh`). Systemd/cron units must point at that binary.

No build step, no test suite. JS deps for the frontend load from unpkg CDNs
(Leaflet 1.9.4, leaflet.markercluster 1.5.3, Turf 6.5.0). Backend deps: express,
mongodb, cheerio, node-cron, cors, helmet, express-rate-limit, dotenv.

## Architecture notes

- **MongoDB is the database** (`db.js` connects, pings, `process.exit(1)` on
  failure, creates indexes). Collections: `lieux` (denormalized), `notes` (one
  doc per rating, source of truth), `departements` (grabber Phase-1 cache, 7-day
  TTL). No JSON-on-disk at runtime; `data/` only feeds `tools/migrate.js`.
- **Equipment contract is frozen** — `inc/core/equipements.js` (`EQUIPEMENTS`, 9
  keys + emoji, order matters) is shared by the grabber, the normalizer, and the
  frontend (via `GET /api/equipements`). `Logo Pinterest` is excluded from
  `equipementsActifs`; the 9 keys are forced onto every lieu.
- **Normalization** (`inc/lieux/normalize.js`) runs at write time
  (grab/import/migrate/overrides): coords = override else scraped → number,
  `location` GeoJSON `[lon,lat]`,
  `equipementsActifs` sorted A→Z, `valid:false` when coords unusable (excluded
  from list responses). `noteAvg`/`noteCount` are denormalized onto `lieux` from
  the `notes` collection on every vote (`inc/lieux/notes.js`).
- **Route auto-mounting**: each `routes/*.js` exports `default` (an Express
  Router) + `routePath`; `server.js` mounts them at `/api${routePath}`. `lieux`
  and `notes` both mount on `/lieux` (paths `/:id` vs `/:id/notes` don't overlap).
  Handlers use the `handleResponse` wrapper from `inc/core/response.js`.
- **Frontend is decoupled** (`public/`): `app.js` boots by fetching
  `/api/lieux?source=pique-nique.info` + `/api/equipements`, then all logic is
  client-side (Leaflet + markercluster, Turf `pointToLineDistance` for the 10 km
  route filter, autocomplete via Photon/Nominatim, OSRM routing). Filter/route
  state lives in the URL via `history.replaceState`.
- **Hybrid map loading** (scales to 50k+ OSM points): pique-nique.info aires
  (~6k) are loaded **eagerly** at boot (so name-autocomplete and the route
  corridor stay complete for them); OSM pseudo-aires are loaded **lazily by bbox**
  — `loadOsmForView()` on `moveend` (debounced) above `OSM_MIN_ZOOM` (11), plus
  `loadOsmForRoute()` along the itinerary. `addLieux()` dedups by id (`loadedIds`),
  creates markers, and adds only new ones to the cluster (no full rebuild on pan);
  `updateMarkers()` is the full rebuild, reserved for filter changes. `lastOsmBbox`
  skips refetching a contained view. Below zoom 11 only pique-nique pins show.
- **Pin colors & popup by source**: `isOsmLieu(l)` (source `openstreetmap` *or*
  id starts `osm:`) drives both. pique-nique.info → **green** pin
  (`PICNIC_ICON`, a coloured divIcon), OSM → default **blue** Leaflet pin. OSM
  popups link to `openstreetmap.org/<type>/<id>`, show a provenance badge
  (`PSEUDO_META[kind].label`), and omit the rating block (pseudo-aires have no
  notes); pique-nique.info popups link to `AireDetail.php` and keep ratings. The
  list API exposes `source` + `kind` on every item (`toListItem`).
- **Unified two-mode search panel** (v2 UX change, `v2.md` §6.1): a segmented
  control toggles between *Lieu* and *Itinéraire*; only one form is visible at a
  time. Mode persists in `localStorage 'search-mode'` and the `?panel=` permalink;
  `from`/`to` in the URL force Itinéraire mode.
- **Permalink gotcha**: `syncURL()` rewrites the URL early during boot, so
  restoration reads a captured `initialParams` snapshot (not live
  `location.search`) — otherwise `?lieu` is stripped before it's restored.
- **markercluster timing**: `chunkedLoading` adds markers asynchronously;
  `restoreOpenLieu()` waits for the marker's `_icon`/`__parent` before calling
  `zoomToShowLayer`, else the popup never opens.
- **Scraping is fragile by design** (`inc/grabber/scrape.js`, cheerio port of the
  PHP XPath parser): keys off text labels ("Latitude", "Description",
  "Equipements"). A failed fetch returns `null` → the lieu is skipped, never
  partially written. Throttled 200 ms between detail fetches.
- **Grab flow** (`inc/grabber/index.js`, `runGrab`, in-process lock `isGrabbing`):
  *Phase 1* collects ids per department (`collectIds`, 7-day Mongo cache in
  `departements`, returns `{ids, source}` where source ∈ cache/fetched/fallback).
  *Phase 2* is **incremental** — a lieu already present in `lieux` is skipped
  (existence = skip), unknown ids are scraped + upserted. Returns
  `{discovered, inserted, skipped, failed}`. `runGrab({verbose})` logs one line per
  department and per lieu; `tools/grab.js` (`npm run grab`) passes `verbose:true`,
  while cron/boot/admin stay quiet. Because Phase 2 skips existing lieux, editing a
  lieu already in the DB requires the overrides script, not a re-grab.
- **OSM pseudo-aires import** (`inc/grabber/overpass.js` + `tools/import-osm.js`,
  `npm run import`): loads three OSM themes via the **Overpass API** — playgrounds
  (`leisure=playground` → `kind:'aire-de-jeux'`), rest areas (`highway=rest_area`
  → `aire-de-repos`), service areas (`highway=services` → `aire-de-service`). These
  are **not** from pique-nique.info: `source:'openstreetmap'`, `_id` prefixed
  `osm:<n|w|r><id>` (no collision with numeric ids), `noteAvg/noteCount` never set.
  OSM tags are mapped onto the frozen 9-key equipment contract via
  `equipementsFromTags` (per-theme base set + tag enrichment) so pseudo-aires obey
  the **thematic filter** natively (a playground only shows under the "Aire de
  jeux" filter, etc.). The territory is swept as a **bbox grid** (`franceCells`,
  metropole + Corsica + DROM; raw bbox, so border countries spill in — accepted);
  each cell POSTs all requested themes in one query with a fallback endpoint +
  retries; `out center tags` yields a point even for ways/relations. Idempotent
  upsert by `_id` (re-run to refresh), throttled 1.5 s/cell. Reuses `normalizeLieu`
  then overrides `source`/`kind`. CLI subset: `npm run import -- jeux repos service`.
- **Manual coord overrides** (`data/coords-overrides.json` +
  `inc/lieux/overrides.js` + `tools/apply-overrides.js`, `npm run overrides`): a
  single hand-edited JSON `{ "<id>": {latitude, longitude} }` (id = numeric or
  `osm:…`; `_`-prefixed keys and null entries ignored). Feeds `normalizeLieu`'s
  `override` param → `coordsOverride` set, `location`/`valid` recomputed. `npm run
  overrides` applies to lieux **already in the DB** (rebuilds a raw from the doc,
  preserves nom/equipements/photos/source/kind); the grab also applies overrides
  to freshly scraped fiches. Use this to fix bad pique-nique.info coordinates.
- **Third-party services** (Photon, Nominatim, OSRM, OSM tiles) are called
  **directly from the browser**, as in the prototype. Optional `/api` proxies are
  described in `v2.md` §5.6 but not implemented.
```
