# 001 — HistoMeteo MVP — Spec Technique v19

> **Source fonctionnelle** : `001-histometeo-mvp.md`
> **Base technique** : `001-histometeo-mvp.tech.v18.md`
> **Feedback intégré** : `feedback-to-architect-001-v18.md` (R56, R57)
> **Demandes additionnelles** :
>
> 1. Gestion propre de l'indisponibilité du service météo (3 états UI)
> 2. Stabilisation des pages résultat comme pages d'archive (prefetch + cache persistant)
>    **Date** : 2026-03-13

---

## 0) Contract

- **Source of truth** : cette spec technique (`001-histometeo-mvp.tech.v19.md`)
- **Functional integrity** : AC1–AC23 inchangés. Critères d'acceptation additionnels AC24–AC30.
  - AC24 : En cas d'erreur de l'API météo (réseau, timeout, 5xx), la page affiche uniquement le bloc recherche, un message d'erreur explicite et un bouton « Réessayer » — aucun élément dépendant des données n'est visible
  - AC25 : En cas de données indisponibles pour la période (réponse vide, 4xx validation), un message distinct est affiché, sans bouton « Réessayer »
  - AC26 : Le bouton « Réessayer » relance la requête avec les mêmes paramètres ; la page repasse en état de chargement puis affiche le succès ou l'erreur
  - AC27 : Pendant le chargement des données, le bouton de recherche est désactivé, un indicateur visuel est affiché, aucun résultat partiel n'apparaît
  - AC28 : Les routes SEO (`/meteo/{slug}/{start}/{end}`, `/meteo/{slug}/{year}/{month}`, `/ville/{slug}`) injectent les données météo pré-chargées dans le HTML via un `<script id="prefetched-data" type="application/json">`
  - AC29 : Si des données pré-chargées sont présentes dans le HTML, le frontend les utilise directement sans appel API — la page s'affiche instantanément
  - AC30 : Les données pré-chargées sont persistées dans un cache fichier ; les consultations suivantes d'une même URL sont servies depuis le cache sans appel API externe
