# 001 — HistoMeteo MVP — Spec Technique v17

> **Source fonctionnelle** : `001-histometeo-mvp.md`
> **Base technique** : `001-histometeo-mvp.tech.v16.md`
> **Feedback intégré** : `feedback-to-architect-001-v16.md` (R50, R51)
> **Demande additionnelle** : Évolution SEO — pages Ville et pages Mois
> **Date** : 2026-03-13

---

## 0) Contract

- **Source of truth** : cette spec technique (`001-histometeo-mvp.tech.v17.md`)
- **Functional integrity** : AC1–AC11 inchangés. Critères d'acceptation additionnels AC12–AC17.
- **Scope** — fichiers modifiables :
  - `src/main.py`
  - `src/normals_service.py`
  - `src/config.py`
  - `public/app.js`
  - `public/index.html`
  - `public/style.css`
  - `tests/test_api.py`
  - `tests/test_normals_service.py`
  - `.gitignore`
  - `docs/specs/001-histometeo-mvp.tech.v17.md`
- **Forbidden changes** :
  - `src/weather_service.py`, `src/cache.py`, `src/commune_service.py`
  - `src/og_service.py`
  - `requirements.txt`, `Dockerfile`, `pyproject.toml`
  - `README.md`, `.github/`, `public/assets/`
  - `src/assets/weather-icons/`
  - Tests ≤ v16 (83 tests existants) — ne pas modifier, tous doivent continuer à passer
- **Invariants** :
  - INV-1 à INV-21 : tous préservés (cf. v16)
  - INV-7 : pas de `innerHTML` dans `app.js`
  - INV-12 : pas de JS media queries
  - INV-14 : OG server-side uniquement
  - INV-20 : un seul `<h1>` visible par page à tout moment
  - INV-21 : le H2 sémantique n'apparaît pas en mode comparaison
  - INV-22 _(nouveau)_ : chaque type de page (période, mois, ville) a un contenu distinct — pas de duplication de contenu entre eux
  - INV-23 _(nouveau)_ : les liens internes utilisent des éléments `<a>` standards avec `href` (pas de `onclick` ni navigation JS custom)
  - INV-24 _(nouveau)_ : la route `/meteo/{slug}/{year}/{month}` est déclarée **avant** la route `/meteo/{slug}/{start}/{end}` dans `main.py`
- **Done when** :
  - D1 : La route `GET /ville/{slug}` renvoie du HTML avec OG tags spécifiques
  - D2 : La route `GET /meteo/{slug}/{year}/{month}` renvoie du HTML avec OG tags spécifiques
  - D3 : Le endpoint `GET /api/normals/annual` retourne les normales climatiques pour 12 mois
  - D4 : Le frontend détecte `/ville/{slug}` et affiche la page Ville
  - D5 : Le frontend détecte `/meteo/{slug}/{year}/{month}` et affiche la page Mois
  - D6 : La page Ville affiche : infos commune, climat annuel, liste des mois, accès rapide
  - D7 : La page Mois affiche : résumé du mois, graphique, tableau des jours, anomalie climatique, liens internes
  - D8 : Les pages Période existantes contiennent des liens vers la page Ville et la page Mois correspondante
  - D9 : R51 — `__pycache__/` ajouté au `.gitignore`
  - D10 : Tous les tests passent (83 hérités + nouveaux)

---

## 1) Objectif technique

Étendre l'architecture du site avec deux nouveaux types de pages indexables :

1. **Page Ville** (`/ville/{slug}`) — hub SEO par commune centralisant les informations climatiques et les accès aux données historiques
2. **Page Mois** (`/meteo/{slug}/{year}/{month}`) — synthèse météo mensuelle avec données jour par jour

Ces pages créent une hiérarchie SEO à 3 niveaux : Ville → Mois → Période, reliée par un maillage interne systématique.

Parallèlement, intégrer les recommandations R50 (signalée, hors scope) et R51 (`.gitignore`) du feedback v16.

---

## 2) Analyse du brief

### Besoins principaux

| #   | Besoin                                       | Origine                |
| --- | -------------------------------------------- | ---------------------- |
| B1  | Page hub Ville pour chaque commune           | Brief SEO §2           |
| B2  | Page synthèse Mois par ville                 | Brief SEO §3           |
| B3  | Maillage interne entre les 3 types de pages  | Brief SEO §4           |
| B4  | Architecture URL hiérarchique et claire      | Brief SEO §1           |
| B5  | Contenu unique par page (pas de duplication) | Brief SEO §6           |
| B6  | Normales climatiques annuelles (12 mois)     | Requis par B1 (climat) |
| B7  | `.gitignore` pour `__pycache__/`             | Feedback R51           |

