# 001 — HistoMétéo MVP — Spécification Technique v10

> **Itération** : v10 — intègre le feedback Reviewer (`feedback-to-architect-001-v9.md`) + nouvelles demandes « Améliorations UX / SEO / Produit ».

---

## 0) Contract

- **Source of truth** : ce document (`001-histometeo-mvp.tech.v10.md`)
- **Functional integrity** : aucun critère d'acceptation de `001-histometeo-mvp.md` ne peut être modifié, ignoré ou réinterprété.
- **Scope** : fichiers et dossiers autorisés à créer/modifier :
  - `public/app.js` — H1 dynamique, question compacte, résumé enrichi, contexte saisonnier, synthèse anomalie, tabs actifs, bloc climat mensuel, correction R29 (anomalies), date compacte, `document.title` dynamique
  - `public/index.html` — H1 dynamique, réorganisation DOM (C1), nouvelles sections (`#seasonal-context`, `#climate-month`)
  - `public/style.css` — H1 résultats, tab active state, bloc climat mensuel, contexte saisonnier
  - `src/normals_service.py` — ajout `month_normals` dans la réponse
  - `src/main.py` — aucune modification de route, la réponse `/api/normals` transmet le nouveau champ `month_normals`
  - `tests/test_normals_service.py` — tests `month_normals`, R32 (29 février), R33 (`get_normals` → `None`)
  - `tests/test_api.py` — test `month_normals` dans la réponse API
- **Forbidden changes** :
  - `src/weather_service.py` — aucune modification
  - `src/cache.py` — aucune modification
  - `src/commune_service.py` — aucune modification
  - `src/config.py` — aucune modification
  - `docs/` — aucune modification des specs existantes (hors ce fichier)
  - `.github/` — aucune modification
  - `Dockerfile`, `pyproject.toml` — pas de modification
  - `public/assets/` — pas de modification
- **Invariants** (tous hérités et préservés) :
  - INV-1 : Aucune donnée utilisateur stockée (ni serveur, ni client)
  - INV-2 : Heures en fuseau `Europe/Paris`
  - INV-3 : Période maximale 31 jours
  - INV-4 : Aucune clé API
  - INV-5 : Interface en français avec accents (`HistoMétéo`)
  - INV-6b : Page unique, URL reflète l'état via des routes propres (path segments)
  - INV-7 : Aucune injection HTML — `textContent`, `createElement`, `replaceChildren()` uniquement, jamais `innerHTML`
  - INV-8 : Le flux recherche simple fonctionne indépendamment du mode comparaison
  - INV-9 : Les normales climatiques sont un enrichissement progressif — leur indisponibilité ne bloque pas l'affichage des données météo ni la navigation
  - INV-10 : Les informations commune sont un enrichissement progressif — leur absence partielle n'empêche pas l'affichage du bloc
  - **INV-11** (nouveau) : Le texte contextuel saisonnier et le bloc climat mensuel sont des enrichissements progressifs liés aux normales — leur absence (échec normales) ne dégrade rien
- **Done when** :
  - Les 11 critères d'acceptation originaux (AC1–AC11) restent vérifiables
  - Toutes les fonctionnalités v2–v9 restent opérationnelles (URLs SEO, redirections 301, canonical, comparaison, normales, enrichissement commune)
  - Le H1 visible affiche « Météo à {ville} du {dates compactes} » après une recherche
  - `document.title` est mis à jour dynamiquement : « Météo à {ville} du {dates} — HistoMétéo »
  - La question SEO utilise le format compact (sans département, dates courtes)
  - Le résumé de période inclut les dates et le nom de ville
  - Un texte contextuel saisonnier apparaît après le résumé (si normales disponibles)
  - Le bloc anomalie climatique contient une phrase de synthèse sous le tableau
  - Les onglets de navigation ont un état actif visible
  - Un bloc « Climat habituel à {ville} en {mois} » s'affiche avec les moyennes mensuelles
  - `#commune-info` est positionné après les résultats, avant `#period-links` (correction C1)
  - Les anomalies comparent des moyennes vs. moyennes (correction R29)
  - `/api/normals` inclut un champ `month_normals` dans la réponse
  - Tous les tests backend passent (anciens + nouveaux)

---

## 1) Objectif technique

Améliorer la lisibilité, la valeur SEO et la qualité perçue des pages de résultats sans modifier l'architecture backend existante. Les changements sont principalement frontend (titrage, formatage, enrichissement textuel, navigation) avec une extension mineure du service des normales pour exposer les moyennes mensuelles.

Corriger les points relevés par le feedback v9 : positionnement DOM de `#commune-info` (C1), incohérence sémantique des anomalies (R29), tests edge cases manquants (R32, R33).

---

## 2) Analyse du brief

### Besoins principaux

| #   | Besoin                                       | Source          | Complexité | Impact             |
| --- | -------------------------------------------- | --------------- | ---------- | ------------------ |
| D1  | H1 dynamique SEO (+ `document.title`)        | Demande UX/SEO  | Faible     | SEO majeur         |
| D2  | Question compacte (sans dept, dates courtes) | Demande UX/SEO  | Faible     | SEO + lisibilité   |
| D3  | Résumé enrichi (avec dates et ville)         | Demande UX/SEO  | Faible     | SEO + contenu      |
| D4  | Texte contextuel saisonnier                  | Demande SEO     | Moyenne    | SEO (thin content) |
| D5  | Synthèse anomalie (phrase sous tableau)      | Demande UX      | Faible     | Compréhension      |
| D6  | Onglets actifs                               | Demande UX      | Faible     | Navigation         |
| D7  | Bloc climat mensuel                          | Demande Produit | Moyenne    | SEO + contenu      |
| C1  | Repositionnement `#commune-info`             | Feedback v9     | Faible     | UX cohérence       |
| R29 | Correction anomalies (moyennes vs moyennes)  | Feedback v9     | Faible     | Exactitude         |
| R32 | Test 29 février normales                     | Feedback v9     | Faible     | Couverture test    |
| R33 | Test `get_normals` → `None`                  | Feedback v9     | Faible     | Couverture test    |