- **Scope** — fichiers modifiables :
  - `public/index.html`
  - `public/style.css`
  - `public/app.js`
  - `src/main.py`
  - `src/cache.py`
  - `src/config.py`
  - `src/weather_service.py` (ajout d'un wrapper pour le prefetch, aucune modification de la logique de fetch existante)
  - Nouveau : `src/prefetch_service.py`
  - `tests/test_api.py` (ajout de tests pour les nouvelles routes)
  - Nouveau : `tests/test_prefetch_service.py`
  - `docs/specs/001-histometeo-mvp.tech.v19.md`
- **Forbidden changes** :
  - `src/normals_service.py`, `src/commune_service.py`, `src/og_service.py`
  - `requirements.txt`, `Dockerfile`, `pyproject.toml`
  - `README.md`, `.github/`, `public/assets/`
  - `src/assets/weather-icons/`
  - Tests existants ≤ v18 (93 tests) — ne pas modifier, tous doivent continuer à passer
- **Invariants** :
  - INV-1 à INV-27 : tous préservés (cf. v18)
  - INV-7 : pas de `innerHTML` dans `app.js`
  - INV-12 : pas de JS media queries (`matchMedia`, `window.innerWidth` interdits)
  - INV-14 : OG tags server-side uniquement
  - INV-20 : un seul `<h1>` visible par page à tout moment
  - INV-25 : le formulaire de recherche n'est jamais dupliqué
  - INV-26 : le bloc compact est peuplé exclusivement via `textContent`
  - INV-27 : la transition compact ↔ ouvert utilise uniquement des animations CSS
  - INV-28 _(nouveau)_ : les données pré-chargées sont injectées via `<script type="application/json">` — jamais via JS exécutable ni `eval`
  - INV-29 _(nouveau)_ : le cache fichier utilise des écritures atomiques (écriture dans un fichier temporaire puis `rename`) pour éviter toute corruption
  - INV-30 _(nouveau)_ : en état d'erreur, TOUTES les sections dépendantes des données sont masquées — seuls le bloc recherche, le message d'erreur et le bouton « Réessayer » sont visibles
  - INV-31 _(nouveau)_ : le bouton « Réessayer » utilise les paramètres de recherche existants (pas de nouvel état) — il appelle la même fonction de chargement
  - INV-32 _(nouveau)_ : les clés du cache fichier sont dérivées du slug URL + dates (pas de coordonnées GPS dans les noms de fichiers)
  - INV-33 _(nouveau)_ : le cache fichier ne stocke JAMAIS de données provenant de l'entrée utilisateur brute — les clés sont validées et normalisées côté serveur
- **Done when** :
  - D20 : En cas d'erreur API météo, la page affiche uniquement le bloc recherche (compact ou ouvert), un message d'erreur et un bouton « Réessayer »
  - D21 : Aucun élément de résultat n'est visible en état d'erreur (onglets, navigation, résumé, graphique, détail horaire — tous masqués)
  - D22 : Le bouton « Réessayer » relance le chargement et affiche l'état de chargement
  - D23 : Le message distingue « service indisponible » vs « aucune donnée disponible »
  - D24 : Les routes SEO `/meteo/{slug}/{start}/{end}` injectent les données pré-chargées dans le HTML
  - D25 : Les routes SEO `/meteo/{slug}/{year}/{month}` injectent les données pré-chargées dans le HTML
  - D26 : La route SEO `/ville/{slug}` injecte les données pré-chargées dans le HTML
  - D27 : Le frontend détecte les données pré-chargées et les utilise sans appel API
  - D28 : Un cache fichier persiste les données entre redémarrages du serveur
  - D29 : R56 — les suppressions `__pycache__/` sont committées
  - D30 : R57 — `formatCompactCommune` et `formatCommuneLabel` fusionnés en une seule fonction
  - D31 : 93 tests hérités passent + nouveaux tests pour le prefetch service
  - D32 : État de chargement clair : bouton désactivé, spinner visible, aucun résultat partiel

---

## 1) Objectif technique

Deux évolutions complémentaires :

### 1.1 Gestion d'état d'erreur (frontend)

Introduire un modèle d'état explicite à 3 valeurs dans le frontend :

| État        | Affichage                                                                |
| ----------- | ------------------------------------------------------------------------ |
| **loading** | Bouton désactivé, spinner, aucun résultat, aucune erreur                 |
| **success** | Résultats complets (comportement actuel)                                 |
| **error**   | Bloc recherche + message d'erreur typé + bouton « Réessayer » uniquement |

En état d'erreur, TOUS les éléments dépendants des données sont masqués : onglets, navigation temporelle, résumé, graphique, détail horaire, liens internes, bloc de partage, normales climatiques.

### 1.2 Pages d'archive (backend + frontend)

Transformer les routes SEO en pages d'archive servies immédiatement :

1. Le backend pré-charge les données météo (+ normales + commune) lors du premier accès à une route SEO
2. Les données sont injectées dans le HTML comme `<script id="prefetched-data" type="application/json">`
3. Elles sont persistées dans un cache fichier pour les accès suivants
4. Le frontend détecte ces données pré-chargées et les affiche instantanément — sans appel API visible

Résultat : les pages météo historiques se comportent comme des archives stables, consultables immédiatement.

---

## 2) Analyse du brief

### Besoins principaux

| #   | Besoin                                                                 | Origine              |
| --- | ---------------------------------------------------------------------- | -------------------- |
| B9  | 3 états UI clairs : chargement / succès / erreur                       | Brief Erreur §2      |
| B10 | En erreur : masquer toute section dépendante des données               | Brief Erreur §4      |
| B11 | Message d'erreur clair + bouton « Réessayer »                          | Brief Erreur §5, §6  |
| B12 | Différencier « service indisponible » vs « aucune donnée »             | Brief Erreur §7      |
| B13 | État de chargement clair (bouton désactivé, indicateur)                | Brief Erreur §8      |
| B14 | Navigation temporelle masquée en erreur                                | Brief Erreur §9      |
| B15 | Onglets masqués en erreur                                              | Brief Erreur §10     |
| B16 | Pages résultat = pages d'archive instantanées                          | Brief Archive §2     |
| B17 | Données pré-chargées côté serveur au premier accès                     | Brief Archive §3     |
| B18 | Cache persistant très longue durée                                     | Brief Archive §4, §5 |
| B19 | Frontend ne dépend plus d'un appel API visible sur les pages archivées | Brief Archive §6     |
| B20 | R56 — commit des suppressions `__pycache__/`                           | Feedback v18         |
| B21 | R57 — fusion `formatCompactCommune` / `formatCommuneLabel`             | Feedback v18         |

### Contraintes

- Le budget API reste **zéro** — aucun service payant. Le cache fichier utilise le système de fichiers local (pas de Redis, pas de BDD)
- Les routes API existantes (`/api/weather`, `/api/normals`, `/api/communes`) restent inchangées pour le flux de recherche depuis la page d'accueil
- Le cache in-memory `TTLCache` existant reste actif comme couche L1. Le cache fichier est une couche L2 persistante
- Le prefetch côté serveur est **best-effort** : si l'API externe est indisponible au moment du premier accès, la page est servie sans données pré-chargées → le frontend essaie via l'API classique → en cas d'échec, l'état d'erreur est affiché proprement
- Les dependencies Python existantes (`httpx`, `fastapi`) suffisent — aucune nouvelle dépendance

### Risques

Voir §7.

---

## 3) Design minimal proposé

### 3.1 Architecture du cache fichier — `src/cache.py`

#### 3.1.1 Nouvelle classe `FileCache`

Ajout dans `src/cache.py` d'une classe `FileCache` complémentaire à `TTLCache` :

```python
class FileCache:
    """Cache persistant basé sur le système de fichiers.

    Les données historiques météo ne changent pas — TTL très long ou permanent.
    """

    def __init__(self, base_dir: Path, ttl_seconds: int = 0) -> None:
        self.base_dir = base_dir
        self.ttl_seconds = ttl_seconds  # 0 = permanent
        self.base_dir.mkdir(parents=True, exist_ok=True)

    def _key_to_path(self, key: str) -> Path:
        """Convertit une clé en chemin de fichier sûr."""
        safe_key = re.sub(r'[^a-zA-Z0-9_\-]', '_', key)
        return self.base_dir / f"{safe_key}.json"

    def get(self, key: str) -> dict | None:
        path = self._key_to_path(key)
        if not path.exists():
            return None

        try:
            raw = path.read_text(encoding="utf-8")
            entry = json.loads(raw)
        except (OSError, json.JSONDecodeError):
            return None

        # Vérifier TTL si activé
        if self.ttl_seconds > 0:
            created_at = entry.get("_created_at", 0)
            if time.time() - created_at > self.ttl_seconds:
                path.unlink(missing_ok=True)
                return None

        return entry.get("data")

    def set(self, key: str, value: dict) -> None:
        path = self._key_to_path(key)
        tmp_path = path.with_suffix(".tmp")
        entry = {
            "_created_at": time.time(),
            "data": value,
        }
        try:
            tmp_path.write_text(
                json.dumps(entry, ensure_ascii=False),
                encoding="utf-8",
            )
            tmp_path.replace(path)  # Écriture atomique (INV-29)
        except OSError:
            tmp_path.unlink(missing_ok=True)
```

**Sécurité des clés** (INV-33) : `_key_to_path` sanitise la clé avec une regex — seuls les caractères alphanumériques, `_` et `-` passent. Aucune donnée utilisateur brute ne peut manipuler le chemin du fichier.

**Écriture atomique** (INV-29) : écriture dans un `.tmp` puis `replace()` — si le processus plante pendant l'écriture, le fichier original reste intact.

#### 3.1.2 Imports nécessaires dans `cache.py`

Ajouter en haut du fichier :

```python
import json
import re
from pathlib import Path
```

### 3.2 Configuration — `src/config.py`

Ajout de constantes :

```python
# Cache fichier (persistant)
DATA_DIR = Path(__file__).resolve().parent.parent / "data"
FILE_CACHE_DIR = DATA_DIR / "weather_cache"
FILE_CACHE_TTL_SECONDS = 0  # 0 = permanent (données historiques stables)
```

> Les données météo historiques représentent le passé — elles ne changent pas. Un cache permanent est approprié. Si une recalculation est nécessaire à l'avenir, supprimer le répertoire `data/weather_cache/` suffit.

Ajout dans `.gitignore` (si absent) :

```
data/weather_cache/
```

### 3.3 Service de pré-chargement — `src/prefetch_service.py` (nouveau)

Ce service orchestre le pré-chargement des données pour les routes SEO.

```python
from __future__ import annotations

import logging
from typing import Any

from src.cache import FileCache
from src.config import FILE_CACHE_DIR, FILE_CACHE_TTL_SECONDS

logger = logging.getLogger(__name__)

_file_cache = FileCache(FILE_CACHE_DIR, FILE_CACHE_TTL_SECONDS)


def cache_key_period(slug: str, start: str, end: str) -> str:
    """Clé de cache pour une requête période."""
    return f"period_{slug}_{start}_{end}"


def cache_key_month(slug: str, year: int, month: int) -> str:
    """Clé de cache pour une requête mois."""
    return f"month_{slug}_{year}_{month:02d}"


def cache_key_town(slug: str) -> str:
    """Clé de cache pour une page ville."""
    return f"town_{slug}"


def get_cached(key: str) -> dict | None:
    """Récupère les données depuis le cache fichier."""
    return _file_cache.get(key)


def store_cached(key: str, data: dict) -> None:
    """Stocke les données dans le cache fichier."""
    _file_cache.set(key, data)


async def prefetch_period(
    commune_service: Any,
    weather_service: Any,
    normals_service: Any,
    slug: str,
    start: str,
    end: str,
) -> dict | None:
    """Pré-charge toutes les données pour une page période.

    Retourne le bundle complet ou None en cas d'erreur.
    L'erreur est silencieuse — le frontend fera un fallback API.
    """
    key = cache_key_period(slug, start, end)

    # Essayer le cache d'abord
    cached = get_cached(key)
    if cached is not None:
        return cached

    try:
        commune = await commune_service.resolve_slug(slug)
        if commune is None:
            return None

        lat = commune.get("latitude")
        lon = commune.get("longitude")
        if lat is None or lon is None:
            return None

        weather = await weather_service.get_weather(lat, lon, start, end)

        # Normales — best-effort, non bloquant
        normals = None
        try:
            normals = await normals_service.get_normals(lat, lon, start, end)
        except Exception:
            pass

        bundle = {
            "commune": commune,
            "weather": weather,
            "normals": normals,
        }

        store_cached(key, bundle)
        return bundle

    except Exception:
        logger.debug("Prefetch failed for %s %s/%s", slug, start, end)
        return None


async def prefetch_month(
    commune_service: Any,
    weather_service: Any,
    normals_service: Any,
    slug: str,
    start: str,
    end: str,
    year: int,
    month: int,
) -> dict | None:
    """Pré-charge toutes les données pour une page mois.

    Le mois est découpé en chunks de ≤ 31 jours par le caller.
    """
    key = cache_key_month(slug, year, month)

    cached = get_cached(key)
    if cached is not None:
        return cached

    try:
        commune = await commune_service.resolve_slug(slug)
        if commune is None:
            return None

        lat = commune.get("latitude")
        lon = commune.get("longitude")
        if lat is None or lon is None:
            return None

        weather = await weather_service.get_weather(lat, lon, start, end)

        normals = None
        try:
            normals = await normals_service.get_normals(lat, lon, start, end)
        except Exception:
            pass

        bundle = {
            "commune": commune,
            "weather": weather,
            "normals": normals,
        }

        store_cached(key, bundle)
        return bundle

    except Exception:
        logger.debug("Prefetch failed for %s %d/%02d", slug, year, month)
        return None


async def prefetch_town(
    commune_service: Any,
    normals_service: Any,
    slug: str,
) -> dict | None:
    """Pré-charge les données pour une page ville (climat annuel)."""
    key = cache_key_town(slug)

    cached = get_cached(key)
    if cached is not None:
        return cached

    try:
        commune = await commune_service.resolve_slug(slug)
        if commune is None:
            return None

        lat = commune.get("latitude")
        lon = commune.get("longitude")
        if lat is None or lon is None:
            return None

        annual = None
        try:
            annual = await normals_service.get_annual_normals(lat, lon)
        except Exception:
            pass

        bundle = {
            "commune": commune,
            "annual_climate": annual,
        }

        store_cached(key, bundle)
        return bundle

    except Exception:
        logger.debug("Prefetch failed for town %s", slug)
        return None
```

**Design** :

- Chaque type de page a sa propre fonction de prefetch + clé de cache
- Le prefetch est **best-effort** : en cas d'échec, `None` est retourné et la page est servie sans données pré-chargées
- Les normales sont récupérées en best-effort (erreur silencieuse) — leur absence n'empêche pas le rendu principal

### 3.4 Modification des routes SEO — `src/main.py`

#### 3.4.1 Nouvelle fonction d'injection de données

```python
import json as json_module

def _inject_prefetched_data(html: str, data: dict | None) -> str:
    """Injecte les données pré-chargées dans le HTML comme script JSON."""
    if data is None:
        return html

    json_str = json_module.dumps(data, ensure_ascii=False)
    # Échapper </script> pour éviter l'injection (sécurité)
    json_str = json_str.replace("</", "<\\/")

    script_tag = f'<script id="prefetched-data" type="application/json">{json_str}</script>'

    # Insérer avant </head>
    return html.replace("</head>", f"{script_tag}\n</head>", 1)
```

**Sécurité** (INV-28) : le `type="application/json"` empêche l'exécution automatique par le navigateur. Le contenu est du JSON pur, pas du JavaScript. L'échappement de `</` prévient les attaques par injection de balise fermante.

#### 3.4.2 Import du prefetch service

En haut de `main.py` :

```python
from src.prefetch_service import prefetch_period, prefetch_month, prefetch_town
```

#### 3.4.3 Modification de `seo_meteo_page` (route `/meteo/{slug}/{start}/{end}`)

Après la construction du HTML avec les OG tags, ajouter le prefetch :

```python
@app.get("/meteo/{slug_dept}/{start}/{end}")
async def seo_meteo_page(
    request: Request,
    slug_dept: str,
    start: str = FastAPIPath(pattern=ISO_DATE_PATTERN),
    end: str = FastAPIPath(pattern=ISO_DATE_PATTERN),
) -> Response:
    # ... code OG existant inchangé ...

    html = _inject_og_tags(_get_html_template(), { ... })  # existant

    # --- NOUVEAU : prefetch des données ---
    prefetched = await prefetch_period(
        commune_service, weather_service, normals_service,
        slug_dept, start, end,
    )
    html = _inject_prefetched_data(html, prefetched)
    # --- FIN NOUVEAU ---

    return Response(content=html, media_type="text/html")
```

#### 3.4.4 Modification de `seo_month_page` (route `/meteo/{slug}/{year}/{month}`)

Même patron — ajouter le prefetch après la construction OG :

```python
    # --- NOUVEAU : prefetch des données ---
    prefetched = await prefetch_month(
        commune_service, weather_service, normals_service,
        slug_dept,
        first_day.isoformat(), last_day.isoformat(),
        year, month,
    )
    html = _inject_prefetched_data(html, prefetched)
    # --- FIN NOUVEAU ---
```

#### 3.4.5 Modification de `seo_ville_page` (route `/ville/{slug}`)

```python
    # --- NOUVEAU : prefetch des données ---
    prefetched = await prefetch_town(
        commune_service, normals_service,
        slug_dept,
    )
    html = _inject_prefetched_data(html, prefetched)
    # --- FIN NOUVEAU ---
```

#### 3.4.6 Route de comparaison

La route `/comparaison/...` n'est PAS pré-chargée dans cette itération. Raisons :

- Deux communes = deux résolutions + deux jeux de données météo → complexité accrue
- Usage moins fréquent que les pages simples
- Le frontend continue d'utiliser le flux API classique pour les comparaisons

### 3.5 Modification frontend — État d'erreur

#### 3.5.1 Nouveau HTML dans `public/index.html`

Modifier le bloc `#global-error` existant pour ajouter un bouton « Réessayer » et structurer le message :

**Avant** :

```html
<div id="global-error" class="error hidden" aria-live="polite"></div>
```

**Après** :

```html
<div id="global-error" class="error-block hidden" aria-live="polite">
  <p id="global-error-message" class="error-message"></p>
  <button type="button" id="retry-button" class="btn-primary hidden">
    Réessayer
  </button>
</div>
```

#### 3.5.2 Nouveau CSS dans `public/style.css`

```css
/* ── Error State ─────────────────────────── */

.error-block {
  text-align: center;
  padding: var(--space-lg);
  margin: var(--space-md) 0;
  border: 1px solid var(--border);
  border-radius: 8px;
  background: var(--surface);
}

.error-block.hidden {
  display: none;
}

.error-message {
  color: var(--danger, #c0392b);
  font-size: 1.05rem;
  margin: 0 0 var(--space-md);
  line-height: 1.5;
}

#retry-button {
  margin-top: var(--space-sm);
}

#retry-button.hidden {
  display: none;
}

/* ── Loading Spinner ─────────────────────── */

.spinner {
  display: inline-block;
  width: 1.2em;
  height: 1.2em;
  border: 2px solid var(--border);
  border-top-color: var(--primary, #2563eb);
  border-radius: 50%;
  animation: spin 0.6s linear infinite;
  vertical-align: middle;
  margin-right: var(--space-xs, 4px);
}

@keyframes spin {
  to {
    transform: rotate(360deg);
  }
}
```

#### 3.5.3 JavaScript — Nouvelles fonctions dans `public/app.js`

##### Références DOM supplémentaires

```javascript
const globalError = document.getElementById("global-error");
const globalErrorMessage = document.getElementById("global-error-message");
const retryButton = document.getElementById("retry-button");
```

> **Note** : remplace la référence unique `globalError` existante. `globalErrorMessage` et `retryButton` sont nouveaux.

##### Remplacement de `showGlobalError()` / `clearGlobalError()`

**Avant** :

```javascript
function showGlobalError(message) {
  globalError.textContent = message;
  globalError.classList.remove("hidden");
}

function clearGlobalError() {
  globalError.textContent = "";
  globalError.classList.add("hidden");
}
```

**Après** :

```javascript
// Variable pour stocker le callback de retry
let retryCallback = null;

function showGlobalError(message, options = {}) {
  const { canRetry = false, onRetry = null } = options;

  globalErrorMessage.textContent = message;
  globalError.classList.remove("hidden");

  if (canRetry && onRetry) {
    retryCallback = onRetry;
    retryButton.classList.remove("hidden");
  } else {
    retryCallback = null;
    retryButton.classList.add("hidden");
  }

  // Masquer TOUTES les sections dépendantes des données (INV-30)
  hideAllDataSections();
}

function clearGlobalError() {
  globalErrorMessage.textContent = "";
  globalError.classList.add("hidden");
  retryButton.classList.add("hidden");
  retryCallback = null;
}
```

##### Nouvelle fonction `hideAllDataSections()`

```javascript
function hideAllDataSections() {
  const dataSections = [
    "period-summary",
    "daily-summary",
    "climate-normals",
    "chart",
    "results",
    "results-nav",
    "seo-h2",
    "seo-intro",
    "period-links",
    "share-block",
    "commune-info",
    "comparison-summary",
    "climate-month",
    "month-summary",
    "annual-climate",
    "month-links",
    "quick-periods",
    "internal-links",
  ];

  for (const id of dataSections) {
    const el = document.getElementById(id);
    if (el) el.classList.add("hidden");
  }
}
```

> Cette fonction est le cœur de la gestion d'erreur : elle garantit qu'AUCUN élément de résultat ne reste visible (INV-30). La liste est exhaustive et couvre tous les blocs de la page résultat.

##### Event listener pour le bouton « Réessayer »

```javascript
retryButton.addEventListener("click", () => {
  if (retryCallback) {
    retryCallback();
  }
});
```

##### État de chargement amélioré

Le mécanisme existant dans `performSearch()` désactive le bouton et change son texte. Compléter avec un spinner visuel :

```javascript
function setLoadingState(isLoading) {
  if (isLoading) {
    searchButton.disabled = true;
    searchButton.textContent = "";

    const spinner = document.createElement("span");
    spinner.className = "spinner";
    spinner.setAttribute("aria-hidden", "true");
    searchButton.appendChild(spinner);
    searchButton.appendChild(document.createTextNode(" Chargement…"));

    clearGlobalError();
    clearResults();
  } else {
    searchButton.textContent = "Voir la météo";
    updateSearchButtonState();
  }
}
```

#### 3.5.4 Différenciation des types d'erreur

Les erreurs doivent être classifiées en deux catégories :

| Type                     | Condition                                                 | Message                                                                                                                                           | Retry  |
| ------------------------ | --------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | ------ |
| **Service indisponible** | HTTP 502, réseau, timeout, erreur serveur                 | « Impossible de récupérer les données météo pour le moment. Le service météo est temporairement indisponible. Réessayez dans quelques instants. » | ✅ Oui |
| **Aucune donnée**        | HTTP 400 (période hors limites, validation), réponse vide | « Aucune donnée météo n'est disponible pour cette période. »                                                                                      | ❌ Non |

**Implémentation** — modifier les fonctions `fetch*` pour propager le type d'erreur :

```javascript
class WeatherError extends Error {
  constructor(message, type) {
    super(message);
    this.type = type; // "unavailable" | "no-data"
  }
}

async function fetchWeather(lat, lon, start, end) {
  const response = await fetch(
    "/api/weather?" + new URLSearchParams({ lat, lon, start, end }),
  );
  const payload = await response.json();

  if (!response.ok) {
    const type =
      response.status >= 500 || response.status === 502
        ? "unavailable"
        : "no-data";
    throw new WeatherError(payload.error || "Erreur inconnue.", type);
  }

  if (!payload.data || payload.data.length === 0) {
    throw new WeatherError(
      "Aucune donnée météo n'est disponible pour cette période.",
      "no-data",
    );
  }

  return payload;
}
```

**Gestion dans les catch** — modifier `performSearch()` et `loadFromURL()` :

```javascript
catch (error) {
  clearResults();
  setSearchExpanded();

  if (error instanceof WeatherError && error.type === "unavailable") {
    showGlobalError(
      "Impossible de récupérer les données météo pour le moment. Le service météo est temporairement indisponible. Réessayez dans quelques instants.",
      {
        canRetry: true,
        onRetry: () => performSearch(),  // ou () => loadFromURL()
      }
    );
  } else if (error instanceof WeatherError && error.type === "no-data") {
    showGlobalError(
      "Aucune donnée météo n'est disponible pour cette période."
    );
  } else {
    showGlobalError(
      error.message || "Une erreur inattendue s'est produite.",
      {
        canRetry: true,
        onRetry: () => performSearch(),
      }
    );
  }
}
```

#### 3.5.5 Détection des données pré-chargées — `loadPrefetchedData()`

```javascript
function loadPrefetchedData() {
  const scriptEl = document.getElementById("prefetched-data");
  if (!scriptEl) return null;

  try {
    const data = JSON.parse(scriptEl.textContent);
    // Retirer le script pour éviter un double chargement
    scriptEl.remove();
    return data;
  } catch (_e) {
    return null;
  }
}
```

#### 3.5.6 Intégration dans `loadFromURL()`

Le flux de `loadFromURL()` est modifié pour vérifier la présence de données pré-chargées :

```javascript
async function loadFromURL() {
  setSearchExpanded();

  const parsed = parseSeoPath(window.location.pathname);
  const prefetched = loadPrefetchedData();

  try {
    if (parsed.mode === "town") {
      if (prefetched && prefetched.commune) {
        await renderTownPageFromData(parsed.slug, prefetched);
      } else {
        await loadTownPage(parsed.slug);
      }
    } else if (parsed.mode === "month") {
      if (prefetched && prefetched.commune && prefetched.weather) {
        await renderMonthPageFromData(parsed, prefetched);
      } else {
        await loadMonthPage(parsed.slug, parsed.year, parsed.month);
      }
    } else if (parsed.mode === "simple") {
      if (prefetched && prefetched.commune && prefetched.weather) {
        await renderSimpleResultsFromData(parsed, prefetched);
      } else {
        // Flux existant : resolve commune + fetch weather via API
        await loadSimplePage(parsed.slug, parsed.start, parsed.end);
      }
    } else if (parsed.mode === "comparison") {
      // Pas de prefetch pour les comparaisons — flux API classique
      await loadComparisonPage(parsed);
    } else {
      // Page d'accueil ou paramètres query string
      // ... flux existant inchangé ...
    }
  } catch (error) {
    setSearchExpanded();
    // ... gestion erreur avec différenciation (cf. 3.5.4) ...
  }
}
```

> **Important** : les fonctions `render*FromData()` sont des variantes allégées des flux existants qui reçoivent les données déjà résolues (commune, weather, normals) au lieu de faire des appels API. Elles appellent exactement les mêmes fonctions de rendu (`renderSimpleResults()`, `renderTownPage()`, `renderMonthPage()`) mais avec les données en entrée directe.

#### 3.5.7 Nouvelles fonctions `render*FromData()`

Ces fonctions encapsulent le flux « données déjà disponibles ». Elles évitent de dupliquer la logique de rendu — elles préparent l'état et délèguent aux fonctions de rendu existantes.

**Principe commun** :

1. Extraire `commune`, `weather`, `normals` du bundle pré-chargé
2. Alimenter les variables globales (`selectedCommune`, dates, etc.)
3. Appeler les fonctions de rendu existantes
4. Si `normals` est `null`, sauter le rendu des normales sans erreur

**Exemple pour `renderSimpleResultsFromData`** :

```javascript
async function renderSimpleResultsFromData(parsed, data) {
  const commune = data.commune;
  const weather = data.weather;
  const normals = data.normals;

  // Alimenter l'état global comme le ferait le flux API
  selectedCommune = commune;
  communeInput.value = commune.nom || "";
  dateStart.value = parsed.start;
  dateEnd.value = parsed.end;
  syncHiddenToText(dateStartController);
  syncHiddenToText(dateEndController);

  // Appeler le rendu existant avec les données
  renderSimpleResults(commune, parsed.start, parsed.end, weather);

  // Normales — best-effort
  if (normals) {
    renderClimateNormals(normals, computeObservedAggregates(weather));
  } else {
    appendSeasonalContext(commune.nom, parsed.start, null);
    climateNormalsSection.classList.add("hidden");
  }
}
```

> Le même patron s'applique à `renderMonthPageFromData()` et `renderTownPageFromData()`. Les fonctions de rendu existantes ne sont pas modifiées — elles reçoivent les mêmes données dans le même format.

### 3.6 Récapitulatif du flux par scénario

#### Scénario A — Page archivée avec données en cache

```
Navigateur → GET /meteo/gap-05/2026-03-01/2026-03-07
  Backend : cache fichier HIT → injecte le JSON dans <script>
  Frontend : loadPrefetchedData() → données trouvées
           → renderSimpleResultsFromData() → affichage instantané
  Résultat : 0 appel API externe, page immédiate
```

#### Scénario B — Première consultation d'une page

```
Navigateur → GET /meteo/gap-05/2026-03-01/2026-03-07
  Backend : cache fichier MISS → appelle weather_service + normals_service
          → stocke dans le cache fichier → injecte dans <script>
  Frontend : loadPrefetchedData() → données trouvées
           → renderSimpleResultsFromData() → affichage instantané
  Résultat : 1 appel API externe (backend), page immédiate pour l'utilisateur
```

#### Scénario C — First visit + API externe indisponible

```
Navigateur → GET /meteo/gap-05/2026-03-01/2026-03-07
  Backend : cache fichier MISS → appel API échoue → prefetch retourne None
          → HTML servi sans données pré-chargées
  Frontend : loadPrefetchedData() → null
           → flux classique : fetch /api/weather → échoue
           → showGlobalError("service indisponible", { canRetry: true })
  Résultat : état d'erreur propre avec bouton « Réessayer »
```

#### Scénario D — Recherche depuis la page d'accueil

```
Navigateur → Page d'accueil → formulaire
  Utilisateur : saisit commune + période → clic « Voir la météo »
  Frontend : performSearch() → fetch /api/weather (flux existant inchangé)
           → pushState → renderSimpleResults()
  Résultat : comportement identique à aujourd'hui
```

### 3.7 Intégration du feedback v18

#### R56 — Commit des suppressions `__pycache__/`

```bash
git commit -m "chore: remove __pycache__ from git index"
```

Les suppressions sont déjà stagées depuis v18.

#### R57 — Fusion `formatCompactCommune` / `formatCommuneLabel`

Remplacer les deux fonctions par une seule `formatCommuneLabel(commune)` :

```javascript
function formatCommuneLabel(commune) {
  if (!commune) return "";
  const dept = commune.departement || commune.codeDepartement || "";
  return commune.nom + (dept ? " (" + dept + ")" : "");
}
```

Cette fonction unique est utilisée :

- Dans le bloc compact (`setSearchCompact`)
- Partout où `formatCommuneLabel` était utilisé auparavant
- À la place de `formatCompactCommune` supprimé

---

## 4) Plan d'implémentation

| Étape | Description                                                                                                                                                                                                          | Fichiers                  | Testable                                                                                              |
| ----- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------- | ----------------------------------------------------------------------------------------------------- |
| E1    | `src/cache.py` : ajouter la classe `FileCache` avec écriture atomique + TTL optionnel                                                                                                                                | `src/cache.py`            | Nouveau test `test_file_cache_read_write()`                                                           |
| E2    | `src/config.py` : ajouter `DATA_DIR`, `FILE_CACHE_DIR`, `FILE_CACHE_TTL_SECONDS`                                                                                                                                     | `src/config.py`           | Import check                                                                                          |
| E3    | Créer `src/prefetch_service.py` avec les fonctions `prefetch_period`, `prefetch_month`, `prefetch_town`                                                                                                              | `src/prefetch_service.py` | Nouveaux tests `test_prefetch_service.py`                                                             |
| E4    | `src/main.py` : ajouter `_inject_prefetched_data()`, modifier les 3 routes SEO pour appeler le prefetch                                                                                                              | `src/main.py`             | Test : route `/meteo/{slug}/{start}/{end}` retourne un HTML contenant `<script id="prefetched-data">` |
| E5    | `public/index.html` : restructurer `#global-error` avec `#global-error-message` + `#retry-button`                                                                                                                    | `public/index.html`       | Visuel — le bloc d'erreur n'apparaît pas par défaut                                                   |
| E6    | `public/style.css` : ajouter les styles `.error-block`, `.error-message`, `.spinner`, `#retry-button`                                                                                                                | `public/style.css`        | Visuel — spinner CSS animé                                                                            |
| E7    | `public/app.js` : implémenter `WeatherError`, `hideAllDataSections()`, nouveau `showGlobalError()` / `clearGlobalError()`, `setLoadingState()`, event listener retry, modification de `performSearch()` catch blocks | `public/app.js`           | Fonctionnel — provoquer une erreur API → seul le message + bouton « Réessayer » sont visibles         |
| E8    | `public/app.js` : implémenter `loadPrefetchedData()`, `render*FromData()`, intégrer dans `loadFromURL()`                                                                                                             | `public/app.js`           | Fonctionnel — visiter une route SEO → affichage instantané sans appel API client visible              |
| E9    | `public/app.js` : R57 — fusionner `formatCompactCommune` + `formatCommuneLabel` en une seule fonction                                                                                                                | `public/app.js`           | Fonctionnel — bloc compact affiche toujours correctement la commune                                   |
| E10   | R56 : `git commit` des suppressions `__pycache__/`                                                                                                                                                                   | _(git)_                   | `git log --oneline -1`                                                                                |
| E11   | Validation : `pytest tests/ -v` → 93 hérités + nouveaux tests PASSED. Tests manuels M27–M40.                                                                                                                         | —                         | Tous les tests passent                                                                                |

---

## 5) Guide pour le Développeur

### Pièges fréquents

1. **Le `<script type="application/json">` n'est PAS exécuté par le navigateur** — c'est du JSON inerte. Le frontend doit explicitement le parser via `JSON.parse(scriptEl.textContent)`. Ne JAMAIS utiliser `type="text/javascript"` pour les données pré-chargées (INV-28).

2. **Échapper `</script>` dans le JSON injecté** — Si une donnée météo contient la chaîne `</`, elle pourrait fermer prématurément la balise `<script>`. L'échappement `<\/` dans `_inject_prefetched_data()` est obligatoire.

3. **Le prefetch est best-effort** — Si l'API externe échoue pendant le prefetch côté serveur, la route SEO retourne la page sans `<script id="prefetched-data">`. Le frontend doit gérer ce cas (fallback API classique). Ne JAMAIS bloquer le rendu de la page si le prefetch échoue.

4. **Le cache fichier utilise `replace()` pour l'atomicité** — Sur Windows, `Path.replace()` peut échouer si le fichier cible est ouvert par un autre processus. C'est très rare pour un serveur web mono-processus. Si des problèmes surviennent, passer par `os.replace()` qui a le même comportement.

5. **Les clés de cache sont sanitisées** — `_key_to_path()` remplace tout caractère non-alphanumérique par `_`. Vérifier que deux URLs différentes ne produisent pas la même clé (collision). Avec le préfixe `period_`, `month_`, `town_` + le slug + les dates, les collisions sont exclues.

6. **`hideAllDataSections()` masque TOUS les blocs** — La liste est exhaustive. Si un nouveau bloc de résultat est ajouté dans une itération future, il faut l'ajouter à cette liste. Sinon il restera visible en état d'erreur.

7. **Le `retryCallback` est une closure** — Il capture les paramètres de recherche au moment de l'erreur. Si l'utilisateur modifie le formulaire entre l'erreur et le clic « Réessayer », le retry utilise les ANCIENS paramètres (c'est le comportement voulu — « réessayer la même requête »).