### Contraintes

- Les pages Mois réutilisent au maximum l'infrastructure existante (graphique, résumé journalier, anomalie climatique) — un mois ≤ 31 jours rentre dans `MAX_PERIOD_DAYS`
- Les pages Ville ne font pas d'appel à l'API météo (pas de période spécifique) — uniquement des normales climatiques
- La route `/meteo/{slug}/{year}/{month}` doit être distinguable de la route existante `/meteo/{slug}/{start}/{end}` — utiliser les convertisseurs de type FastAPI (`{year:int}/{month:int}` vs `{start:str}/{end:str}`)
- La navigation inter-pages utilise des liens `<a>` standards (pas de SPA transition) — le rechargement passe par `loadFromURL()`
- L'API Open-Meteo ne fournit pas de données futures — les pages Mois pour le mois courant montrent les données disponibles jusqu'à hier

### Risques

Voir §7.

---

## 3) Design minimal proposé

### 3.1 Nouvelles routes backend (`src/main.py`)

#### 3.1.1 Route page Mois

```python
@app.get("/meteo/{slug_dept}/{year:int}/{month:int}")
async def seo_month_page(
    request: Request,
    slug_dept: str,
    year: int,
    month: int,
) -> Response:
```

**Positionnement** : déclarée **avant** `seo_meteo_page` (route période). FastAPI/Starlette essaie les routes dans l'ordre de déclaration. Le convertisseur `{year:int}` échoue sur `"2026-03-06"` (pas un entier), donc la requête tombe sur la route période — discrimination correcte.

**Validation** :

- `month` : 1–12
- `year` : 1940–année courante
- combinaison `year/month` : ne doit pas être dans le futur (mois courant autorisé)
- Si invalide → retourne `index.html` brut (pas de 404 — le frontend affichera un message)

**OG tags injectés** :