### Contraintes

- **Aucune nouvelle dépendance** — tout se fait avec les APIs et services existants.
- **Les normales sont déjà cachées pour l'année complète** — les données mensuelles sont un sous-ensemble du cache existant, sans coût réseau additionnel.
- **INV-7 absolu** — tous les nouveaux rendus HTML utilisent `createElement` / `textContent` / `replaceChildren()`.
- **Le flux non-normales (recherche simple, comparaison) doit fonctionner identiquement si les normales échouent** — INV-9 + INV-11.
- **Les changements sont presque exclusivement frontend** — le backend ne change que pour ajouter `month_normals` à la réponse existante.

### Risques

| #   | Risque                                                                          | Mitigation                                                                                                                                   |
| --- | ------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
| 1   | Le texte contextuel saisonnier devient répétitif sur des recherches successives | Texte court (1-2 phrases), variant avec la saison et les normales réelles                                                                    |
| 2   | L'IntersectionObserver pour les tabs actifs ne se déclenche pas correctement    | Fallback : l'état actif initial est le premier onglet. Threshold conservateur (0.2)                                                          |
| 3   | Le bloc climat mensuel doublonne avec l'anomalie climatique                     | Contenus différents : anomalie = écart observé vs. normal pour la période. Climat mensuel = normales du mois complet, perspective plus large |

---

## 3) Design minimal proposé

### 3.1 Formatage compact des dates (`formatCompactDateRange`)

Nouvelle fonction utilitaire frontend pour produire des plages de dates naturelles en français.

#### Comportement

| Cas                        | Entrée                         | Sortie                                                                |
| -------------------------- | ------------------------------ | --------------------------------------------------------------------- |
| Même jour                  | `"2026-03-09"`, `"2026-03-09"` | `{ type: "single", text: "9 mars 2026" }`                             |
| Même mois et année         | `"2026-03-09"`, `"2026-03-11"` | `{ type: "range", start: "9", end: "11 mars 2026" }`                  |
| Mois différent, même année | `"2026-02-28"`, `"2026-03-03"` | `{ type: "range", start: "28 février", end: "3 mars 2026" }`          |
| Année différente           | `"2025-12-30"`, `"2026-01-02"` | `{ type: "range", start: "30 décembre 2025", end: "2 janvier 2026" }` |

#### Utilisation

```js
function formatCompactDateRange(startISO, endISO) {
  const s = parseIsoDateAtMidnight(startISO);
  const e = parseIsoDateAtMidnight(endISO);

  if (startISO === endISO) {
    return { type: "single", text: formatDayMonth(s, true) };
  }

  const sameMonth =
    s.getMonth() === e.getMonth() && s.getFullYear() === e.getFullYear();
  const sameYear = s.getFullYear() === e.getFullYear();

  if (sameMonth) {
    return {
      type: "range",
      start: String(s.getDate()),
      end: formatDayMonth(e, true),
    };
  }
  if (sameYear) {
    return {
      type: "range",
      start: formatDayMonth(s, false),
      end: formatDayMonth(e, true),
    };
  }
  return {
    type: "range",
    start: formatDayMonth(s, true),
    end: formatDayMonth(e, true),
  };
}
```

Fonctions auxiliaires :

```js
function formatDayMonth(date, withYear) {
  const opts = { day: "numeric", month: "long" };
  if (withYear) opts.year = "numeric";
  return date.toLocaleDateString("fr-FR", opts);
}

function formatDateRangeText(range, prefix) {
  // prefix = "du"/"au" ou "entre le"/"et le"
  if (range.type === "single") {
    return `le ${range.text}`;
  }
  if (prefix === "du") {
    return `du ${range.start} au ${range.end}`;
  }
  return `entre le ${range.start} et le ${range.end}`;
}
```

---

### 3.2 H1 dynamique et `document.title`

#### Principe

La page contient un seul `<h1>` à la fois visible :

- **État initial** (homepage, pas de résultats) : H1 sr-only dans le `<header>` : « HistoMétéo — Historique de la Météo en France ».
- **État résultats** (après recherche) : H1 visible dans `<main>` : « Météo à {ville} du {dates compactes} ».

