# 001 — HistoMétéo MVP — Spec technique v24

## Delta v23 → v24

**Trois axes** :

1. **Visibilité cache dans l'admin** — nouvel onglet "Cache" affichant les entrées en mémoire (date de mise en cache, nombre de réutilisations)
2. **Badge "cache" dans la liste des recherches** — indicateur visuel quand un résultat a été servi entièrement depuis le cache
3. **Attribution complète des appels API SPA** — résout R3 (commune non transmise), R4 (search_id non propagé aux normals/communes) et R5 (vue détail incomplète) du feedback v23

---

## 0) Contract

- **Source of truth** : cette spec technique
- **Functional integrity** : aucun critère d'acceptation existant (AC1–AC51) ne peut être modifié
- **Spec fonctionnelle** : `001-histometeo-mvp.md`
- **Feedback intégré** : `feedback-to-architect-001-v23.md` (R3, R4, R5)

### Scope — fichiers autorisés

| Fichier                          | Nature de la modification                                                                                                                                                       |
| -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `src/cache.py`                   | Ajout `created_at`, `hit_count`, méthode `snapshot()` dans `TTLCache`                                                                                                           |
| `src/main.py`                    | Nouvel endpoint `/admin/api/cache`, param optionnel `search_id` sur `/api/normals` et `/api/normals/annual`, params `commune`/`slug` sur `/api/weather` (utilisation existante) |
| `src/normals_service.py`         | (aucun changement — ContextVar suffit)                                                                                                                                          |
| `public/app.js`                  | R3 : envoi `commune`/`slug` à `/api/weather` ; R4 : génération `search_id` client + propagation à `/api/normals`, `/api/normals/annual`                                         |
| `admin/admin.js`                 | Badge "cache", onglet Cache, chargement données cache                                                                                                                           |
| `admin/index.html`               | CSS badge `.status-cache`, structure HTML onglet Cache                                                                                                                          |
| `tests/test_cache.py`            | Tests `snapshot()`, `hit_count`, `created_at`                                                                                                                                   |
| `tests/test_tracking_service.py` | (aucun nouveau test)                                                                                                                                                            |
| `tests/test_admin_api.py`        | Tests endpoint `/admin/api/cache`, badge cache via search list, search_id propagation                                                                                           |
| `tests/test_api.py`              | Tests search_id accepté par `/api/normals`, commune/slug dans search_logs                                                                                                       |

### Forbidden changes

- `src/weather_service.py` — aucune modification
- `src/commune_service.py` — aucune modification
- `src/normals_service.py` — aucune modification
- `src/tracking_service.py` — aucune modification
- `src/config.py` — aucune modification
- `src/og_service.py` — aucune modification
- `src/prefetch_service.py` — aucune modification
- `public/index.html` — aucune modification
- `public/style.css` — aucune modification
- Tests existants hérités (≤ v23, 152 tests) — tous doivent passer sans modification sémantique

### Invariants

| #              | Invariant                                                                                                                                                                 |
| -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| INV-1 à INV-47 | Hérités, inchangés                                                                                                                                                        |
| INV-48         | `TTLCache.get()` incrémente `hit_count` uniquement quand la valeur est retournée (non expirée, non None)                                                                  |
| INV-49         | `TTLCache.snapshot()` ne retourne jamais les valeurs stockées — uniquement les métadonnées (clé, created_at, expires_at, hit_count)                                       |
| INV-50         | `TTLCache.set()` remet `hit_count` à 0 pour une clé ré-insérée                                                                                                            |
| INV-51         | Le paramètre `search_id` sur `/api/normals` et `/api/normals/annual` est optionnel. S'il est absent, le comportement est identique à v23 (search_id = NULL dans les logs) |
| INV-52         | Le paramètre `search_id` est validé côté serveur (format UUID v4 strict). Toute valeur non conforme est ignorée silencieusement (fallback sur `None`)                     |
| INV-53         | Le badge "cache" n'apparaît que si `status === 'success'` ET `total_api_calls === 0`                                                                                      |
| INV-54         | L'endpoint `/admin/api/cache` est protégé par la même auth HTTP Basic que les autres endpoints admin                                                                      |
| INV-55         | `snapshot()` ne prend pas de lock bloquant — il capture un instantané sans garantie transactionnelle (acceptable pour un dashboard)                                       |

### Done when