8. **`loadPrefetchedData()` supprime le `<script>` après lecture** — Pour éviter qu'un `pushState` suivi d'un rechargement de page ne relise les anciennes données. Le script est un one-shot.

9. **Le rendu des normales dans `render*FromData()` est asynchrone-compatible** — Même si les données sont déjà disponibles, maintenir les fonctions `async` pour uniformité avec le flux API.

### Zones de dérive

- Ne pas créer de base de données (SQLite, Redis) — le cache fichier JSON suffitpour le volume attendu
- Ne pas pré-charger les pages de comparaison — hors scope de cette itération
- Ne pas modifier les endpoints API existants (`/api/weather`, `/api/normals`) — ils restent inchangés pour le flux de recherche
- Ne pas ajouter de mécanisme de retry automatique côté serveur — le prefetch échoue silencieusement, le frontend gère
- Ne pas ajouter de header `Cache-Control` sur les routes SEO HTML — le cache navigateur pourrait servir des données obsolètes. Le cache est côté serveur uniquement

### Simplifications autorisées

- La fonction `_inject_prefetched_data()` insère le `<script>` avant `</head>` avec un simple `str.replace()`. C'est suffisant car le template HTML est contrôlé — pas besoin d'un parser HTML complet.
- Le `FileCache` n'a pas de limite d'entrées (pas de `max_entries`). Le volume de données historiques est fini et la taille sur disque est faible (~50 KB par fichier JSON). Un nettoyage périodique peut être ajouté ultérieurement si besoin.
- Les fonctions `render*FromData()` peuvent être implémentées comme un pré-remplissage de variables globales + appel direct aux fonctions de rendu existantes. Pas besoin de refactorer l'architecture de rendu.