Deux éléments H1 coexistent dans le DOM mais un seul est accessible à la fois (l'autre est `hidden`).

#### HTML

Dans `<header class="hero">`, l'existant :

```html
<h1 id="page-title-default" class="sr-only">
  HistoMétéo — Historique de la Météo en France
</h1>
```

Dans `<main>`, nouveau, inséré juste avant `#seo-question` :

```html
<h1 id="page-title-results" class="results-title" hidden></h1>
```

#### Logique JS

Nouvelle fonction `updatePageTitle(commune1Name, commune2Name, startDate, endDate)` :

```js
function updatePageTitle(commune1Name, commune2Name, startDate, endDate) {
  const range = formatCompactDateRange(startDate, endDate);
  const dateText = formatDateRangeText(range, "du");

  let h1Text;
  if (commune2Name) {
    h1Text = `Comparaison météo\u00a0: ${commune1Name} vs ${commune2Name} ${dateText}`;
  } else {
    h1Text = `Météo à ${commune1Name} ${dateText}`;
  }

  pageTitleDefault.hidden = true;
  pageTitleResults.textContent = h1Text;
  pageTitleResults.hidden = false;
  document.title = `${h1Text} — HistoMétéo`;
}
```

Sur `clearResults()`, restaurer l'état initial :

```js
pageTitleDefault.hidden = false;
pageTitleResults.hidden = true;
document.title = "HistoMétéo — Historique météo en France";
```

#### Appel

Depuis `renderSimpleResults()` et `renderComparisonResults()`, appeler `updatePageTitle()` avec le nom de commune (sans département : `selectedCommune.nom`, pas `formatCommuneLabel(selectedCommune)`).

**Règle** : le H1 et le `document.title` utilisent **uniquement le nom de la ville**, jamais le code département.

---

### 3.3 Question SEO compacte

#### Modification de `renderSeoQuestion`

Remplacer l'implémentation actuelle pour utiliser les dates compactes et supprimer le département :

```js
function renderSeoQuestion(commune1Name, commune2Name, startDate, endDate) {
  const range = formatCompactDateRange(startDate, endDate);
  const dateText = formatDateRangeText(range, "du");

  let text;
  if (commune2Name) {
    text = `Quel temps faisait-il à ${commune1Name} et ${commune2Name} ${dateText}\u00a0?`;
  } else {
    text = `Quel temps faisait-il à ${commune1Name} ${dateText}\u00a0?`;
  }

  seoQuestionText.textContent = text;
  seoQuestionSection.classList.remove("hidden");
}
```

**Modification des appelants** : passer `selectedCommune.nom` au lieu de `formatCommuneLabel(selectedCommune)` pour `commune1Name`. Idem pour `commune2Name`.

| Fonction appelante        | Paramètre actuel                                           | Nouveau paramètre                              |
| ------------------------- | ---------------------------------------------------------- | ---------------------------------------------- |
| `renderSimpleResults`     | `formatCommuneLabel(selectedCommune)`                      | `selectedCommune.nom`                          |
| `renderComparisonResults` | `formatCommuneLabel(selectedCommune)` / `selectedCommune2` | `selectedCommune.nom` / `selectedCommune2.nom` |

**Même modification** pour les appels à `updateMetaDescription` — le nom de ville seul, sans département.

---

### 3.4 Résumé enrichi

#### Modification de `buildPeriodSummaryParagraph`

Enrichir le résumé avec les dates et reformuler la phrase de cumul :

```js
function buildPeriodSummaryParagraph(agg, communeName, startDate, endDate) {
  const range = formatCompactDateRange(startDate, endDate);
  const dateText = formatDateRangeText(range, "entre");

  const tempMin = valueOrDash(agg.tempMin, (v) => v.toFixed(1));
  const tempMax = valueOrDash(agg.tempMax, (v) => v.toFixed(1));
  const precip = valueOrDash(agg.precipTotal, (v) => v.toFixed(1));
  const condition = (agg.dominantDesc || "variées").toLowerCase();

  const p1 = document.createElement("p");
  p1.textContent = `À ${communeName}, ${dateText}, la température a varié entre ${tempMin}\u00a0°C et ${tempMax}\u00a0°C.`;

  const p2 = document.createElement("p");
  p2.textContent = `Les conditions ont été majoritairement ${condition} avec un cumul de pluie de ${precip}\u00a0mm.`;

  const fragment = document.createDocumentFragment();
  fragment.appendChild(p1);
  fragment.appendChild(p2);
  return fragment;
}
```

#### Modification de `renderPeriodSummary`

Passer les dates en paramètre :

```js
function renderPeriodSummary(agg, communeName, startDate, endDate) {
  periodSummaryBody.replaceChildren(
    buildPeriodSummaryParagraph(agg, communeName, startDate, endDate),
  );
  periodSummarySection.classList.remove("hidden");
}
```

**Idem pour `renderComparisonPeriodSummary`** : passer les dates et utiliser `communeName` sans département.

---

### 3.5 Texte contextuel saisonnier

#### Principe

Après le résumé de période, un court paragraphe contextualise la météo observée par rapport à la saison et aux normales. Ce texte est un enrichissement progressif (INV-11) : il n'apparaît que si les normales sont disponibles.

#### Détermination de la saison

Basée sur le mois médian de la période (ou le mois de la date de début) :

| Mois | Saison    | Position    | Texte français        |
| ---- | --------- | ----------- | --------------------- |
| 12   | Hiver     | début       | au début de l'hiver   |
| 1    | Hiver     | milieu/cœur | en plein hiver        |
| 2    | Hiver     | fin         | à la fin de l'hiver   |
| 3    | Printemps | début       | au début du printemps |
| 4    | Printemps | milieu/cœur | en plein printemps    |
| 5    | Printemps | fin         | à la fin du printemps |
| 6    | Été       | début       | au début de l'été     |
| 7    | Été       | milieu/cœur | en plein été          |
| 8    | Été       | fin         | à la fin de l'été     |
| 9    | Automne   | début       | au début de l'automne |
| 10   | Automne   | milieu/cœur | en plein automne      |
| 11   | Automne   | fin         | à la fin de l'automne |

#### Génération du texte

```js
function buildSeasonalContext(communeName, startDate, normalsData) {
  if (!normalsData?.period_normals) return null;

  const month = parseIsoDateAtMidnight(startDate).getMonth() + 1;
  const seasonText = getSeasonPositionText(month);
  const normalMin = Math.round(normalsData.period_normals.temp_min_avg);
  const normalMax = Math.round(normalsData.period_normals.temp_max_avg);

  const p = document.createElement("p");
  p.className = "seasonal-context";
  p.textContent =
    `Cette période correspond ${seasonText} à ${communeName}, ` +
    `où les températures moyennes se situent généralement entre ${normalMin}\u00a0°C et ${normalMax}\u00a0°C.`;
  return p;
}
```

#### Placement

Le texte est ajouté **à la fin de `#period-summary-body`** (après les paragraphes du résumé), via `appendChild`, lors du callback de `fetchAndRenderNormals`.

Il n'est **pas** rendu lors du `renderPeriodSummary` initial car les normales ne sont pas encore disponibles. Le flux est :

1. `renderSimpleResults()` → `renderPeriodSummary()` (résumé sans contexte saisonnier)
2. `fetchAndRenderNormals()` → succès → appelle `appendSeasonalContext()` qui ajoute le paragraphe

```js
function appendSeasonalContext(communeName, startDate, normalsData) {
  // Supprimer le contexte précédent s'il existe
  const existing = periodSummaryBody.querySelector(".seasonal-context");
  if (existing) existing.remove();

  const p = buildSeasonalContext(communeName, startDate, normalsData);
  if (p) periodSummaryBody.appendChild(p);
}
```

En cas d'échec des normales : le contexte saisonnier n'est simplement pas affiché (INV-11).

---

### 3.6 Synthèse de l'anomalie climatique

#### Principe

Sous le tableau des anomalies, ajouter une phrase de synthèse en langage naturel.

#### Génération

Nouvelle fonction `buildAnomalySynthesis(anomalyTempAvg)` :

```js
function buildAnomalySynthesis(anomalyTempAvg) {
  const p = document.createElement("p");
  p.className = "anomaly-synthesis";

  const absDelta = Math.abs(anomalyTempAvg);

  if (absDelta < 0.3) {
    p.textContent = "La période a été conforme à la normale saisonnière.";
  } else if (anomalyTempAvg > 0) {
    p.textContent =
      `La période a été plus douce que la normale saisonnière ` +
      `avec une température moyenne supérieure de ${oneDecimalFr.format(absDelta)}\u00a0°C.`;
  } else {
    p.textContent =
      `La période a été plus fraîche que la normale saisonnière ` +
      `avec une température moyenne inférieure de ${oneDecimalFr.format(absDelta)}\u00a0°C.`;
  }

  return p;
}
```

#### Intégration dans `renderClimateNormals`

Après la ligne `tableWrapper.appendChild(table)`, construire la synthèse et l'ajouter au contenu de la section :

```js
const anomalyTempAvg = observedAggregates.avgTempAvg - periodNormals.temp_avg;
const synthesis = buildAnomalySynthesis(anomalyTempAvg);

climateNormalsSection.replaceChildren(title, subtitle, tableWrapper, synthesis);
```

Note : `avgTempAvg` est le nouveau champ d'agrégat (cf. section 3.9, correction R29).

---

### 3.7 Navigation par onglets — État actif

#### Principe

Un `IntersectionObserver` surveille les sections liées aux onglets (`#daily-summary`, `#chart`, `#results`) et met en surbrillance l'onglet correspondant à la section la plus visible.

#### Implémentation

```js
function initResultsNavObserver() {
  const sectionIds = ["daily-summary", "chart", "results"];
  const links = resultsNav.querySelectorAll("a");

  const observer = new IntersectionObserver(
    (entries) => {
      entries.forEach((entry) => {
        if (entry.isIntersecting) {
          links.forEach((a) => a.classList.remove("active"));
          const match = resultsNav.querySelector(
            `a[href="#${entry.target.id}"]`,
          );
          if (match) match.classList.add("active");
        }
      });
    },
    { threshold: 0.2 },
  );

  sectionIds.forEach((id) => {
    const section = document.getElementById(id);
    if (section) observer.observe(section);
  });
}
```

Appeler `initResultsNavObserver()` une seule fois au chargement de la page (dans le bloc d'initialisation).

#### CSS

```css
.results-nav a.active {
  background: var(--accent);
  color: #fff;
}
```

---

### 3.8 Bloc climat mensuel

#### Principe

Nouveau bloc « Climat habituel à {ville} en {mois} » affiché après le détail horaire. Utilise les `month_normals` retournées par `/api/normals`.

#### Source de données

Le backend retourne un nouveau champ `month_normals` dans la réponse `/api/normals` (cf. section 3.10) :

```json
{
  "elevation": 608.0,
  "reference_period": "1991-2020",
  "period_normals": { ... },
  "daily_normals": [ ... ],
  "month_normals": {
    "month": 3,
    "month_name": "mars",
    "temp_avg": 10.2,
    "temp_max_avg": 14.5,
    "temp_min_avg": 6.1,
    "precipitation_total": 62.3
  }
}
```

#### HTML

Nouvelle section dans `index.html`, positionnée après `#results` et avant `#commune-info` :

```html
<section id="climate-month" class="info-card" hidden aria-live="polite">
  <h2 id="climate-month-title"></h2>
</section>
```

#### Rendu frontend

Nouvelle fonction `renderClimateMonth(communeName, monthNormals)` :

```js
function renderClimateMonth(communeName, monthNormals) {
  const section = document.getElementById("climate-month");

  if (!monthNormals) {
    section.classList.add("hidden");
    return;
  }

  const title = document.createElement("h2");
  title.textContent = `Climat habituel à ${communeName} en ${monthNormals.month_name}`;

  const list = document.createElement("ul");
  list.className = "climate-month-list";

  const addRow = (label, value) => {
    const li = document.createElement("li");
    const labelSpan = document.createElement("span");
    labelSpan.className = "info-label";
    labelSpan.textContent = `${label} :`;
    const valueSpan = document.createElement("span");
    valueSpan.textContent = value;
    li.appendChild(labelSpan);
    li.appendChild(valueSpan);
    list.appendChild(li);
  };

  addRow(
    "Température moyenne",
    `${oneDecimalFr.format(monthNormals.temp_avg)} °C`,
  );
  addRow(
    "Temp. max moyenne",
    `${oneDecimalFr.format(monthNormals.temp_max_avg)} °C`,
  );
  addRow(
    "Temp. min moyenne",
    `${oneDecimalFr.format(monthNormals.temp_min_avg)} °C`,
  );
  addRow(
    "Précipitations",
    `${oneDecimalFr.format(monthNormals.precipitation_total)} mm`,
  );

  section.replaceChildren(title, list);
  section.classList.remove("hidden");
}
```

#### Appel

Depuis `fetchAndRenderNormals`, après le rendu des anomalies :

```js
renderClimateMonth(commune.nom, normalsData?.month_normals);
```

En mode comparaison ou si les normales échouent : le bloc est masqué.

---

### 3.9 Correction R29 — Anomalies moyennes vs. moyennes

#### Problème

`computeAggregates()` retourne `tempMax = Math.max(…dailyMaxes)` (max absolu) et `tempMin = Math.min(…dailyMins)` (min absolu). Les normales retournent des **moyennes** de max/min journaliers. La comparaison est biaisée.

#### Solution

Ajouter deux champs aux agrégats retournés par `computeAggregates()` :

```js
// Dans computeAggregates, en plus des champs existants :
avgDailyMax: dailyMaxes.length ? dailyMaxes.reduce((a, b) => a + b, 0) / dailyMaxes.length : null,
avgDailyMin: dailyMins.length ? dailyMins.reduce((a, b) => a + b, 0) / dailyMins.length : null,
avgTempAvg: tempAvgs.length ? tempAvgs.reduce((a, b) => a + b, 0) / tempAvgs.length : null,
```

Note : `tempAvg` existe déjà dans les agrégats mais est calculé comme la moyenne de `(min+max)/2`. Le champ `avgTempAvg` est identique et peut réutiliser la valeur existante. Si `tempAvg` est déjà la moyenne des valeurs journalières, alors `avgTempAvg = tempAvg` — dans ce cas, un alias suffit.

#### Modification de `renderClimateNormals`

Utiliser les nouveaux champs pour le calcul des anomalies :

| Ligne du tableau  | Observé (avant)                           | Observé (après)                                    | Normale (inchangé)                  |
| ----------------- | ----------------------------------------- | -------------------------------------------------- | ----------------------------------- |
| Temp. max moyenne | `observedAggregates.tempMax` (max absolu) | `observedAggregates.avgDailyMax` (moyenne des max) | `periodNormals.temp_max_avg`        |
| Temp. min moyenne | `observedAggregates.tempMin` (min absolu) | `observedAggregates.avgDailyMin` (moyenne des min) | `periodNormals.temp_min_avg`        |
| Temp. moyenne     | `observedAggregates.tempAvg`              | `observedAggregates.tempAvg` (inchangé)            | `periodNormals.temp_avg`            |
| Précipitations    | `observedAggregates.precipTotal`          | `observedAggregates.precipTotal` (inchangé)        | `periodNormals.precipitation_total` |

Les champs existants `tempMax` et `tempMin` (valeurs absolues) **ne sont pas supprimés** — ils restent utilisés par le résumé de période et la comparaison. Les nouveaux champs `avgDailyMax` et `avgDailyMin` sont utilisés uniquement pour les anomalies.

---

### 3.10 Extension backend — `month_normals`

#### Modification de `NormalsService.get_normals()`

Après le calcul des `period_normals` et `daily_normals`, calculer les normales pour le mois complet de la date de début.

```python
def _compute_month_normals(self, all_days: dict, start_date: str) -> dict:
    """Calcule les normales pour le mois complet de start_date."""
    month = int(start_date[5:7])

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

    month_days = {
        k: v for k, v in all_days.items()
        if int(k.split("-")[0]) == month
    }

    if not month_days:
        return None

    values = list(month_days.values())
    return {
        "month": month,
        "month_name": MONTH_NAMES_FR[month],
        "temp_avg": round(sum(d["temp_avg"] for d in values) / len(values), 1),
        "temp_max_avg": round(sum(d["temp_max"] for d in values) / len(values), 1),
        "temp_min_avg": round(sum(d["temp_min"] for d in values) / len(values), 1),
        "precipitation_total": round(sum(d["precipitation"] for d in values), 1),
    }
```

#### Ajout dans la réponse

Dans `get_normals()`, après la construction du dictionnaire de résultat :

```python
result["month_normals"] = self._compute_month_normals(all_days, start)
```

Le champ `month_normals` est `None` (ou absent) si aucun jour du mois n'a de données. Le frontend gère ce cas via la condition `if (!monthNormals)`.

#### Rétrocompatibilité

Le champ `month_normals` est additif — le frontend existant l'ignore s'il ne l'utilise pas. Les champs `elevation`, `reference_period`, `period_normals`, `daily_normals` restent identiques.

---

### 3.11 Restructuration DOM — Correction C1 + nouveaux blocs

#### Nouvel ordre des sections dans `<main>`

Les sections sont réorganisées pour correspondre au flux de lecture naturel :

```
 1. Search panel (existant — #search)
 2. Global error (existant — #global-error)
 3. H1 résultats (nouveau — #page-title-results) [hidden par défaut]
 4. Question SEO (existant — #seo-question)
 5. Résumé de la période (existant — #period-summary)
    └── Texte contextuel saisonnier (ajouté dynamiquement, classe .seasonal-context)
 6. Comparaison summary (existant — #comparison-summary)
 7. Navigation onglets (existant — #results-nav)
 8. Résumé journalier (existant — #daily-summary)
 9. Anomalie climatique (existant — #climate-normals) + phrase de synthèse
10. Graphique (existant — #chart)
11. Détail horaire (existant — #results)
12. Climat mensuel (nouveau — #climate-month) [hidden par défaut]
13. Informations commune (existant — #commune-info) ← DÉPLACÉ depuis après #info
14. Navigation période (existant — #period-links)
15. Transparence données (existant — #info)
16. Footer
```

#### Modifications HTML

1. **Ajouter** `id="page-title-default"` au H1 existant dans le `<header>`.
2. **Ajouter** `<h1 id="page-title-results" class="results-title" hidden></h1>` juste après `#global-error`.
3. **Ajouter** `<section id="climate-month" class="info-card" hidden aria-live="polite"><h2 id="climate-month-title"></h2></section>` après `#results`.
4. **Déplacer** `<section id="commune-info">` de sa position actuelle (après `#info`) vers **après `#climate-month`**, avant `#period-links`.

---

### 3.12 Styles CSS

#### Nouveaux styles

```css
/* H1 résultats */
.results-title {
  font-size: 1.35rem;
  font-weight: 700;
  color: var(--text);
  text-align: center;
  margin: var(--space-md) 0 var(--space-sm);
}

/* Tab active state */
.results-nav a.active {
  background: var(--accent);
  color: #fff;
}

/* Contexte saisonnier */
.seasonal-context {
  font-style: italic;
  color: var(--muted-text, #666);
  margin-top: var(--space-xs, 0.5rem);
}

/* Synthèse anomalie */
.anomaly-synthesis {
  margin-top: var(--space-sm, 0.75rem);
  font-size: 0.95rem;
}

/* Bloc climat mensuel — réutilise .info-card + .commune-info-list existants */
.climate-month-list {
  list-style: none;
  padding: 0;
  margin: var(--space-sm) 0 0;
}

.climate-month-list li {
  padding: 0.35rem 0;
  display: flex;
  gap: 0.5rem;
}
```

Les classes `.info-card`, `.info-label`, `.commune-info-list` existantes sont réutilisées pour le bloc climat mensuel et le bloc commune info (cohérence visuelle).

---

## 4) Plan d'implémentation

### Étape 1 — Frontend : Utilitaires de formatage + H1 dynamique

**Fichiers** : `public/app.js`, `public/index.html`

- Implémenter `formatDayMonth()`, `formatCompactDateRange()`, `formatDateRangeText()`
- Ajouter `id="page-title-default"` au H1 du hero dans `index.html`
- Ajouter `<h1 id="page-title-results">` dans `<main>` (après `#global-error`)
- Implémenter `updatePageTitle(commune1Name, commune2Name, startDate, endDate)`
- Ajouter les refs DOM : `pageTitleDefault`, `pageTitleResults`
- Modifier `clearResults()` pour restaurer le H1 par défaut et `document.title`

**Testable** : après une recherche « Bordeaux, 9-11 mars 2026 », le H1 visible est « Météo à Bordeaux du 9 au 11 mars 2026 » et `document.title` est « Météo à Bordeaux du 9 au 11 mars 2026 — HistoMétéo ».

---

### Étape 2 — Frontend : Question compacte + résumé enrichi + noms sans département

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

- Modifier `renderSeoQuestion()` pour utiliser `formatCompactDateRange` (pas `formatNaturalFrDate`)
- Modifier `buildPeriodSummaryParagraph()` pour prendre `startDate`/`endDate` et produire 2 paragraphes
- Modifier `renderPeriodSummary()` pour passer les dates
- Modifier les appels dans `renderSimpleResults()` et `renderComparisonResults()` pour utiliser `commune.nom` au lieu de `formatCommuneLabel(commune)`
- Modifier `updateMetaDescription()` de la même façon (commune.nom, pas formatCommuneLabel)

**Testable** : la question affiche « Quel temps faisait-il à Bordeaux du 9 au 11 mars 2026 ? ». Le résumé contient deux paragraphes avec la ville et les dates.

---

### Étape 3 — Frontend : Tabs actifs + correction R29 (`computeAggregates`)

**Fichier** : `public/app.js`, `public/style.css`

- Implémenter `initResultsNavObserver()` avec IntersectionObserver
- Appeler `initResultsNavObserver()` à l'initialisation de la page
- Ajouter le style `.results-nav a.active` dans `style.css`
- Ajouter `avgDailyMax`, `avgDailyMin` dans `computeAggregates()`
- Modifier `renderClimateNormals()` pour utiliser `avgDailyMax`/`avgDailyMin` au lieu de `tempMax`/`tempMin`
- Ajouter le label « Temp. max moyenne » pour confirmer qu'il s'agit d'une moyenne et non d'un absolu

**Testable** : en scrollant, l'onglet actif change de couleur. Les anomalies de température max/min sont des différences de moyennes.

---

### Étape 4 — Frontend : Synthèse anomalie + contexte saisonnier + bloc climat mensuel

**Fichier** : `public/app.js`, `public/style.css`

- Implémenter `buildAnomalySynthesis(anomalyTempAvg)` et l'intégrer dans `renderClimateNormals()`
- Implémenter `getSeasonPositionText(month)`, `buildSeasonalContext()`, `appendSeasonalContext()`
- Implémenter `renderClimateMonth(communeName, monthNormals)`
- Modifier `fetchAndRenderNormals()` pour appeler `appendSeasonalContext()` et `renderClimateMonth()`
- Ajouter les styles `.seasonal-context`, `.anomaly-synthesis`, `.climate-month-list`

**Testable** : après une recherche en mars, le tableau anomalie est suivi de « La période a été plus douce/fraîche… ». Le résumé contient un paragraphe en italique « Cette période correspond au début du printemps… ». Un bloc « Climat habituel à Bordeaux en mars » apparaît sous le détail horaire.

---

### Étape 5 — HTML : Restructuration DOM (C1 + nouveaux blocs)

**Fichier** : `public/index.html`

- Ajouter `id="page-title-default"` au H1 du hero
- Ajouter `<h1 id="page-title-results" class="results-title" hidden></h1>` après `#global-error`
- Ajouter `<section id="climate-month">` après `<section id="results">`
- Déplacer `<section id="commune-info">` de sa position actuelle vers après `#climate-month`
- Vérifier que l'ordre DOM correspond à la section 3.11

**Testable** : inspecter le DOM → les sections sont dans l'ordre spécifié. `#commune-info` apparaît entre `#climate-month` et `#period-links`.

---

### Étape 6 — Backend : `month_normals` dans `NormalsService`

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

- Ajouter la méthode `_compute_month_normals(self, all_days, start_date)`
- Appeler `_compute_month_normals()` dans `get_normals()` et ajouter le résultat au dict de retour
- Le champ `month_normals` est `None` si aucun jour du mois n'a de données

**Testable** : appeler `get_normals(48.86, 2.35, "2024-03-05", "2024-03-11")` → le résultat contient `month_normals.month == 3`, `month_normals.month_name == "mars"`, et les 4 champs de normales mensuelles.

---

### Étape 7 — Tests

**Fichiers** : `tests/test_normals_service.py`, `tests/test_api.py`

Nouveaux tests :

| Test                              | Fichier                   | Assertion                                                                          |
| --------------------------------- | ------------------------- | ---------------------------------------------------------------------------------- |
| `test_month_normals_basic`        | `test_normals_service.py` | `month_normals` présent, `month == 3`, `month_name == "mars"`, 4 champs numériques |
| `test_month_normals_december`     | `test_normals_service.py` | Période en décembre → `month_normals.month == 12`, `month_name == "décembre"`      |
| `test_normals_feb29`              | `test_normals_service.py` | Données avec 29 février → moyenne calculée sur les seules années bissextiles (R32) |
| `test_normals_no_matching_days`   | `test_normals_service.py` | Cache pré-rempli mais aucun jour ne correspond → retourne `None` (R33)             |
| `test_normals_api_includes_month` | `test_api.py`             | `GET /api/normals?...` → réponse contient `month_normals` avec les bons champs     |

---

## 5) Guide pour le Développeur

### Pièges fréquents

1. **`commune.nom` vs `formatCommuneLabel(commune)`** : pour tout texte visible (H1, question, résumé, titre bloc climat), utiliser **`commune.nom`** (sans département). `formatCommuneLabel` reste utilisé uniquement pour les inputs de recherche et les labels de comparaison dans les tableaux.

2. **Deux H1 dans le DOM** : c'est intentionnel. L'un est `hidden`, l'autre est affiché. Ne jamais avoir les deux visibles simultanément.

3. **`formatCompactDateRange` et le mois de l'année** : `Date.getMonth()` retourne 0-11 en JS. Le `toLocaleDateString("fr-FR", { month: "long" })` gère correctement le français. Ne pas construire de tableau de mois manuellement côté frontend.

4. **`appendSeasonalContext` est idempotent** : elle supprime l'éventuel contexte précédent avant d'en ajouter un nouveau. Important pour la navigation période (précédent/suivant) qui re-trigger `fetchAndRenderNormals`.

5. **IntersectionObserver et sections hidden** : l'observer ne se déclenche pas pour les sections avec l'attribut `hidden`. C'est le comportement souhaité : les tabs actifs n'apparaissent que quand les sections de résultats sont visibles.

6. **`month_normals` côté backend** : le calcul utilise `all_days` (le cache complet des 365 jours). Le mois est déterminé par `start_date`, pas par la période complète. Si la période chevauche deux mois (ex: 28 fév – 5 mars), seul le mois de début (février) est utilisé pour le bloc climat.

7. **INV-7** : aucun `innerHTML` dans les nouveaux rendus. `buildSeasonalContext`, `buildAnomalySynthesis`, `renderClimateMonth` utilisent tous `createElement` + `textContent`.

### Zones de dérive

- **Ne pas créer les pages `/climat/{slug}` ou `/meteo/{slug}`** — ce sont des fonctionnalités futures (V2). Le bloc climat mensuel est un enrichissement de la page de résultats existante, pas une page séparée.
- **Ne pas ajouter de lien `<a>` vers `/climat/{slug}`** dans le bloc climat — la route n'existe pas encore.
- **Ne pas rendre le texte contextuel saisonnier si les normales échouent** — il dépend des données de référence.
- **Ne pas modifier le résumé de comparaison** pour inclure les dates compactes — hors scope v10. Seule la question et le H1 sont modifiés en comparaison.
- **Ne pas modifier `weather_service.py`**, `cache.py`, `commune_service.py`, `config.py`.

### Décisions explicitement interdites

- Ajouter un routeur frontend (vue-router, etc.) pour les futures pages villes/climat.
- Stocker le contexte saisonnier ou les normales dans `localStorage` (INV-1).
- Bloquer le rendu initial pour attendre le contexte saisonnier (INV-9 + INV-11).
- Utiliser `innerHTML` pour injecter le texte saisonnier ou la synthèse anomalie (INV-7).

---

## 6) Stratégie de tests

### Tests unitaires — extension `NormalsService`

| Test                            | Données en entrée (mockées)                                                 | Résultat attendu                                                                                          |
| ------------------------------- | --------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- |
| `test_month_normals_basic`      | Cache pré-rempli avec 365 jours, appel pour période en mars (05–11 mars)    | `month_normals.month == 3`, `month_name == "mars"`, 4 champs numériques calculés sur les 31 jours de mars |
| `test_month_normals_december`   | Cache pré-rempli, appel pour période en décembre                            | `month_normals.month == 12`, `month_name == "décembre"`                                                   |
| `test_normals_feb29`            | Données fake avec "02-29" sur 8 années (bissextiles), "02-28" sur 30 années | Moyenne de "02-29" calculée sur 8 valeurs, pas sur 30 (R32)                                               |
| `test_normals_no_matching_days` | Cache avec jours uniquement pour janvier, appel pour période en juillet     | Retourne `None` (R33)                                                                                     |

### Tests intégration — API

| Route                                                                 | Attendu                                            |
| --------------------------------------------------------------------- | -------------------------------------------------- |
| `GET /api/normals?lat=48.86&lon=2.35&start=2024-03-05&end=2024-03-11` | 200 + JSON avec `month_normals` contenant 6 champs |

### Tests manuels frontend

| #   | Scénario                                       | Résultat attendu                                                                                                                                                                     |
| --- | ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| T1  | Recherche Bordeaux, 9-11 mars 2026             | H1 = « Météo à Bordeaux du 9 au 11 mars 2026 ». Question = « Quel temps faisait-il à Bordeaux du 9 au 11 mars 2026 ? ». Résumé avec 2 paragraphes + contexte saisonnier en italique. |
| T2  | Recherche Paris, 15 janvier 2024               | H1 = « Météo à Paris le 15 janvier 2024 » (jour unique, date compacte). Contexte : « en plein hiver ».                                                                               |
| T3  | Recherche Strasbourg, 28 déc 2025 – 3 jan 2026 | H1 = « Météo à Strasbourg du 28 décembre 2025 au 3 janvier 2026 » (années différentes). Contexte : « au début de l'hiver ».                                                          |
| T4  | Comparaison Bordeaux vs Lyon, 9-11 mars 2026   | H1 = « Comparaison météo : Bordeaux vs Lyon du 9 au 11 mars 2026 ». Blocs anomalie/climat/commune masqués.                                                                           |
| T5  | Scroll depuis le résumé vers le graphique      | L'onglet « Résumé » perd le highlighting, « Graphique » le gagne.                                                                                                                    |
| T6  | Recherche avec normales OK                     | Anomalie + synthèse « plus douce/fraîche… ». Bloc « Climat habituel à {ville} en {mois} » visible.                                                                                   |
| T7  | Recherche avec normales KO (simuler erreur)    | Résultats météo normaux. Pas de contexte saisonnier, pas d'anomalie, pas de bloc climat. `#commune-info` affiché sans altitude.                                                      |
| T8  | Navigation période (suivant)                   | H1, question, résumé, contexte, anomalie, climat mensuel tous mis à jour pour la nouvelle période.                                                                                   |
| T9  | Retour à l'accueil (vider la recherche)        | H1 default sr-only restauré. `document.title` restauré.                                                                                                                              |
| T10 | Mobile 360px                                   | H1 résultats lisible, blocs responsifs, tabs scrollent horizontalement si nécessaire.                                                                                                |

### Régression

| #   | Scénario                                    | Résultat attendu                                                   |
| --- | ------------------------------------------- | ------------------------------------------------------------------ |
| R1  | Autocomplete commune                        | Fonctionne — `formatCommuneLabel` toujours utilisé dans les inputs |
| R2  | URLs SEO `/meteo/...` et `/comparaison/...` | Fonctionnent identiquement (le H1 se met à jour)                   |
| R3  | Redirections 301 anciennes URLs             | Fonctionnent identiquement                                         |
| R4  | Toggle « Tout déplier/replier »             | Fonctionne                                                         |
| R5  | Graphiques Chart.js                         | Affichés correctement                                              |
| R6  | `<link rel="canonical">`                    | Présente et correcte                                               |
| R7  | Mode comparaison complet                    | Fonctionne, sans blocs normales/climat/communeInfo                 |
| R8  | Résumé de période existant                  | Toujours deux paragraphes avec formatage français                  |

---

## 7) Risques techniques

| #   | Risque                                                           | Mitigation                                                                                                                                                                         |
| --- | ---------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 1   | **IntersectionObserver non supporté** (navigateurs très anciens) | Supporté par tous les navigateurs modernes depuis 2017. Pas de polyfill nécessaire. En cas d'absence, les tabs restent sans état actif (dégradation gracieuse).                    |
| 2   | **Texte saisonnier générique ou répétitif**                      | Le texte varie avec : la ville, la saison (4 × 3 = 12 variantes), et les normales réelles (températures différentes pour chaque localisation). Le contenu est suffisamment unique. |
| 3   | **Double `<h1>` perçu comme problème SEO**                       | Un seul H1 est visible/accessible à la fois (l'autre est `hidden`). Google ignore les éléments `hidden`. Conforme aux bonnes pratiques HTML5 sectioning.                           |

---

## Annexe — Notes non bloquantes (informatives)

### Reports des itérations précédentes

- **R21** — `public/assets/logo-histometeo.png` et `favicon.png` non committés. `git add public/assets/`.
- **R22** — `DeprecationWarning` Python 3.14+ persistants. Upgrade `pytest-asyncio`/`fastapi` nécessaire.
- **R23** — Les changements v6–v9 sont dans le working tree. Envisager un commit par version.
- **R30** — Idem R22 (rappel feedback v9).
- **R31** — Idem R21 (rappel feedback v9).

### V2 (hors scope, pour mémoire)

Les éléments suivants sont **explicitement hors scope v10** mais documentés pour le futur :

- **Pages villes** : URL `/meteo/{slug}` avec contenu éditorial (présentation, climat, accès historique, liens populaires). Nécessite un routeur et du contenu statique/généré.
- **Pages climat** : URL `/climat/{slug}` avec normales complètes par mois, graphiques annuels. Nécessite une nouvelle route backend et des templates.
- **Lien vers page climat** : le bloc « Climat habituel » pourra contenir un lien `<a href="/climat/{slug}">` une fois la route créée.
- **Ensoleillement dans le bloc climat** : l'API Open-Meteo Archive expose `sunshine_duration` mais ce n'est pas dans les variables actuellement fetchées. Ajout V2.
- Affichage des normales en mode comparaison
- Overlay des normales journalières sur le graphique Chart.js
- Routes sans suffixe département pour les communes à nom unique
- Routes par jour unique (`/meteo/{slug}/{date}`)
- Maillage interne automatique (liens entre pages villes, dates populaires)