| #       | Critère                                                                                                                                  |
| ------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
| D33–D73 | Acquis v23, 152 tests hérités passent                                                                                                    |
| D74     | `TTLCache.snapshot()` retourne la liste des entrées avec `key`, `created_at`, `expires_at`, `hit_count`                                  |
| D75     | `TTLCache.get()` incrémente `hit_count` à chaque cache hit                                                                               |
| D76     | `GET /admin/api/cache` retourne le snapshot des 5 caches (weather, normals, communes_search, communes_slug, og_image)                    |
| D77     | L'onglet "Cache" dans l'admin affiche un tableau par service avec clé, date de mise en cache, expiration, nombre de réutilisations       |
| D78     | Le badge "cache" apparaît dans la colonne Statut de la liste des recherches quand la recherche a été servie depuis le cache              |
| D79     | `public/app.js` transmet `commune` et `slug` en query params à `/api/weather`                                                            |
| D80     | `public/app.js` génère un UUID `search_id` avant chaque recherche et le transmet à `/api/weather`, `/api/normals`, `/api/normals/annual` |
| D81     | `/api/normals` et `/api/normals/annual` acceptent un paramètre optionnel `search_id` et le propagent via `set_current_search_id()`       |
| D82     | Avec search_id propagé, `total_api_calls` d'une recherche SPA inclut weather + normals                                                   |
| D83     | La vue détail d'une recherche SPA affiche les appels weather ET normals                                                                  |
| D84     | Tous les tests passent (152 hérités + nouveaux)                                                                                          |

---

## 1) Objectif technique

Rendre le cache in-memory observable depuis l'interface admin, afficher un indicateur visuel de cache hit dans la liste des recherches, et corriger l'attribution incomplète des appels API SPA pour que le dashboard reflète le coût réel de chaque recherche.

---

## 2) Analyse du brief

### Besoins

1. **Observabilité cache** : l'administrateur n'a actuellement aucune visibilité sur l'état des caches in-memory (TTLCache). Il ne sait pas quelles données sont en cache, depuis quand, ni combien de fois elles ont été réutilisées.
2. **Indicateur visuel** : dans la liste des recherches récentes, rien ne distingue visuellement une recherche servie depuis le cache (0 appels API, rapide) d'une recherche en cours ou échouée.
3. **Attribution API SPA** (feedback R3/R4/R5) : le SPA ne transmet pas la commune/slug à `/api/weather` et ne propage pas de `search_id` aux appels `/api/normals`. Le dashboard sous-estime le coût réel des recherches SPA d'un facteur ~4x et la vue détail est incomplète.

### Contraintes