### Décisions explicitement interdites

- Ne pas modifier de fichier backend interdit (`normals_service.py`, `commune_service.py`, `og_service.py`)
- Ne pas modifier les 93 tests existants
- Ne pas utiliser `innerHTML` (INV-7)
- Ne pas utiliser `eval()` ni `new Function()` pour charger les données pré-chargées (INV-28)
- Ne pas dupliquer le formulaire (INV-25)
- Ne pas stocker de données utilisateur dans le cache fichier (INV-33) — uniquement des données API normalisées

---

## 6) Stratégie de tests

### Tests existants à préserver

Les 93 tests existants doivent tous passer sans modification.

### Nouveaux tests backend

#### `tests/test_prefetch_service.py` (nouveau fichier)

| #   | Test                                   | Vérification                                                      |
| --- | -------------------------------------- | ----------------------------------------------------------------- |
| T1  | `test_file_cache_set_and_get`          | Écriture puis lecture d'un JSON → données identiques              |
| T2  | `test_file_cache_miss`                 | Lecture d'une clé inexistante → `None`                            |
| T3  | `test_file_cache_ttl_expired`          | Avec TTL court, attendre expiration → `None`                      |
| T4  | `test_file_cache_atomic_write`         | Vérifier qu'un fichier `.tmp` n'existe pas après écriture         |
| T5  | `test_file_cache_key_sanitization`     | Clés avec caractères spéciaux → chemin sûr                        |
| T6  | `test_prefetch_period_cache_hit`       | Données en cache → retournées sans appel service                  |
| T7  | `test_prefetch_period_cache_miss`      | Cache vide → appel services → données retournées + cachées        |
| T8  | `test_prefetch_period_service_failure` | Service échoue → retourne `None`                                  |
| T9  | `test_prefetch_period_normals_failure` | Weather OK, Normals échoue → retourne bundle avec `normals: null` |
| T10 | `test_prefetch_town_ok`                | Prefetch ville → commune + annual_climate                         |
| T11 | `test_prefetch_month_ok`               | Prefetch mois → commune + weather + normals                       |