| Tag              | Valeur                                                                                                                                  |
| ---------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
| `og:title`       | `Météo à {commune} en {mois_fr} {année}`                                                                                                |
| `og:description` | `Historique météo à {commune} pour le mois de {mois_fr} {année}\u00a0: températures, précipitations et conditions météo jour par jour.` |
| `og:image`       | `/api/og-image/{slug}/{first_day}/{last_day}` (réutilise l'endpoint existant)                                                           |
| `og:url`         | `{base_url}/meteo/{slug}/{year}/{month:02d}`                                                                                            |
| `canonical`      | `/meteo/{slug}/{year}/{month:02d}`                                                                                                      |

**Calcul des bornes du mois** :

```python
from calendar import monthrange

first_day = date(year, month, 1)
_, num_days = monthrange(year, month)
last_day = date(year, month, num_days)

# Si mois courant, limiter à hier
yesterday = max_available_date()
if last_day > yesterday:
    last_day = yesterday
```

**Noms de mois français** : ajouter `MONTH_NAMES_FR` dans `config.py` :

```python
MONTH_NAMES_FR = [
    "", "janvier", "février", "mars", "avril", "mai", "juin",
    "juillet", "août", "septembre", "octobre", "novembre", "décembre",
]
```

> Note : la même liste existe déjà dans `normals_service.py` (`_compute_month_normals`). La déplacer dans `config.py` pour partager. `normals_service.py` importera depuis `config.py`.

#### 3.1.2 Route page Ville

```python
@app.get("/ville/{slug_dept}")
async def seo_ville_page(
    request: Request,
    slug_dept: str,
) -> Response:
```

**OG tags injectés** :

| Tag              | Valeur                                                                                                                                                          |
| ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `og:title`       | `Météo et climat à {commune}`                                                                                                                                   |
| `og:description` | `Consultez l'historique météo et le climat à {commune}. Retrouvez les températures, précipitations et conditions météo passées pour chaque période de l'année.` |
| `og:image`       | Image par défaut du site (pas d'image OG dynamique — pas de période météo)                                                                                      |
| `og:url`         | `{base_url}/ville/{slug}`                                                                                                                                       |
| `canonical`      | `/ville/{slug}`                                                                                                                                                 |

**Pas de données météo côté serveur** — le contenu est généré côté client via l'API `normals/annual`.

#### 3.1.3 Endpoint `/api/normals/annual`

```python
@app.get("/api/normals/annual")
async def get_annual_normals(
    lat: float | None = None,
    lon: float | None = None,
) -> JSONResponse:
```

**Retour** :

```json
{
  "elevation": 2042.0,
  "reference_period": "1991-2020",
  "annual_avg_temp": 5.2,
  "annual_precipitation": 842.0,
  "months": [
    {
      "month": 1,
      "month_name": "janvier",
      "temp_avg": -2.1,
      "temp_max_avg": 1.4,
      "temp_min_avg": -5.6,
      "precipitation_total": 62.3
    },
    ...
  ]
}
```

**Implémentation** : nouvelle méthode `NormalsService.get_annual_normals(lat, lon)` qui :

1. Réutilise le cache interne existant (`cache_key = "normals:{lat:.2f}:{lon:.2f}"`) — les normales 1991-2020 sont déjà cachées par coordonnée après le premier appel `get_normals()`
2. Si pas en cache, appelle `_fetch_reference_normals()` (même logique que `get_normals`)
3. Extrait les normales pour chacun des 12 mois via `_compute_month_normals()` existant
4. Calcule les moyennes annuelles à partir des 12 mois
5. Retourne la structure JSON ci-dessus

```python
async def get_annual_normals(
    self, latitude: float, longitude: float
) -> dict[str, Any] | None:
    self._validate_coordinates(latitude, longitude)

    cache_key = f"normals:{latitude:.2f}:{longitude:.2f}"
    cached = self.cache.get(cache_key)
    if cached is None:
        cached = await self._fetch_reference_normals(latitude, longitude)
        self.cache.set(cache_key, cached)

    elevation = cached.get("elevation")
    days = cached.get("days") or {}

    months = []
    for m in range(1, 13):
        # Réutilise _compute_month_normals avec un start fictif
        month_data = self._compute_month_normals(days, f"2020-{m:02d}-01")
        if month_data:
            months.append(month_data)

    if not months:
        return None

    # Moyennes annuelles
    temp_values = [m["temp_avg"] for m in months if m.get("temp_avg") is not None]
    precip_values = [m["precipitation_total"] for m in months if m.get("precipitation_total") is not None]

    return {
        "elevation": elevation,
        "reference_period": NORMALS_REFERENCE_PERIOD,
        "annual_avg_temp": self._average(temp_values),
        "annual_precipitation": round(sum(precip_values), 1) if precip_values else None,
        "months": months,
    }
```

**Cache** : aucune clé de cache supplémentaire — la méthode réutilise le cache `normals:` existant qui stocke déjà les 365 jours de normales. Le surcoût est nul après un premier appel pour la même coordonnée.

### 3.2 Modification de `src/config.py`

Ajouts :

```python
MONTH_NAMES_FR = [
    "", "janvier", "février", "mars", "avril", "mai", "juin",
    "juillet", "août", "septembre", "octobre", "novembre", "décembre",
]
```

### 3.3 Modification de `src/normals_service.py`

1. Importer `MONTH_NAMES_FR` depuis `config.py`
2. Remplacer la liste `month_names_fr` locale dans `_compute_month_normals()` par `MONTH_NAMES_FR`
3. Ajouter la méthode `get_annual_normals()` (cf. §3.1.3)

### 3.4 Frontend — Détection des URL (`public/app.js`)

#### 3.4.1 Extension de `parseSeoPath()`

La fonction `parseSeoPath()` actuelle gère :

- `/meteo/{slug}/{start}/{end}` → `{mode: "simple", slug, start, end}`
- `/comparaison/{slug1}/vs/{slug2}/{start}/{end}` → `{mode: "comparison", ...}`

Ajouter la détection de :

- `/ville/{slug}` → `{mode: "town", slug}`
- `/meteo/{slug}/{year}/{month}` → `{mode: "month", slug, year, month}`

**Discrimination mois vs période** : le 2e segment après le slug est un entier de 4 chiffres (année) pour le mois, vs un format ISO `YYYY-MM-DD` pour la période.

```javascript
function parseSeoPath() {
  const path = window.location.pathname;

  // Page Ville : /ville/{slug}
  const villeMatch = path.match(/^\/ville\/([^/]+)$/);
  if (villeMatch) {
    return { mode: "town", slug: villeMatch[1] };
  }

  // Page Mois : /meteo/{slug}/{year}/{month}
  const monthMatch = path.match(/^\/meteo\/([^/]+)\/(\d{4})\/(\d{1,2})$/);
  if (monthMatch) {
    const year = parseInt(monthMatch[2], 10);
    const month = parseInt(monthMatch[3], 10);
    if (month >= 1 && month <= 12) {
      return { mode: "month", slug: monthMatch[1], year, month };
    }
  }

  // Page Période (existant) : /meteo/{slug}/{start}/{end}
  // ...code existant inchangé...
}
```

> Le test du mois est placé **avant** le test de la période (même logique que côté serveur). La regex `\d{4}` sans tiret ne matche pas `2026-03-06`.

#### 3.4.2 Extension de `loadFromURL()`

Après `parseSeoPath()`, ajouter le traitement des nouveaux modes :

```javascript
const parsed = parseSeoPath();
if (parsed) {
  switch (parsed.mode) {
    case "town":
      await loadTownPage(parsed.slug);
      return;
    case "month":
      await loadMonthPage(parsed.slug, parsed.year, parsed.month);
      return;
    case "simple":
      // ...code existant...
      break;
    case "comparison":
      // ...code existant...
      break;
  }
}
```

### 3.5 Frontend — Page Mois (`public/app.js`)

#### 3.5.1 Fonction `loadMonthPage(slug, year, month)`

Flux :

1. Résoudre le slug via `/api/resolve/{slug}` (existant)
2. Calculer les bornes du mois :
   ```javascript
   const firstDay = `${year}-${String(month).padStart(2, "0")}-01`;
   const lastDayDate = new Date(year, month, 0); // dernier jour du mois
   let lastDay = formatISODate(lastDayDate);
   // Si mois courant, limiter à hier
   const yesterday = formatISODate(new Date(Date.now() - 86400000));
   if (lastDay > yesterday) lastDay = yesterday;
   ```
3. Fetch météo via `/api/weather?lat=...&lon=...&start=${firstDay}&end=${lastDay}` (existant)
4. Fetch normales via `/api/normals?lat=...&lon=...&start=${firstDay}&end=${lastDay}` (existant)
5. Appeler `renderMonthPage(commune, year, month, weatherData, normalsData)`

#### 3.5.2 Fonction `renderMonthPage(commune, year, month, weatherData, normalsData)`

1. `clearResults()`
2. Mettre à jour le H1 : `Météo à {commune} en {monthName} {year}`
3. Afficher l'introduction (section `#seo-intro`) :
   - `<p>` : `Voici l'historique météo à {commune} pour le mois de {monthName} {year}\u00a0: températures, précipitations et conditions météo jour par jour.`
   - `<h2>` : `Historique météo à {commune} en {monthName} {year}`
4. Afficher le résumé du mois (section `#month-summary`, nouveau)
5. Afficher le graphique (réutiliser `renderChart()` existant avec les données du mois)
6. Afficher le tableau des jours (réutiliser `renderDailySummary()` existant — enrichi avec des liens vers chaque jour)
7. Afficher l'anomalie climatique (réutiliser `renderClimateNormals()` existant)
8. Afficher les liens internes (section `#internal-links`, nouveau)
9. Afficher les infos commune (réutiliser `renderCommuneInfo()` existant)

**Résumé du mois** (`#month-summary`) — contenu distinct du résumé de période :

| Donnée                      | Calcul                                                  |
| --------------------------- | ------------------------------------------------------- |
| Température moyenne du mois | Moyenne des `temp_max` et `temp_min` du `daily_summary` |
| Température minimale        | Min absolue des `temp_min` du `daily_summary`           |
| Température maximale        | Max absolue des `temp_max` du `daily_summary`           |
| Cumul des précipitations    | Somme des `precipitation_sum` du `daily_summary`        |

Style : une grille de 4 cartes KPI (comme les cards d'anomalie existantes).

**Enrichissement du tableau journalier** : chaque ligne du `daily_summary` doit afficher un lien `<a>` sur la date pointant vers `/meteo/{slug}/{YYYY-MM-DD}/{YYYY-MM-DD}` (page période jour unique).

**Liens internes** (`#internal-links`) : affichés en bas de page :

```
Voir l'historique météo à {commune} → /ville/{slug}
```

### 3.6 Frontend — Page Ville (`public/app.js`)

#### 3.6.1 Fonction `loadTownPage(slug)`

Flux :

1. Résoudre le slug via `/api/resolve/{slug}`
2. Fetch normales annuelles via `/api/normals/annual?lat=...&lon=...`
3. Appeler `renderTownPage(commune, annualNormals)`

#### 3.6.2 Fonction `renderTownPage(commune, annualNormals)`

1. `clearResults()`
2. Mettre à jour le H1 : `Météo et climat à {commune}`
3. Afficher l'introduction (section `#seo-intro`) :
   - `<p>` : `Consultez l'historique météo et le climat à {commune}. Retrouvez les températures, précipitations et conditions météo passées pour chaque période de l'année.`
   - `<h2>` : `Climat et historique météo à {commune}`
4. Afficher les infos commune (réutiliser `renderCommuneInfo()`)
5. Afficher le climat annuel (section `#annual-climate`, nouveau)
6. Afficher la liste des mois (section `#month-links`, nouveau)
7. Afficher les accès rapides (section `#quick-periods`, nouveau)

**Climat annuel** (`#annual-climate`) :

- Température moyenne annuelle
- Précipitations annuelles
- Tableau des 12 mois avec : nom du mois, temp moyenne, temp max moyenne, temp min moyenne, précipitations

Style : tableau responsive reprenant le style de `#daily-summary`.

**Liste des mois** (`#month-links`) :

- Affiche l'année courante par défaut
- Navigation année précédente / année suivante (boutons `<` / `>`)
- 12 cartes (une par mois), affichant : nom du mois, temp moyenne (si normales disponibles)
- Les mois passés sont des liens `<a href="/meteo/{slug}/{year}/{month:02d}">` cliquables
- Le mois courant est un lien (données partielles disponibles)
- Les mois futurs sont grisés (pas de `href`)
- Plage : de l'année courante à `MIN_HISTORICAL_DATE.year` (1940)

**Accès rapide** (`#quick-periods`) :

Liens contextuels calculés à partir de `new Date()` :

| Label                        | Lien                                         |
| ---------------------------- | -------------------------------------------- |
| Météo la semaine dernière    | `/meteo/{slug}/{lundi-1sem}/{dimanche-1sem}` |
| Météo il y a 1 an (ce jour)  | `/meteo/{slug}/{date-1an}/{date-1an}`        |
| Météo il y a 5 ans (ce jour) | `/meteo/{slug}/{date-5ans}/{date-5ans}`      |

Chaque entrée est un lien `<a>` standard.

#### 3.6.3 Pré-remplissage du formulaire de recherche

Lorsque la page Ville se charge, le champ commune est pré-rempli avec les données résolues (même logique que pour les pages période). Les champs de dates restent vides — l'utilisateur peut lancer une recherche libre.

### 3.7 Frontend — Maillage interne depuis les pages Période (`public/app.js`)

Dans `renderSimpleResults()`, après le rendering existant, ajouter un bloc de liens internes (section `#internal-links`) :

```
Voir l'historique météo à {commune} → /ville/{slug}
Voir la météo à {commune} en {monthName} {year} → /meteo/{slug}/{year}/{month:02d}
```

Le mois est extrait de la date de début de la période.

**Mode comparaison** : la section `#internal-links` reste `hidden` (INV-21 étendu — les liens SEO internes sont pertinents uniquement pour le mode simple).

### 3.8 HTML — Nouvelles sections (`public/index.html`)

Ajouter les sections suivantes dans le `<main>`, avant `<!-- DATA TRANSPARENCY -->` :

```html
<!-- MONTH SUMMARY (page Mois uniquement) -->
<section id="month-summary" class="panel hidden" aria-live="polite">
  <h2>Résumé du mois</h2>
  <div id="month-summary-body" class="kpi-grid"></div>
</section>

<!-- ANNUAL CLIMATE (page Ville uniquement) -->
<section id="annual-climate" class="panel hidden" aria-live="polite">
  <h2>Climat habituel</h2>
  <div id="annual-climate-body"></div>
</section>

<!-- MONTH LINKS (page Ville uniquement) -->
<section id="month-links" class="panel hidden" aria-live="polite">
  <h2>Historique par mois</h2>
  <div id="month-links-header" class="month-links-nav"></div>
  <div id="month-links-body" class="month-links-grid"></div>
</section>

<!-- QUICK PERIODS (page Ville uniquement) -->
<section id="quick-periods" class="panel hidden" aria-live="polite">
  <h2>Accès rapide</h2>
  <div id="quick-periods-body"></div>
</section>

<!-- INTERNAL LINKS (toutes pages sauf comparaison) -->
<section id="internal-links" class="panel hidden">
  <nav
    id="internal-links-body"
    class="internal-links"
    aria-label="Liens internes"
  ></nav>
</section>
```

**Mise à jour de `clearResults()`** : ajouter le masquage de ces 5 nouvelles sections et le vidage de leurs contenus.

### 3.9 CSS — Nouveaux styles (`public/style.css`)

#### KPI Grid (résumé du mois)

```css
.kpi-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
  gap: var(--space-sm);
}

.kpi-card {
  background: var(--card-bg);
  border-radius: var(--radius);
  padding: var(--space-sm);
  text-align: center;
}

.kpi-card .kpi-value {
  font-size: 1.5rem;
  font-weight: 700;
  color: var(--primary);
}

.kpi-card .kpi-label {
  font-size: 0.85rem;
  color: var(--text-muted);
  margin-top: 0.25rem;
}
```

#### Month Links Grid (page Ville)

```css
.month-links-nav {
  display: flex;
  justify-content: center;
  align-items: center;
  gap: var(--space-sm);
  margin-bottom: var(--space-md);
}

.month-links-nav button {
  background: var(--card-bg);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  padding: 0.4rem 0.8rem;
  cursor: pointer;
  font-size: 0.9rem;
}

.month-links-nav .year-label {
  font-size: 1.1rem;
  font-weight: 600;
}

.month-links-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
  gap: var(--space-sm);
}

.month-card {
  display: block;
  background: var(--card-bg);
  border-radius: var(--radius);
  padding: var(--space-sm);
  text-align: center;
  text-decoration: none;
  color: var(--text);
  border: 1px solid var(--border);
  transition: border-color 0.2s;
}

.month-card:hover {
  border-color: var(--primary);
}

.month-card.disabled {
  opacity: 0.4;
  pointer-events: none;
}

.month-card .month-name {
  font-weight: 600;
  font-size: 0.95rem;
}

.month-card .month-temp {
  font-size: 0.8rem;
  color: var(--text-muted);
  margin-top: 0.2rem;
}
```

#### Internal Links

```css
.internal-links {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-sm);
  justify-content: center;
}

.internal-links a {
  display: inline-block;
  padding: 0.5rem 1rem;
  background: var(--card-bg);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  text-decoration: none;
  color: var(--primary);
  font-size: 0.9rem;
  transition: background 0.2s;
}

.internal-links a:hover {
  background: var(--primary);
  color: #fff;
}
```

#### Quick Periods

```css
#quick-periods-body {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-sm);
  justify-content: center;
}

#quick-periods-body a {
  display: inline-block;
  padding: 0.5rem 1rem;
  background: var(--card-bg);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  text-decoration: none;
  color: var(--text);
  font-size: 0.9rem;
}

#quick-periods-body a:hover {
  border-color: var(--primary);
}
```

### 3.10 Enrichissement du tableau journalier — liens par jour

Dans `renderDailySummary()` (existant), modifier la cellule date pour qu'elle contienne un `<a>` :

```javascript
// AVANT
dateCell.textContent = formattedDate;

// APRÈS — en mode Mois uniquement
if (currentPageMode === "month") {
  const dayLink = document.createElement("a");
  dayLink.href = `/meteo/${currentSlug}/${isoDate}/${isoDate}`;
  dayLink.textContent = formattedDate;
  dateCell.appendChild(dayLink);
} else {
  dateCell.textContent = formattedDate;
}
```

**Variable de contexte** : introduire une variable module-level `currentPageMode` (valeurs : `"search"`, `"simple"`, `"comparison"`, `"town"`, `"month"`) pour conditionner le comportement des fonctions réutilisées.

### 3.11 Intégration R51 — `.gitignore`

Ajouter `__pycache__/` au `.gitignore` s'il n'y est pas déjà :

```
__pycache__/
```

### 3.12 R50 — Tests E2E (signalée, hors scope)

R50 (Playwright E2E) est une recommandation pertinente mais hors scope de cette itération. Elle est reportée à une itération dédiée au test frontend.

---

## 4) Plan d'implémentation

| Étape | Description                                                                                                                                                                                         | Fichiers                                                | Testable                                                          |
| ----- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- | ----------------------------------------------------------------- |
| E1    | `config.py` : ajouter `MONTH_NAMES_FR`. `normals_service.py` : ajouter `get_annual_normals()`, refactoriser `month_names_fr` local → import `MONTH_NAMES_FR`. `.gitignore` : ajouter `__pycache__/` | `src/config.py`, `src/normals_service.py`, `.gitignore` | `pytest test_normals_service.py` — tests existants + nouveau test |
| E2    | `main.py` : ajouter routes `/ville/{slug}`, `/meteo/{slug}/{year}/{month}`, endpoint `/api/normals/annual`                                                                                          | `src/main.py`                                           | `pytest test_api.py` — nouveaux tests pour les 3 routes           |
| E3    | `index.html` : ajouter sections `#month-summary`, `#annual-climate`, `#month-links`, `#quick-periods`, `#internal-links`                                                                            | `public/index.html`                                     | Visuel — toutes sections `hidden` par défaut                      |
| E4    | `style.css` : ajouter styles KPI grid, month links grid, internal links, quick periods                                                                                                              | `public/style.css`                                      | Visuel                                                            |
| E5    | `app.js` : étendre `parseSeoPath()`, `loadFromURL()`, ajouter `loadMonthPage()`, `renderMonthPage()`, variable `currentPageMode`, enrichir `renderDailySummary()` avec liens                        | `public/app.js`                                         | Fonctionnel — `/meteo/{slug}/2026/03` doit afficher la page Mois  |
| E6    | `app.js` : ajouter `loadTownPage()`, `renderTownPage()`, `renderAnnualClimate()`, `renderMonthLinks()`, `renderQuickPeriods()`, maillage depuis `renderSimpleResults()`, extension `clearResults()` | `public/app.js`                                         | Fonctionnel — `/ville/{slug}` doit afficher la page Ville         |
| E7    | Tests : `test_normals_service.py` (test annual), `test_api.py` (test routes + endpoint). Valider tous les tests (83 hérités + nouveaux)                                                             | `tests/test_api.py`, `tests/test_normals_service.py`    | `pytest` — tous les tests passent                                 |

---

## 5) Guide pour le Développeur

### Pièges fréquents

1. **Ordre des routes FastAPI** — La route `/meteo/{slug}/{year:int}/{month:int}` DOIT être déclarée avant `/meteo/{slug}/{start}/{end}`. Si l'ordre est inversé, les URL mois seront capturées par la route période avec `start="2026"` et `end="03"` → échec de validation. Tester systématiquement les deux types d'URL après implémentation.

2. **Mois courant = données partielles** — Le dernier jour disponible est `yesterday` (cf. `max_available_date()`). Si `year/month` correspond au mois en cours, les bornes du mois doivent être `first_day → yesterday`. Ne pas envoyer une date future à l'API météo.

3. **Cache normales déjà chaud** — `get_annual_normals()` réutilise le même `cache_key` que `get_normals()`. Si un utilisateur a déjà consulté une page période pour la même coordonnée, les normales 1991-2020 sont déjà en cache. Ne pas créer un cache séparé.

4. **Variable `currentPageMode`** — Cette variable doit être mise à jour dans `loadFromURL()` AVANT tout rendering. `clearResults()` ne la réinitialise pas (elle est mise à jour par le prochain load). Les fonctions réutilisées (`renderDailySummary`, `renderChart`, etc.) peuvent la consulter pour adapter leur comportement.

5. **Liens `<a>` et navigation** — Les liens internes (maillage) sont des `<a>` standards avec `href`. Au clic, le navigateur recharge la page. `loadFromURL()` sur la nouvelle page détecte le pattern et affiche le bon contenu. Ne pas intercepter les clics ni ajouter de gestion `pushState` ou `replaceState` pour ces liens.

6. **`MONTH_NAMES_FR` partagé** — Après le déplacement dans `config.py`, vérifier que `normals_service.py` importe `MONTH_NAMES_FR` depuis `config.py` et ne redéfinit plus la liste locale. Les tests existants de `test_normals_service.py` doivent continuer à passer sans modification.

### Zones de dérive

- Ne pas générer de sitemap.xml dans cette itération (évolution futur séparée).
- Ne pas créer d'images OG spécifiques pour les pages Ville (utiliser l'image par défaut).
- Ne pas ajouter de pagination sur la liste des mois de la page Ville (la navigation année par année suffit).
- Ne pas transformer le SPA en multi-page app — les liens entre types de pages provoquent un rechargement complet, c'est intentionnel.

### Simplifications autorisées

- La page Mois en mode mois courant peut ne pas afficher le graphique si le mois n'a qu'1 ou 2 jours de données (passage au-dessus proprement).
- Les accès rapides « il y a 1 an / 5 ans » peuvent ne pas vérifier que la date tombe un jour ouvrable ou un jour spécifique — ils pointent vers le jour exact calculé.

### Décisions explicitement interdites

- Ne pas créer de nouvelle base de données ni de système de génération statique.
- Ne pas modifier `weather_service.py`, `commune_service.py`, `og_service.py`, `cache.py`.
- Ne pas ajouter de dépendance dans `requirements.txt`.
- Ne pas utiliser `innerHTML` (INV-7).
- Ne pas modifier les 83 tests existants.

---

## 6) Stratégie de tests

### Tests existants à préserver

Les 83 tests existants doivent tous passer sans modification.

### Nouveaux tests backend

| ID   | Test                                      | Fichier                         | Vérification                                                                                                                            |
| ---- | ----------------------------------------- | ------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
| T-23 | `test_annual_normals_returns_12_months`   | `tests/test_normals_service.py` | `get_annual_normals(lat, lon)` retourne 12 entrées dans `months`, chacune avec `month`, `month_name`, `temp_avg`, `precipitation_total` |
| T-24 | `test_annual_normals_reuses_cache`        | `tests/test_normals_service.py` | Après un `get_normals()`, `get_annual_normals()` sur les mêmes coordonnées ne fait pas d'appel HTTP                                     |
| T-25 | `test_annual_normals_invalid_coordinates` | `tests/test_normals_service.py` | Coordonnées hors bornes → `NormalsValidationError`                                                                                      |
| T-26 | `test_api_annual_normals_endpoint`        | `tests/test_api.py`             | `GET /api/normals/annual?lat=48.85&lon=2.35` → 200, JSON contient `months` (12 items)                                                   |
| T-27 | `test_api_annual_normals_missing_params`  | `tests/test_api.py`             | `GET /api/normals/annual` sans paramètres → 400                                                                                         |
| T-28 | `test_seo_ville_page_og_tags`             | `tests/test_api.py`             | `GET /ville/paris-75` → 200, HTML contient `og:title` = `Météo et climat à Paris`                                                       |
| T-29 | `test_seo_month_page_og_tags`             | `tests/test_api.py`             | `GET /meteo/paris-75/2026/03` → 200, HTML contient `og:title` = `Météo à Paris en mars 2026`                                            |
| T-30 | `test_seo_month_page_invalid_month`       | `tests/test_api.py`             | `GET /meteo/paris-75/2026/13` → retourne HTML brut (pas d'OG spécifiques)                                                               |
| T-31 | `test_seo_month_page_future`              | `tests/test_api.py`             | `GET /meteo/paris-75/2030/06` → retourne HTML brut (pas d'OG spécifiques)                                                               |
| T-32 | `test_seo_period_route_still_works`       | `tests/test_api.py`             | `GET /meteo/paris-75/2026-03-01/2026-03-07` → 200, OG tags période (vérifie non-régression de l'ordre des routes)                       |

### Tests manuels recommandés

| #   | Scénario                                         | Vérification                                                                                        |
| --- | ------------------------------------------------ | --------------------------------------------------------------------------------------------------- |
| M8  | Page Ville (Saint-Véran)                         | H1, intro, infos commune, climat annuel, liste 12 mois, accès rapide affichés                       |
| M9  | Page Mois (Saint-Véran, mars 2026)               | H1, intro, résumé KPI, graphique, tableau jours avec liens, anomalie, lien vers page Ville affichés |
| M10 | Clic sur un jour dans la page Mois               | Navigation vers `/meteo/{slug}/{date}/{date}` — page période affichée                               |
| M11 | Clic « voir historique » depuis une page Période | Navigation vers `/ville/{slug}` — page Ville affichée                                               |
| M12 | Navigation année sur la page Ville               | Les boutons `<` / `>` changent l'année affichée, mois futurs grisés                                 |
| M13 | Page Mois mois courant                           | Données jusqu'à hier, pas de jours futurs dans le tableau                                           |
| M14 | Page Ville — accès rapide « il y a 1 an »        | Lien correct vers `/meteo/{slug}/{date-1an}/{date-1an}`                                             |
| M15 | OG preview (opengraph.xyz) pour page Mois        | `og:title` = « Météo à {commune} en mars 2026 », image correcte                                     |

### Total tests attendu

83 (hérités) + 10 (nouveaux T-23 à T-32) = **93 tests minimum**

---

## 7) Risques techniques

| #   | Risque                                                                                                                       | Probabilité | Mitigation                                                                                                                                                                                                                                                                                                           |
| --- | ---------------------------------------------------------------------------------------------------------------------------- | ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| R1  | L'ordre des routes FastAPI ne discrimine pas correctement mois vs période — les URL mois sont capturées par la route période | Moyenne     | Le convertisseur `{year:int}` rejette les chaînes avec tirets (`2026-03-06`). Tester les deux types d'URL dans T-29 et T-32. Si Starlette se comporte différemment que prévu, ajouter une validation regex explicite dans chaque route pour lever l'ambiguïté.                                                       |
| R2  | La page Mois pour le mois courant affiche un graphique incomplet ou vide si le mois vient de commencer (1-2 jours)           | Faible      | Le graphique existant gère déjà les courtes périodes. Ajouter un test M13. Si le rendu est dégradé avec 1 point de données, ajouter un seuil minimal (ex. ≥ 3 jours) en-dessous duquel le graphique n'est pas affiché.                                                                                               |
| R3  | La taille du code `app.js` augmente significativement (~300-500 lignes) → risque de maintenabilité                           | Faible      | Les nouvelles fonctions sont bien isolées (`loadTownPage`, `renderTownPage`, `loadMonthPage`, `renderMonthPage`). Elles réutilisent les fonctions de rendering existantes. Pas besoin de refactoring structurel pour cette itération, mais envisager un découpage par module (ES modules) dans une itération future. |