- Les instances `TTLCache` sont en mémoire volatile — le snapshot cache est une vue instantanée, pas un historique persistant
- `public/app.js` peut être modifié (levée de l'interdiction v23 spécifiquement pour R3/R4)
- La propagation de `search_id` depuis le client nécessite une validation UUID côté serveur pour éviter toute injection
- Le badge cache est une heuristique basée sur `total_api_calls == 0 && status == success` — elle est exacte après la correction R4

### Risques identifiés

1. **Race condition snapshot** : le snapshot cache est lu sans lock global → données potentiellement incohérentes entre deux entrées. Acceptable pour un dashboard informatif.
2. **UUID client-generated** : un client malveillant pourrait envoyer un `search_id` existant. Impact nul (le tracking tolère les doublons, pas de modificaiton de données existantes).
3. **Compatibilité navigateur** : `crypto.randomUUID()` est supporté dans tous les navigateurs modernes (Chrome 92+, Firefox 95+, Safari 15.4+).

---

## 3) Design minimal proposé

### 3.1 — TTLCache : métadonnées et snapshot

**Modification de la structure interne** :

```
_store: OrderedDict[str, tuple[float, float, int, T]]
#                          created_at, expires_at, hit_count, value
```

Actuellement : `tuple[float, T]` → `(expires_at, value)`.

Nouveau : `tuple[float, float, int, T]` → `(created_at, expires_at, hit_count, value)`.

**Méthode `get()` modifiée** : si la valeur est retournée (non expirée), incrémenter `hit_count` en place.

**Méthode `set()` modifiée** : stocker `created_at = time.time()`, `hit_count = 0`.

**Nouvelle méthode `snapshot()`** :

```python
def snapshot(self) -> list[dict[str, Any]]:
    """Retourne les métadonnées de toutes les entrées non expirées."""
    now = time.time()
    result = []
    with self._lock:
        for key, (created_at, expires_at, hit_count, _value) in self._store.items():
            if expires_at > now:
                result.append({
                    "key": key,
                    "created_at": created_at,
                    "expires_at": expires_at,
                    "hit_count": hit_count,
                })
    return result
```

### 3.2 — Endpoint admin `/admin/api/cache`

**Route** : `GET /admin/api/cache` (protégé par `admin_router`)

**Réponse** :

```json
{
  "weather": {
    "entries": [
      {
        "key": "weather:44.58:6.45:2026-01-01:2026-01-31",
        "created_at": 1710423632.5,
        "expires_at": 1711028432.5,
        "hit_count": 3
      }
    ],
    "total": 1,
    "max_entries": 500,
    "ttl_seconds": 604800
  },
  "normals": { "entries": [...], "total": N, "max_entries": 200, "ttl_seconds": 7776000 },
  "communes_search": { "entries": [...], "total": N, "max_entries": 500, "ttl_seconds": 86400 },
  "communes_slug": { "entries": [...], "total": N, "max_entries": 500, "ttl_seconds": 86400 },
  "og_image": { "entries": [...], "total": N, "max_entries": 200, "ttl_seconds": 604800 }
}
```

**Accès aux caches** : les services sont des variables globales dans `main.py` (`weather_service`, `normals_service`, `commune_service`, `og_service`). Chaque service expose son cache via un attribut public (`weather_service.cache`, `normals_service.cache`, `commune_service.cache`, `commune_service.slug_cache`, `og_service._cache`).

### 3.3 — Badge "cache" dans la liste des recherches

**Logique frontend** (`admin.js`, dans la boucle `loadSearches`) :

Après le `statusBadge(item.status)`, ajouter conditionnellement :

```javascript
const cacheBadge =
  item.status === "success" && (item.total_api_calls ?? 0) === 0
    ? ' <span class="badge status-cache">cache</span>'
    : "";
```

Le HTML de la cellule Statut devient : `${statusBadge(item.status)}${cacheBadge}`.

**CSS** dans `admin/index.html` :

```css
.status-cache {
  color: #0e6ba8;
  border-color: #b3d7f0;
  background: #e8f4fd;
}
```

### 3.4 — Onglet "Cache" dans l'admin

**HTML** : nouveau `<section id="cache-view" class="view">` avec :

- Un résumé par service (nombre d'entrées / max, TTL)
- Un tableau unifié de toutes les entrées : Service | Clé | Mise en cache | Expiration | TTL restant | Réutilisations

**Navigation** : nouveau bouton tab `data-view="cache-view"`.

**JS** : nouvelle fonction `loadCache()` appelée quand l'onglet est activé. Formate les timestamps Unix en dates lisibles, calcule le TTL restant.

### 3.5 — R3 : commune/slug transmis par le SPA

**`public/app.js` — `fetchWeatherForCommune()`** :

Ajouter `commune` (nom) et `slug` (slug-dept) aux query params envoyés à `/api/weather` :

```javascript
async function fetchWeatherForCommune(commune) {
  const slug = commune.slug || generateSlug(commune);
  const params = new URLSearchParams({
    lat: commune.latitude,
    lon: commune.longitude,
    start: dateStart.value,
    end: dateEnd.value,
    commune: commune.nom,
    slug: slug,
  });
  // ...
}
```

Le slug est disponible dans l'objet commune retourné par l'API `/api/resolve` ou `/api/communes`. Si absent, le construire côté client (pas de fonction `generateSlug` existante côté client — la construire ou utiliser le slug déjà résolu).

**Vérification** : l'endpoint `/api/weather` accepte déjà les paramètres optionnels `commune` et `slug` (cf. [src/main.py](src/main.py#L203)) et les transmet à `create_search()`. Aucune modification backend nécessaire pour R3.

### 3.6 — R4 : propagation du search_id aux appels normals

**Flux côté client** :

1. Avant de lancer les requêtes parallèles, le SPA génère un UUID : `const searchId = crypto.randomUUID()`
2. Ce `searchId` est ajouté en query param aux 3 appels : `/api/weather?...&search_id=X`, `/api/normals?...&search_id=X`, `/api/normals/annual?...&search_id=X`

**Flux côté serveur — `/api/weather`** :

Le paramètre `search_id` est déjà reçu (c'est un nouveau query param). Modification :

```python
@app.get("/api/weather")
async def get_weather(
    lat: float | None = None,
    lon: float | None = None,
    start: str | None = None,
    end: str | None = None,
    commune: str | None = None,
    slug: str | None = None,
    search_id: str | None = Query(default=None, alias="search_id"),
) -> JSONResponse:
    validated_id = _validate_search_id(search_id)
    sid = validated_id or str(uuid4())
    set_current_search_id(sid)
    # ... reste identique avec sid au lieu de search_id
```

**Flux côté serveur — `/api/normals` et `/api/normals/annual`** :

Ajouter le param optionnel `search_id` et le propager via ContextVar :

```python
@app.get("/api/normals")
async def get_normals(
    lat: float | None = None,
    lon: float | None = None,
    start: str | None = None,
    end: str | None = None,
    search_id: str | None = Query(default=None, alias="search_id"),
) -> JSONResponse:
    validated_id = _validate_search_id(search_id)
    if validated_id:
        set_current_search_id(validated_id)
    try:
        # ... logique existante inchangée
    finally:
        set_current_search_id(None)
```

**Fonction de validation UUID** (dans `main.py`) :

```python
import re
_UUID_PATTERN = re.compile(
    r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$",
    re.IGNORECASE,
)

def _validate_search_id(value: str | None) -> str | None:
    if value and _UUID_PATTERN.match(value):
        return value
    return None
```

**Conséquence sur `_count_api_calls`** : une fois le `search_id` propagé, les appels normals logués avec ce `search_id` seront comptabilisés dans `total_api_calls`. La requête SQL existante (`WHERE search_id = ? AND cache_status = 'miss'`) capte automatiquement ces entrées. Aucune modification de `tracking_service.py` nécessaire.

**Conséquence sur `get_search_detail`** : la vue détail (`WHERE search_id = ?`) retournera désormais les appels weather ET normals. Résout R5 sans modification.

### 3.7 — Gestion des erreurs / Edge cases

| Cas                                              | Comportement                                                                                               |
| ------------------------------------------------ | ---------------------------------------------------------------------------------------------------------- |
| `search_id` invalide (non UUID)                  | Ignoré silencieusement, fallback sur UUID généré (weather) ou None (normals)                               |
| `search_id` valide mais inconnu                  | Les entrées normals seront logguées avec ce search_id. `_count_api_calls` les comptera. Pas de corruption. |
| Navigateur ne supporte pas `crypto.randomUUID()` | Fallback : ne pas envoyer `search_id`. Comportement identique à v23.                                       |
| Cache vide au démarrage                          | `snapshot()` retourne `[]`. L'onglet admin affiche "Aucune entrée en cache".                               |
| Entrée expirée entre le snapshot et l'affichage  | TTL restant affiché comme ≤ 0. Acceptable — informationnel.                                                |

---

## 4) Plan d'implémentation

### Étape 1 — TTLCache métadonnées + snapshot

**Fichier** : `src/cache.py`

**Modifications** :

1. Changer le tuple stocké de `(expires_at, value)` à `(created_at, expires_at, hit_count, value)`
2. `set()` : stocker `created_at = time.time()`, `hit_count = 0`
3. `get()` : sur cache hit, incrémenter `hit_count` in-place (mutable via list ou accès direct)
4. `_evict_if_needed()` : adapter l'unpacking
5. Ajouter `snapshot()` retournant les métadonnées de toutes les entrées non expirées

**Attention** : le `hit_count` doit être mutable dans le tuple. Deux options :

- (a) Stocker une liste `[created_at, expires_at, hit_count, value]` au lieu d'un tuple
- (b) Stocker un objet dataclass `_CacheEntry(created_at, expires_at, hit_count, value)` avec `hit_count` mutable

L'option (a) est la plus simple et cohérente avec le style existant.

**Testable** : `tests/test_cache.py` — vérifier que `get()` incrémente `hit_count`, que `snapshot()` retourne les bonnes métadonnées, que `set()` remet `hit_count` à 0.

---

### Étape 2 — Badge "cache" dans la liste des recherches

**Fichiers** : `admin/index.html`, `admin/admin.js`

**Modifications** :

1. CSS : ajouter `.status-cache` (bleu clair, cohérent avec le design system existant)
2. JS (`loadSearches`) : après `statusBadge(item.status)`, ajouter le badge cache conditionnel

**Condition** : `item.status === 'success' && (item.total_api_calls ?? 0) === 0`

**Testable** : visuellement + test d'intégration admin API qui vérifie que les données retournées permettent de déduire le badge.

---

### Étape 3 — Onglet Cache dans l'admin

**Fichiers** : `admin/index.html`, `admin/admin.js`

**Modifications HTML** :

1. Nouveau bouton tab : `<button class="tab" data-view="cache-view">Cache</button>`
2. Nouvelle section `<section id="cache-view" class="view">` avec :
   - Résumé par service (cards) : entrées / max, TTL
   - Tableau unifié : Service | Clé | Mise en cache | Expiration | TTL restant | Réutilisations
   - Bouton "Rafraîchir" pour recharger le snapshot

**Modifications JS** :

1. Fonction `loadCache()` : appelle `GET /admin/api/cache`, peuple la section
2. Formatage des timestamps Unix en date locale (`new Date(ts * 1000).toLocaleString()`)
3. Calcul TTL restant : `Math.max(0, Math.round(entry.expires_at - Date.now() / 1000))` → affiché en heures/minutes
4. Wiring dans `wireEvents()` : activer `loadCache()` quand l'onglet "Cache" est sélectionné

**Testable** : test d'intégration admin API — vérifier que `/admin/api/cache` retourne le format attendu.

---

### Étape 4 — Endpoint `/admin/api/cache`

**Fichier** : `src/main.py`

**Nouvelle route** :

```python
@admin_router.get("/api/cache")
async def admin_cache() -> JSONResponse:
    def _section(cache: TTLCache, max_entries: int, ttl_seconds: int) -> dict:
        entries = cache.snapshot()
        return {
            "entries": entries,
            "total": len(entries),
            "max_entries": max_entries,
            "ttl_seconds": ttl_seconds,
        }

    data = {
        "weather": _section(weather_service.cache, CACHE_MAX_ENTRIES, WEATHER_CACHE_TTL_SECONDS),
        "normals": _section(normals_service.cache, NORMALS_CACHE_MAX_ENTRIES, NORMALS_CACHE_TTL_SECONDS),
        "communes_search": _section(commune_service.cache, CACHE_MAX_ENTRIES, COMMUNE_CACHE_TTL_SECONDS),
        "communes_slug": _section(commune_service.slug_cache, CACHE_MAX_ENTRIES, COMMUNE_CACHE_TTL_SECONDS),
        "og_image": _section(og_service._cache, OG_IMAGE_CACHE_MAX_ENTRIES, OG_IMAGE_CACHE_TTL_SECONDS),
    }
    return JSONResponse(status_code=200, content=data)
```

**Imports additionnels** : `CACHE_MAX_ENTRIES`, `WEATHER_CACHE_TTL_SECONDS`, `COMMUNE_CACHE_TTL_SECONDS`, `NORMALS_CACHE_MAX_ENTRIES`, `NORMALS_CACHE_TTL_SECONDS`, `OG_IMAGE_CACHE_MAX_ENTRIES`, `OG_IMAGE_CACHE_TTL_SECONDS` depuis `src/config.py`.

**Testable** : `tests/test_admin_api.py` — appeler l'endpoint, vérifier la structure.

---

### Étape 5 — R3 : commune/slug transmis par le SPA

**Fichier** : `public/app.js`

**Modification dans `fetchWeatherForCommune()`** :

Ajouter les params `commune` et `slug` :

```javascript
async function fetchWeatherForCommune(commune) {
  const params = new URLSearchParams({
    lat: commune.latitude,
    lon: commune.longitude,
    start: dateStart.value,
    end: dateEnd.value,
  });
  if (commune.nom) params.set("commune", commune.nom);
  if (commune.slug) params.set("slug", commune.slug);
  // ...
}
```

L'objet `commune` retourné par `/api/resolve/{slug}` et `/api/communes` contient le champ `nom`. Le champ `slug` est présent dans la réponse de `/api/resolve` (cf. `commune_service.resolve_slug()`) mais pas dans `/api/communes`. Pour le cas autocomplete, construire le slug depuis le nom + département :

```javascript
if (commune.slug) {
  params.set("slug", commune.slug);
} else if (commune.nom && commune.departement) {
  params.set("slug", generateSlug(commune.nom) + "-" + commune.departement);
}
```

La fonction `generateSlug()` doit reproduire la logique Python de `CommuneService.generate_slug()` : normalisation NFD, suppression accents, lowercase, remplacement espaces par tirets, suppression caractères non alphanumériques.

**Testable** : test d'intégration — vérifier que `commune_1` et `commune_1_slug` sont peuplés dans `search_logs` pour une requête SPA.

---

### Étape 6 — R4 : propagation search_id

**Fichiers** : `public/app.js`, `src/main.py`

**Modification `public/app.js`** :

Dans la fonction qui orchestre les appels (ex: le handler de recherche dans la section period/month/town), générer un UUID avant les appels parallèles :

```javascript
const searchId =
  typeof crypto !== "undefined" && crypto.randomUUID
    ? crypto.randomUUID()
    : undefined;
```

Passer `searchId` aux fonctions :

- `fetchWeatherForCommune(commune, searchId)` → ajoute `search_id` aux params
- `fetchNormals(lat, lon, start, end, searchId)` → ajoute `search_id` aux params
- `fetchAnnualNormals(lat, lon, searchId)` → ajoute `search_id` aux params

Les fonctions modifiées ajoutent conditionnellement le param :

```javascript
if (searchId) params.set("search_id", searchId);
```

**Modification `src/main.py`** :

1. Ajouter la fonction `_validate_search_id()` (regex UUID)
2. `/api/weather` : accepter `search_id` optionnel, l'utiliser au lieu de `uuid4()` s'il est valide
3. `/api/normals` : accepter `search_id` optionnel, appeler `set_current_search_id()` + `finally: set_current_search_id(None)`
4. `/api/normals/annual` : idem

**Testable** : test d'intégration — vérifier que les appels normals sont loggés avec le même `search_id` que le weather, et que `total_api_calls` reflète le total.

---

### Étape 7 — Tests

**Nouveaux tests** :

| #   | Test                                      | Fichier             | Vérifie                                                                             |
| --- | ----------------------------------------- | ------------------- | ----------------------------------------------------------------------------------- |
| T60 | `test_ttlcache_hit_count`                 | `test_cache.py`     | `get()` incrémente `hit_count`, `set()` le remet à 0                                |
| T61 | `test_ttlcache_snapshot`                  | `test_cache.py`     | `snapshot()` retourne clé, created_at, expires_at, hit_count — sans la valeur       |
| T62 | `test_ttlcache_snapshot_excludes_expired` | `test_cache.py`     | Les entrées expirées ne sont pas dans le snapshot                                   |
| T63 | `test_admin_cache_endpoint`               | `test_admin_api.py` | `GET /admin/api/cache` retourne les 5 sections avec la bonne structure              |
| T64 | `test_admin_cache_endpoint_auth_required` | `test_admin_api.py` | `GET /admin/api/cache` sans auth → 401                                              |
| T65 | `test_admin_cache_reflects_entries`       | `test_admin_api.py` | Après un appel weather, le cache contient l'entrée                                  |
| T66 | `test_search_list_cache_badge_data`       | `test_admin_api.py` | Recherche avec 0 api_calls + success → données compatibles badge cache              |
| T67 | `test_weather_receives_commune_slug`      | `test_api.py`       | `/api/weather?...&commune=X&slug=Y` → search_logs.commune_1 = X, commune_1_slug = Y |
| T68 | `test_normals_accepts_search_id`          | `test_api.py`       | `/api/normals?...&search_id=UUID` → api_call_logs.search_id = UUID                  |
| T69 | `test_normals_annual_accepts_search_id`   | `test_api.py`       | `/api/normals/annual?...&search_id=UUID` → api_call_logs.search_id = UUID           |
| T70 | `test_normals_rejects_invalid_search_id`  | `test_api.py`       | `/api/normals?...&search_id=invalid` → search_id = NULL dans logs                   |
| T71 | `test_search_total_includes_normals`      | `test_api.py`       | Après weather + normals avec même search_id, total_api_calls inclut les deux        |

---

## 5) Guide pour le Développeur

### Pièges fréquents

1. **Tuple vs List dans `_store`** : le tuple Python est immutable — impossible d'incrémenter `hit_count` sur un tuple. Utiliser une liste `[created_at, expires_at, hit_count, value]` ou remplacer le tuple entier à chaque hit. La liste est plus efficace (mutation O(1) vs remplacement O(1) + GC).

2. **Accès à `og_service._cache`** : l'attribut est préfixé `_` (convention privée). C'est acceptable pour l'endpoint admin — ne pas renommer l'attribut pour ne pas casser la convention de l'OGImageService.

3. **`set_current_search_id(None)` dans finally** : pour `/api/normals` et `/api/normals/annual`, le `finally` doit reset le ContextVar à `None` pour ne pas polluer les requêtes suivantes. C'est le pattern déjà utilisé dans `/api/weather`.

4. **Validation UUID côté serveur** : utiliser un regex strict (format canonique UUID v4) et non `uuid.UUID()` qui accepte des formats non canoniques et peut lever des exceptions.

5. **`crypto.randomUUID()` fallback** : si le navigateur ne supporte pas `crypto.randomUUID()`, ne pas envoyer `search_id` du tout (fallback gracieux). Ne pas implémenter un UUID polyfill — les navigateurs ciblés le supportent.

6. **Imports config** : l'endpoint `/admin/api/cache` a besoin de constantes TTL/max déjà importées dans les services. Les importer directement depuis `config.py` dans `main.py` plutôt que d'accéder aux attributs des caches (`cache.ttl_seconds`) pour garder la cohérence avec la source de vérité config.

### Zones de dérive

- **Ne pas** ajouter de persistance au hit_count (pas de sauvegarde en DB ou fichier). Le hit_count est volatile par nature.
- **Ne pas** ajouter de mécanisme de refresh automatique du cache depuis l'admin (pas de bouton "vider le cache" ou "invalider une entrée"). Hors scope.
- **Ne pas** modifier la logique de `_count_api_calls` ou `complete_search` dans `tracking_service.py`. Le fait que normals soit maintenant attribué avec un search_id suffit — le SQL existant fonctionne.
- **Ne pas** modifier les routes SEO. La propagation search_id concerne uniquement les appels SPA. Les routes SEO utilisent déjà ContextVar correctement.

### Simplifications autorisées

- Le tableau cache admin peut être un tableau unique (pas un tableau par service) avec une colonne "Service" pour filtrer visuellement.
- Le TTL restant peut être affiché en secondes arrondies ou en format humain (ex: "2h 15min"). Au choix du développeur.
- Le `generateSlug()` côté JS peut être une version simplifiée de la version Python tant qu'elle produit le même slug pour les cas courants (accents français, espaces, tirets).

### Décisions explicitement interdites

- Ne pas stocker la valeur complète dans `snapshot()` — risque mémoire et sécurité (données potentiellement sensibles).
- Ne pas exposer l'endpoint cache sans authentification admin.
- Ne pas utiliser `window.name` ou `localStorage` pour stocker le `search_id` — le passer en paramètre de fonction uniquement.

---

## 6) Stratégie de tests

### Unitaires (test_cache.py)

- T60 : `set("k", v)` → `get("k")` × 3 → `snapshot()[0]["hit_count"] == 3`
- T61 : `set("a", 1)`, `set("b", 2)` → `snapshot()` retourne 2 entrées avec les bons champs, sans `value`
- T62 : `TTLCache(ttl_seconds=1)` → `set("k", v)` → `time.sleep(2)` → `snapshot()` retourne `[]`

### Intégration admin (test_admin_api.py)

- T63 : `GET /admin/api/cache` avec auth → 200, body contient `weather`, `normals`, `communes_search`, `communes_slug`, `og_image`, chacun avec `entries`, `total`, `max_entries`, `ttl_seconds`
- T64 : `GET /admin/api/cache` sans auth → 401
- T65 : faire un appel `/api/weather` (cache miss) puis vérifier que `/admin/api/cache` → `weather.entries[0].key` contient le cache_key attendu et `hit_count == 0`
- T66 : créer une recherche success avec total_api_calls=0, vérifier qu'elle apparaît dans `list_searches` avec les données permettant de déduire le badge cache

### Intégration API (test_api.py)

- T67 : `GET /api/weather?lat=44&lon=6&start=...&end=...&commune=Tallard&slug=tallard-05` → search_logs a `commune_1 = 'Tallard'`, `commune_1_slug = 'tallard-05'`
- T68 : `GET /api/normals?lat=44&lon=6&start=...&end=...&search_id=<UUID>` (cache miss) → api_call_logs a `search_id = <UUID>`
- T69 : idem pour `/api/normals/annual`
- T70 : `GET /api/normals?...&search_id=not-a-uuid` → api_call_logs a `search_id = NULL`
- T71 : faire `/api/weather?...&search_id=<UUID>` puis `/api/normals?...&search_id=<UUID>` (même UUID) → `search_logs.total_api_calls` inclut weather + normals (ex: 1 + 3 = 4)

### Edge cases critiques

- `snapshot()` sur cache vide → `[]`
- `snapshot()` après `_evict_if_needed()` — les entrées évincées n'apparaissent pas
- `search_id` avec casse mixte (ex: majuscules) — doit être accepté (UUID case-insensitive)
- Deux appels `/api/weather` avec le même `search_id` client → le second échoue car `create_search` fait un INSERT avec PRIMARY KEY. Résultat : le second search est logué en erreur. Non bloquant — le UUID client est unique par recherche.

---

## 7) Risques techniques

| #   | Risque                                                                                         | Probabilité | Impact | Mitigation                                                                                                                                 |
| --- | ---------------------------------------------------------------------------------------------- | ----------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------ |
| 1   | **Regression TTLCache** — le changement de structure du tuple casse les tests existants        | Moyenne     | Élevé  | Adapter tous les tests `test_cache.py` existants. Le changement est mécanique. Vérifier que les 152 tests hérités passent avant de merger. |
| 2   | **Collision search_id client** — `crypto.randomUUID()` génère un UUID déjà existant en base    | Négligeable | Bas    | UUID v4 a $2^{122}$ valeurs possibles. `create_search` lèvera une exception absorbée par le try/except existant.                           |
| 3   | **Performance snapshot** — le lock dans `snapshot()` bloque les `get()/set()` pendant la copie | Basse       | Bas    | La copie est O(n) avec n = max_entries (500). Temps de lock < 1ms. Acceptable pour un endpoint admin appelé manuellement.                  |

---

## Acceptance Criteria

| #    | Critère                                                                                                                                                                                       |
| ---- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| AC52 | `TTLCache.get()` incrémente un compteur `hit_count` accessible via `snapshot()` à chaque cache hit                                                                                            |
| AC53 | `TTLCache.snapshot()` retourne pour chaque entrée non expirée : `key`, `created_at` (timestamp Unix), `expires_at` (timestamp Unix), `hit_count` (entier ≥ 0) — sans la valeur stockée        |
| AC54 | `TTLCache.set()` initialise `created_at` au timestamp courant et `hit_count` à 0                                                                                                              |
| AC55 | `GET /admin/api/cache` retourne un JSON avec 5 sections (`weather`, `normals`, `communes_search`, `communes_slug`, `og_image`), chacune avec `entries`, `total`, `max_entries`, `ttl_seconds` |
| AC56 | L'onglet "Cache" dans l'admin affiche un tableau avec les colonnes : Service, Clé, Mise en cache, Expiration, TTL restant, Réutilisations                                                     |
| AC57 | Dans la liste des recherches (onglet principal), un badge bleu "cache" apparaît à côté du badge statut lorsque `status == 'success'` ET `total_api_calls == 0`                                |
| AC58 | `fetchWeatherForCommune()` dans `public/app.js` transmet `commune` (nom) et `slug` (slug-dept) en query params à `/api/weather`                                                               |
| AC59 | `public/app.js` génère un UUID via `crypto.randomUUID()` avant chaque recherche et le transmet comme `search_id` à `/api/weather`, `/api/normals` et `/api/normals/annual`                    |
| AC60 | `/api/normals` et `/api/normals/annual` acceptent un paramètre optionnel `search_id` (format UUID) et le propagent via `set_current_search_id()` pour le tracking                             |
| AC61 | Un `search_id` non conforme au format UUID est ignoré silencieusement (aucune erreur, fallback sur `None` pour normals ou UUID auto-généré pour weather)                                      |
| AC62 | Après propagation, `total_api_calls` d'une recherche SPA inclut les appels weather ET normals avec le même `search_id`                                                                        |
| AC63 | La vue détail d'une recherche SPA affiche les appels weather ET normals associés                                                                                                              |