#### Modifications dans `tests/test_api.py` (ajout de tests)

| #   | Test                                              | Vérification                                                                        |
| --- | ------------------------------------------------- | ----------------------------------------------------------------------------------- |
| T12 | `test_seo_meteo_page_contains_prefetched_data`    | Route `/meteo/{slug}/{start}/{end}` → HTML contient `<script id="prefetched-data"`  |
| T13 | `test_seo_meteo_page_without_prefetch_on_failure` | Prefetch échoue → HTML ne contient PAS `prefetched-data`                            |
| T14 | `test_seo_ville_page_contains_prefetched_data`    | Route `/ville/{slug}` → HTML contient `<script id="prefetched-data"`                |
| T15 | `test_seo_month_page_contains_prefetched_data`    | Route `/meteo/{slug}/{year}/{month}` → HTML contient `<script id="prefetched-data"` |
| T16 | `test_inject_prefetched_data_escapes_script_tag`  | JSON contenant `</script>` → échappé en `<\/script>`                                |

### Tests manuels

| #   | Scénario                                                                                                          | Vérification                                                                                                                                                                                                                                                       |
| --- | ----------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| M27 | Page simple `/meteo/gap-05/2026-03-01/2026-03-07`                                                                 | Les données s'affichent immédiatement sans état de chargement visible. Pas de spinner. Vérifier dans l'onglet Network : aucun appel à `/api/weather`.                                                                                                              |
| M28 | Même page — second chargement (cache chaud)                                                                       | Même résultat immédiat. Vérifier que le fichier JSON existe dans `data/weather_cache/`.                                                                                                                                                                            |
| M29 | Page mois `/meteo/gap-05/2026/03`                                                                                 | Données pré-chargées, affichage instantané.                                                                                                                                                                                                                        |
| M30 | Page ville `/ville/gap-05`                                                                                        | Données pré-chargées, affichage instantané.                                                                                                                                                                                                                        |
| M31 | Recherche depuis l'accueil → saisie commune + période                                                             | Flux classique : appel API visible dans Network. Spinner pendant le chargement. Résultats normaux.                                                                                                                                                                 |
| M32 | Simuler erreur API : couper le serveur Open-Meteo (ou modifier temporairement l'URL) → recherche depuis l'accueil | Message « Impossible de récupérer les données météo pour le moment. Le service météo est temporairement indisponible. Réessayez dans quelques instants. » + bouton « Réessayer ». AUCUN élément de résultat visible (pas d'onglets, pas de nav, pas de graphique). |
| M33 | Cliquer sur « Réessayer »                                                                                         | La page repasse en état de chargement (spinner). Soit le succès, soit le même message d'erreur.                                                                                                                                                                    |
| M34 | Rechercher une période non couverte (ex: avant 1940)                                                              | Message « Aucune donnée météo n'est disponible pour cette période. » SANS bouton « Réessayer ».                                                                                                                                                                    |
| M35 | Page comparaison `/comparaison/gap-05/vs/lyon-69/...`                                                             | Pas de prefetch (flux API classique). Si erreur → message + retry.                                                                                                                                                                                                 |
| M36 | Mobile (≤ 600px) — état d'erreur                                                                                  | Message lisible, bouton « Réessayer » pleine largeur. Pas d'éléments de résultat parasites.                                                                                                                                                                        |
| M37 | Page simple avec données pré-chargées + normales absentes                                                         | Page s'affiche avec données météo, section normales climatiques masquée, pas d'erreur.                                                                                                                                                                             |
| M38 | Vider `data/weather_cache/` puis recharger une page SEO                                                           | Le serveur refait l'appel API, recache, et injecte les données. Page immédiate pour l'utilisateur.                                                                                                                                                                 |
| M39 | R57 — vérifier que le bloc compact affiche toujours correctement la commune avec département                      | « Gap (05) » ou « Saint-Véran (05) » visible dans le bloc compact.                                                                                                                                                                                                 |
| M40 | Page d'accueil `/` → aucune donnée pré-chargée                                                                    | Formulaire ouvert, pas de `<script id="prefetched-data">` dans le HTML source. Comportement inchangé.                                                                                                                                                              |

### Total tests attendu

93 (hérités) + ~16 nouveaux = **~109 tests minimum**

---

## 7) Risques techniques

| #   | Risque                                                                                                                     | Probabilité | Mitigation                                                                                                                                                                                                                                                                                                                                 |
| --- | -------------------------------------------------------------------------------------------------------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| R1  | Le prefetch côté serveur ralentit le TTFB (Time to First Byte) des routes SEO à la première consultation                   | Moyenne     | Le prefetch est asynchrone et utilise le même client HTTP avec timeout 15s. En cas de timeout, le prefetch retourne `None` et la page est servie sans données pré-chargées. Le TTFB supplémentaire est au pire de 15s (timeout) — acceptable pour une première consultation. Les visites suivantes sont instantanées (cache).              |
| R2  | L'espace disque du cache fichier croît indéfiniment                                                                        | Faible      | Chaque fichier pèse ~50 KB. 10 000 pages archivées = ~500 MB. Acceptable pour un serveur auto-hébergé. Un script de nettoyage par ancienneté peut être ajouté si nécessaire. Le `.gitignore` exclut `data/weather_cache/`.                                                                                                                 |
| R3  | La modification de `showGlobalError()` / `clearGlobalError()` casse les appels existants dans d'autres parties de `app.js` | Moyenne     | La nouvelle signature `showGlobalError(message, options)` est rétro-compatible — le second paramètre est optionnel avec `{}` par défaut. Tous les appels existants continuent de fonctionner (message simple, pas de retry). Valider lors de E7 en testant tous les flux d'erreur existants (commune introuvable, période invalide, etc.). |

---

## Annexe — Structure du cache fichier

```
data/
  weather_cache/
    period_gap-05_2026-03-01_2026-03-07.json
    period_lyon-69_2026-01-15_2026-01-20.json
    month_gap-05_2026_03.json
    town_gap-05.json
    ...
```

Chaque fichier contient :

```json
{
  "_created_at": 1710345600.0,
  "data": {
    "commune": { "nom": "Gap", "codeDepartement": "05", ... },
    "weather": { "data": [...], "daily_summary": [...] },
    "normals": { ... }
  }
}
```
