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

> **Itération** : v5 — intègre le feedback Reviewer (`feedback-to-architect-001-v4.md`) + cinq améliorations UX/SEO (`HistoMeteo—UXImprovements.md`).

---

## 0) Contract

- **Source of truth** : ce document (`001-histometeo-mvp.tech.v5.md`)
- **Functional integrity** : aucun critère d'acceptation de `001-histometeo-mvp.md` ne peut être modifié, ignoré ou réinterprété. Les cinq améliorations UX sont définies dans `HistoMeteo—UXImprovements.md` et réalisées ici.
- **Scope** : fichiers et dossiers autorisés à créer/modifier :
  - `public/` — fichiers frontend (HTML, CSS, JS)
  - `tests/` — tests
  - Fichiers racine : `requirements.txt`
- **Forbidden changes** :
  - `docs/` — aucune modification des specs existantes
  - `.github/` — aucune modification du workflow agent
  - `src/` — aucune modification du backend (aucune route, aucun service, aucune réponse API modifiée)
  - `Dockerfile`, `pyproject.toml` — pas de modification cette itération
- **Invariants** (hérités de v4, tous préservés) :
  - INV-1 : Aucune donnée utilisateur n'est stockée — ni côté serveur, ni côté client (pas de base de données, pas de `localStorage`, pas de fichier)
  - INV-2 : Toutes les heures affichées sont en fuseau horaire `Europe/Paris` — aucun offset UTC hardcodé
  - INV-3 : La période maximale par requête est de 31 jours
  - INV-4 : Aucune clé API n'est requise
  - INV-5 : L'interface est intégralement en français **avec accents et diacritiques** — y compris le nom de l'application (`HistoMétéo`)
  - INV-6 : L'application est une page unique — pas de routeur multi-pages, l'URL reflète l'état de recherche via query parameters
  - INV-7 : Aucune injection HTML — tout contenu dynamique est inséré via `textContent`, `createElement` ou `replaceChildren()`, jamais via `innerHTML`
  - INV-8 : Le flux principal (recherche simple une commune) reste pleinement fonctionnel en l'absence d'activation du mode comparaison — aucune régression UX
- **Done when** :
  - Les 11 critères d'acceptation originaux (AC1–AC11) restent vérifiables
  - Les améliorations des feedbacks v2 (R1–R5), v3 (R6–R9) restent intégrées
  - Les fonctionnalités v3–v4 restent opérationnelles (graphique, URL partageable, regroupement par jour, périodes prédéfinies, mode comparaison)
  - Les 5 nouvelles améliorations UX/SEO sont opérationnelles
  - Le correctif R10 (`requirements.txt`) est appliqué
  - L'amélioration R11 (toggle déplier/replier en comparaison) est intégrée
  - La factorisation R12 (`createDailySummaryTable` / `renderDailySummary`) est réalisée
  - **Tous** les tests passent (aucun test SKIPPED, aucun DeprecationWarning projet)
  - L'application se lance via `uvicorn src.main:app` ou `docker compose up`

---

## 1) Objectif technique

Enrichir l'application HistoMétéo avec cinq améliorations UX/SEO côté frontend et intégrer les recommandations non bloquantes du feedback v4, sans modification du backend :

1. **Comparaison améliorée** — Tableau de synthèse avec mise en évidence de la valeur dominante et d'un delta lisible
2. **Navigation interne** — Barre de navigation sticky avec ancres vers les sections de résultats
3. **Résumé automatique de la période** — Texte descriptif généré dynamiquement à partir des données
4. **Maillage interne** — Liens « jour précédent / jour suivant » décalant la période d'un jour
5. **Bloc question SEO** — Phrase sous le titre reformulant la requête en question naturelle
6. **R10** — Corriger la version `pytest-asyncio` dans `requirements.txt`
7. **R11** — Bouton « Tout déplier / replier » visible en mode comparaison (un par bloc ville)
8. **R12** — Factoriser la construction des lignes de résumé journalier

---

## 2) Analyse du brief

### Besoins principaux

| Besoin                                | Source            | Complexité | Impact                   |
| ------------------------------------- | ----------------- | ---------- | ------------------------ |
| Comparaison améliorée (delta + badge) | UXImprovements §1 | Faible     | Lisibilité comparaison   |
| Navigation interne sticky             | UXImprovements §2 | Faible     | Navigation longues pages |
| Résumé textuel automatique            | UXImprovements §3 | Faible     | SEO + compréhension      |
| Liens jour précédent/suivant          | UXImprovements §4 | Moyen      | Exploration + SEO        |
| Bloc question SEO                     | UXImprovements §5 | Faible     | SEO                      |
| R10 — requirements.txt                | Feedback v4       | Trivial    | Reproductibilité         |
| R11 — toggle comparaison              | Feedback v4       | Faible     | UX confort               |
| R12 — factorisation résumé            | Feedback v4       | Faible     | Maintenabilité           |

### Contraintes

- **Backend inchangé** : aucune route, aucun service, aucune réponse API modifiée. Toutes les améliorations sont purement frontend.
- **Pas de framework JS ajouté** — le projet reste en vanilla JS.
- **Pas de nouvelle dépendance backend** — seule correction de version dans `requirements.txt`.
- **Structure de page actuelle respectée** — les nouvelles sections s'insèrent dans la page existante selon l'ordre défini dans la spec fonctionnelle UX.
- **Les données existantes suffisent** — les résumés, deltas et textes sont calculés à partir de `data` et `daily_summary` déjà retournés par `/api/weather`.

### Risques

| #   | Risque                                                                     | Mitigation                                                                                |
| --- | -------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- |
| 1   | Surcharge visuelle — 3 nouvelles sections ajoutées au-dessus des résultats | Sections compactes, navigation sticky pour accès rapide, sections masquées sans résultats |
| 2   | Liens précédent/suivant avec dates invalides (avant 1940, après hier)      | Validation côté frontend, lien masqué ou désactivé si date hors plage                     |
| 3   | Taille de `app.js` en croissance (~1350 lignes → ~1550 estimé)             | Code ajouté minimal, fonctions pures, factorisation R12 compense                          |

---

## 3) Design minimal proposé

### 3.1 Architecture globale

Backend **strictement inchangé**. Toutes les modifications sont dans `public/`.

```
Backend : AUCUNE modification

Frontend (modifications)
├── public/index.html        → 4 nouvelles sections HTML (question SEO, résumé, nav, liens période)
├── public/app.js            → Fonctions de rendu des nouvelles sections, factorisation R12, toggle R11
├── public/style.css         → Styles nouvelles sections (nav sticky, résumé, liens, question)

Racine (correction)
├── requirements.txt         → Correction version pytest-asyncio (R10)
```

### 3.2 Nouvelle structure de page (ordre des sections)

Après les améliorations, la page suit cet ordre lorsque des résultats sont affichés :

```
1. <header>              — Hero HistoMétéo (inchangé)
2. <section #search>     — Formulaire de recherche (inchangé)
3. <div #global-error>   — Erreur globale (inchangé)
4. <section #seo-question>        — ⭐ NOUVEAU : Bloc question SEO
5. <section #period-summary>      — ⭐ NOUVEAU : Résumé automatique de la période
6. <section #comparison-summary>  — Comparaison améliorée (modifié : delta + badge)
7. <nav #results-nav>             — ⭐ NOUVEAU : Navigation interne sticky
8. <section #daily-summary>       — Résumé par jour (inchangé structurellement)
9. <section #chart>               — Graphique météo (inchangé)
10. <section #results>            — Détail horaire (modifié : toggle R11)
11. <div #period-links>           — ⭐ NOUVEAU : Liens jour précédent/suivant
12. <section #info>               — Transparence des données (inchangé)
13. <footer>                      — Pied de page (inchangé)
```

### 3.3 Fonctionnalité 1 — Bloc question SEO

#### Principe

Afficher une phrase naturelle reformulant la recherche de l'utilisateur, visible uniquement lorsque des résultats sont affichés. Cette phrase sert d'ancrage SEO pour les moteurs de recherche.

#### Emplacement

Nouvelle section `#seo-question` entre `#global-error` et `#period-summary`.

#### Contenu

Le texte est généré dynamiquement selon le mode :

- **Mode simple** : `Quel temps faisait-il à {commune} du {dateStart} au {dateEnd} ?`
- **Mode comparaison** : `Quel temps faisait-il à {commune1} et {commune2} du {dateStart} au {dateEnd} ?`
- **Période d'un jour** : `Quel temps faisait-il à {commune} le {date} ?`

Les dates sont formatées en français lisible (ex : « 2 mars 2026 »).

#### HTML

```html
<section id="seo-question" class="panel hidden" aria-live="polite">
  <p id="seo-question-text" class="seo-question-text"></p>
</section>
```

#### JS — Fonction `renderSeoQuestion(commune1Name, commune2Name, startDate, endDate)`

```js
function renderSeoQuestion(commune1Name, commune2Name, startDate, endDate) {
  const fmt = (isoDate) => {
    const d = new Date(isoDate + "T00:00:00");
    return d.toLocaleDateString("fr-FR", {
      day: "numeric",
      month: "long",
      year: "numeric",
    });
  };
  const start = fmt(startDate);
  const end = fmt(endDate);
  let text;
  if (startDate === endDate) {
    text = commune2Name
      ? `Quel temps faisait-il à ${commune1Name} et ${commune2Name} le ${start}\u00a0?`
      : `Quel temps faisait-il à ${commune1Name} le ${start}\u00a0?`;
  } else {
    text = commune2Name
      ? `Quel temps faisait-il à ${commune1Name} et ${commune2Name} du ${start} au ${end}\u00a0?`
      : `Quel temps faisait-il à ${commune1Name} du ${start} au ${end}\u00a0?`;
  }
  seoQuestionText.textContent = text;
  seoQuestionSection.classList.remove("hidden");
}
```

> Les dates sont formatées avec `toLocaleDateString("fr-FR")` côté navigateur (toujours disponible). Aucun risque de fuseau car on passe `T00:00:00` sans offset.

#### CSS

```css
.seo-question-text {
  font-size: 1.15rem;
  font-weight: 600;
  color: var(--text);
  text-align: center;
  margin: 0;
  padding: 0.2rem 0;
}
```

### 3.4 Fonctionnalité 2 — Résumé automatique de la période

#### Principe

Générer dynamiquement un paragraphe descriptif résumant les conditions météo sur la période, à partir des données `daily_summary` déjà disponibles. Aucune génération IA — texte factuel construit par template.

#### Emplacement

Nouvelle section `#period-summary` entre `#seo-question` et `#comparison-summary`.

#### Contenu du texte

Le résumé inclut :

- température minimale sur la période
- température maximale sur la période
- précipitations totales
- condition dominante

**Template mode simple** :

> Sur cette période à {commune}, la température a varié entre {tempMin} °C et {tempMax} °C. Les conditions ont été majoritairement {conditionDominante}. Le cumul total de pluie est de {precipTotal} mm.

**Template mode comparaison** — deux paragraphes, un par ville, préfixés par le nom :

> À {commune1}, la température a varié entre {tempMin} °C et {tempMax} °C. Les conditions ont été majoritairement {conditionDominante}. Cumul de pluie : {precipTotal} mm.
>
> À {commune2}, la température a varié entre {tempMin} °C et {tempMax} °C. Les conditions ont été majoritairement {conditionDominante}. Cumul de pluie : {precipTotal} mm.

#### Données source

Les valeurs sont obtenues via la fonction `computeAggregates(dailySummaries)` déjà existante, qui retourne `{ tempMin, tempMax, tempAvg, precipTotal, windAvg, dominantIcon, dominantDesc }`.

#### HTML

```html
<section id="period-summary" class="panel hidden" aria-live="polite">
  <h2>Résumé de la période</h2>
  <div id="period-summary-body"></div>
</section>
```

#### JS — Fonction `renderPeriodSummary(agg, communeName)` (retourne un `<p>`)

```js
function buildPeriodSummaryParagraph(agg, communeName) {
  const p = document.createElement("p");
  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";
  p.textContent =
    `À ${communeName}, la température a varié entre ${tempMin} °C et ${tempMax} °C. ` +
    `Les conditions ont été majoritairement ${condition.toLowerCase()}. ` +
    `Cumul de pluie\u00a0: ${precip} mm.`;
  return p;
}
```

Appelée dans `renderSimpleResults` et `renderComparisonResults` :

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

// Mode comparaison
function renderComparisonPeriodSummary(agg1, agg2, nom1, nom2) {
  periodSummaryBody.replaceChildren(
    buildPeriodSummaryParagraph(agg1, nom1),
    buildPeriodSummaryParagraph(agg2, nom2),
  );
  periodSummarySection.classList.remove("hidden");
}
```

### 3.5 Fonctionnalité 3 — Comparaison améliorée (delta + badge)

#### Principe

Améliorer la section `#comparison-summary` existante pour afficher un delta numérique et un badge textuel (« plus chaud », « plus pluvieux ») dans la colonne « Note » du tableau comparatif.

#### Modifications de `renderComparisonSummary`

La colonne « Note » affiche déjà un texte conditionnel (ex : « ville la plus chaude »). Les améliorations :

1. **Delta numérique** : afficher la différence précise entre les deux valeurs (ex : « +6.1 °C »)
2. **Classe CSS `.comparison-winner`** déjà existante — l'appliquer systématiquement à la valeur dominante dans les colonnes Ville 1 / Ville 2 également
3. **Badge textuel** dans la colonne Note : texte court « plus chaud », « plus pluvieux », « plus venteux » avec le nom de la ville

#### Indicateurs comparés (6 lignes)

| Indicateur             | Calcul delta        | Badge si dominant                         |
| ---------------------- | ------------------- | ----------------------------------------- |
| Température min        | `abs(v1 - v2)` °C   | « {ville} +X °C »                         |
| Température max        | `abs(v1 - v2)` °C   | « {ville} +X °C »                         |
| Temp. moyenne          | `abs(v1 - v2)` °C   | « {ville} +X °C »                         |
| Précipitations totales | `abs(v1 - v2)` mm   | « plus pluvieux » si > 0                  |
| Vent moyen             | `abs(v1 - v2)` km/h | « plus venteux » si > 0                   |
| Condition dominante    | —                   | — (pas de delta, affichage icône + texte) |

#### Règle d'affichage du delta

- Si les deux valeurs sont identiques (delta = 0) : colonne Note vide
- Si une valeur est null : colonne Note « — »
- Sinon : nom de la ville avec le delta, formaté avec signe +

#### Exemple de rendu

| Indicateur      | Paris       | Lyon          | Écart                         |
| --------------- | ----------- | ------------- | ----------------------------- |
| Température max | **18.5 °C** | 12.4 °C       | Paris +6.1 °C                 |
| Précipitations  | 0.0 mm      | **4.3 mm**    | Lyon plus pluvieux (+4.3 mm)  |
| Vent moyen      | 8.0 km/h    | **12.0 km/h** | Lyon plus venteux (+4.0 km/h) |

> La valeur la plus élevée est en gras (`.comparison-winner`). Pour les précipitations, « plus pluvieux » n'est pas nécessairement « meilleur » — c'est neutre, juste informatif.

#### CSS additionnel

Aucun nouveau style requis. La classe `.comparison-winner` existante (gras + couleur accent) est suffisante.

### 3.6 Fonctionnalité 4 — Navigation interne sticky

#### Principe

Ajouter une barre de navigation interne avec des liens-ancres vers les sections de résultats. Visible uniquement lorsque des résultats sont affichés. Position sticky pour rester visible au scroll.

#### Emplacement

Élément `<nav id="results-nav">` entre `#comparison-summary` (ou `#period-summary` en mode simple) et `#daily-summary`.

#### Liens

| Label          | Ancre            | Section cible   |
| -------------- | ---------------- | --------------- |
| Résumé         | `#daily-summary` | Résumé par jour |
| Graphique      | `#chart`         | Évolution météo |
| Détail horaire | `#results`       | Détail horaire  |

> Trois liens suffisent. Les sections SEO/résumé sont compactes et ne nécessitent pas de lien dédié.

#### Comportement

- **Masquée** par défaut (classe `hidden`)
- **Affichée** quand des résultats sont rendus (dans `renderSimpleResults` et `renderComparisonResults`)
- **Masquée** quand les résultats sont effacés (`clearResults`)
- Scroll fluide via `scroll-behavior: smooth` (CSS) + ancres classiques `<a href="#...">`
- Position sticky sous le hero header

#### HTML

```html
<nav
  id="results-nav"
  class="results-nav hidden"
  aria-label="Navigation résultats"
>
  <a href="#daily-summary">Résumé</a>
  <a href="#chart">Graphique</a>
  <a href="#results">Détail horaire</a>
</nav>
```

#### CSS

```css
.results-nav {
  position: sticky;
  top: 0;
  z-index: 10;
  display: flex;
  justify-content: center;
  gap: 1.5rem;
  padding: 0.6rem 1rem;
  background: var(--panel);
  border: 1px solid var(--border);
  border-radius: 10px;
  margin-bottom: 1rem;
}

.results-nav a {
  color: var(--accent);
  text-decoration: none;
  font-weight: 500;
  font-size: 0.95rem;
  padding: 0.3rem 0.6rem;
  border-radius: 6px;
  transition: background 0.15s;
}

.results-nav a:hover {
  background: var(--accent-soft);
}

html {
  scroll-behavior: smooth;
}
```

### 3.7 Fonctionnalité 5 — Liens jour précédent / jour suivant

#### Principe

Ajouter deux liens sous les résultats permettant de décaler la période d'un jour vers le passé ou le futur. Les liens modifient l'URL et relancent la recherche.

#### Emplacement

Élément `<div id="period-links">` entre `#results` et `#info`.

#### Calcul des nouvelles périodes

À partir de la période courante `[dateStart, dateEnd]` :

- **Jour précédent** : `[dateStart - 1 jour, dateEnd - 1 jour]`
- **Jour suivant** : `[dateStart + 1 jour, dateEnd + 1 jour]`

#### Contraintes de validation

- **Lien précédent désactivé** si `dateStart - 1 jour < 1940-01-01`
- **Lien suivant désactivé** si `dateEnd + 1 jour > hier` (au-delà de `maxDateValue`)
- La commune est conservée. En mode comparaison, les deux communes sont conservées.

#### Comportement au clic

Un clic sur un lien :

1. Met à jour `dateStart.value` et `dateEnd.value` avec les nouvelles dates
2. Retire la classe `.active` des boutons preset (`clearPresetActiveState()`)
3. Appelle `performSearch()` pour relancer la recherche
4. Le scroll remonte automatiquement vers `#seo-question` (ou `#daily-summary` si pas de question)

> Technique : ce ne sont pas des `<a>` avec href — ce sont des `<button>` qui déclenchent un changement de dates + recherche. Cela évite un rechargement de page et reste cohérent avec INV-6 (page unique, state JS).

#### HTML

```html
<div id="period-links" class="period-links hidden">
  <button type="button" id="prev-period" class="btn-period-link" disabled>
    ← Période précédente
  </button>
  <button type="button" id="next-period" class="btn-period-link" disabled>
    Période suivante →
  </button>
</div>
```

#### JS — Fonction `renderPeriodLinks(startDate, endDate)`

```js
function renderPeriodLinks(startDate, endDate) {
  const start = new Date(startDate + "T00:00:00");
  const end = new Date(endDate + "T00:00:00");

  const prevStart = new Date(start);
  prevStart.setDate(prevStart.getDate() - 1);
  const prevEnd = new Date(end);
  prevEnd.setDate(prevEnd.getDate() - 1);

  const nextStart = new Date(start);
  nextStart.setDate(nextStart.getDate() + 1);
  const nextEnd = new Date(end);
  nextEnd.setDate(nextEnd.getDate() + 1);

  const minDate = new Date(MIN_HISTORICAL_DATE + "T00:00:00");
  const maxDate = new Date(maxDateValue + "T00:00:00");

  const canPrev = prevStart >= minDate;
  const canNext = nextEnd <= maxDate;

  prevPeriodButton.disabled = !canPrev;
  nextPeriodButton.disabled = !canNext;

  // Format labels with dates for clarity
  const fmt = (d) => d.toISOString().slice(0, 10);

  prevPeriodButton.textContent = canPrev
    ? `← ${formatSummaryDate(fmt(prevStart))} — ${formatSummaryDate(fmt(prevEnd))}`
    : "← Période précédente";
  nextPeriodButton.textContent = canNext
    ? `${formatSummaryDate(fmt(nextStart))} — ${formatSummaryDate(fmt(nextEnd))} →`
    : "Période suivante →";

  periodLinksDiv.classList.remove("hidden");
}
```

Event listeners :

```js
prevPeriodButton.addEventListener("click", () => shiftPeriod(-1));
nextPeriodButton.addEventListener("click", () => shiftPeriod(+1));

function shiftPeriod(direction) {
  const start = new Date(dateStart.value + "T00:00:00");
  const end = new Date(dateEnd.value + "T00:00:00");
  start.setDate(start.getDate() + direction);
  end.setDate(end.getDate() + direction);
  dateStart.value = start.toISOString().slice(0, 10);
  dateEnd.value = end.toISOString().slice(0, 10);
  clearPresetActiveState();
  isDateRangeValid();
  performSearch();
  // Scroll vers le haut des résultats
  (
    document.getElementById("seo-question") ||
    document.getElementById("daily-summary")
  ).scrollIntoView({ behavior: "smooth" });
}
```

#### CSS

```css
.period-links {
  display: flex;
  justify-content: space-between;
  gap: 1rem;
  margin: 1rem 0;
  flex-wrap: wrap;
}

.btn-period-link {
  flex: 1;
  min-width: 0;
  padding: 0.5rem 1rem;
  font-size: 0.9rem;
  background: transparent;
  border: 1px solid var(--accent);
  color: var(--accent);
  border-radius: 8px;
  cursor: pointer;
  text-align: center;
}

.btn-period-link:hover:not([disabled]) {
  background: var(--accent);
  color: #fff;
}

.btn-period-link[disabled] {
  opacity: 0.4;
  cursor: not-allowed;
}
```

### 3.8 Amélioration R10 — Correction `requirements.txt`

Remplacer :

```
pytest-asyncio==1.3.0
```

Par :

```
pytest-asyncio==0.25.3
```

> La version effectivement installée et testée dans l'environnement est `0.25.3`, pas `1.3.0`.

### 3.9 Amélioration R11 — Toggle déplier/replier en mode comparaison

#### Problème actuel

En mode comparaison, le bouton « Tout déplier / replier » est masqué. Les deux blocs d'accordéon (un par ville) n'ont pas de moyen de tout ouvrir/fermer.

#### Solution

Ajouter un bouton toggle **par bloc ville** dans `renderComparisonHourlyResults`. Chaque bouton contrôle uniquement les `<details>` de son bloc.

#### Implémentation

La fonction `renderDayGroups(hourlyData, dailySummaries, targetContainer, toggleButton)` accepte déjà un paramètre `toggleButton`. En mode comparaison, créer un `<button>` par ville et le passer à `renderDayGroups` :

```js
function renderComparisonHourlyResults(result1, result2) {
  dayGroups.replaceChildren();

  const cities = [
    { result: result1, name: formatCommuneLabel(selectedCommune) },
    { result: result2, name: formatCommuneLabel(selectedCommune2) },
  ];

  for (const city of cities) {
    const title = document.createElement("h3");
    title.className = "comparison-city-title";
    title.textContent = city.name;
    dayGroups.appendChild(title);

    const toggle = document.createElement("button");
    toggle.type = "button";
    toggle.className = "btn-secondary";
    toggle.textContent = "Tout déplier";
    dayGroups.appendChild(toggle);

    const container = document.createElement("div");
    dayGroups.appendChild(container);

    renderDayGroups(
      city.result.data,
      city.result.daily_summary,
      container,
      toggle,
    );
  }

  resultsSection.classList.remove("hidden");
}
```

> Le bouton global `#toggle-all-days` reste masqué en mode comparaison (comportement inchangé). Les boutons locaux par ville prennent le relais.

### 3.10 Amélioration R12 — Factorisation du résumé journalier

#### Problème actuel

`createDailySummaryTable(summaries)` et `renderDailySummary(summaries)` construisent les mêmes lignes `<tr>` avec la même logique (date, temp min, temp max, pluie, humidité, vent, conditions). La duplication est mineure mais peut être réduite.

#### Solution

Extraire une fonction commune `buildSummaryRows(summaries)` retournant un `DocumentFragment` de `<tr>` :

```js
function buildSummaryRows(summaries) {
  const fragment = document.createDocumentFragment();
  for (const day of summaries) {
    const tr = document.createElement("tr");
    const cells = [
      formatSummaryDate(day.date),
      valueOrDash(day.temp_min, (v) => v.toFixed(1)),
      valueOrDash(day.temp_max, (v) => v.toFixed(1)),
      valueOrDash(day.precipitation_sum, (v) => v.toFixed(1)),
      valueOrDash(day.humidity_avg, (v) => Math.round(v).toString()),
      valueOrDash(day.wind_speed_avg, (v) => v.toFixed(1)),
      day.icon && day.description ? `${day.icon} ${day.description}` : "—",
    ];
    for (const text of cells) {
      const td = document.createElement("td");
      td.textContent = text;
      tr.appendChild(td);
    }
    fragment.appendChild(tr);
  }
  return fragment;
}
```

Puis `renderDailySummary` et `createDailySummaryTable` appellent `buildSummaryRows` :

```js
function renderDailySummary(summaries) {
  dailySummaryBody.replaceChildren(buildSummaryRows(summaries));
}

function createDailySummaryTable(summaries) {
  // Crée le wrapper div + table + thead comme avant
  // Mais le tbody utilise buildSummaryRows(summaries) au lieu de réécrire la boucle
}
```

---

## 4) Plan d'implémentation

### Étape 1 — R10 : Corriger `requirements.txt`

**Fichier** : `requirements.txt`

- Remplacer `pytest-asyncio==1.3.0` par `pytest-asyncio==0.25.3`

**Testable** : `pip install -r requirements.txt` installe la bonne version. `pytest` passe.

---

### Étape 2 — R12 : Factoriser `buildSummaryRows`

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

- Extraire `buildSummaryRows(summaries)` depuis le code commun de `renderDailySummary` et `createDailySummaryTable`
- `renderDailySummary` appelle `buildSummaryRows` puis `replaceChildren`
- `createDailySummaryTable` appelle `buildSummaryRows` pour remplir le `<tbody>`

**Testable** : le résumé journalier s'affiche identiquement en mode simple et comparaison. Aucune régression visuelle.

---

### Étape 3 — R11 : Toggle par bloc ville en comparaison

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

- Modifier `renderComparisonHourlyResults` pour créer un bouton toggle par ville
- Passer ce bouton à `renderDayGroups` en tant que `toggleButton`
- Le bouton global `#toggle-all-days` reste masqué en mode comparaison

**Testable** : en mode comparaison, chaque bloc ville affiche un bouton « Tout déplier / replier » fonctionnel.

---

### Étape 4 — Nouvelles sections HTML + CSS

**Fichiers** : `public/index.html`, `public/style.css`

- Ajouter `<section id="seo-question">` après `#global-error`
- Ajouter `<section id="period-summary">` après `#seo-question`
- Déplacer `<section id="comparison-summary">` à sa position actuelle (déjà correcte)
- Ajouter `<nav id="results-nav">` après `#comparison-summary`
- Ajouter `<div id="period-links">` entre `#results` et `#info`
- Ajouter les styles CSS pour `.seo-question-text`, `.results-nav`, `.period-links`, `.btn-period-link`
- Ajouter `html { scroll-behavior: smooth; }`

**Testable** : la page charge sans erreur, les nouvelles sections sont masquées par défaut.

---

### Étape 5 — JS : Bloc question SEO + résumé automatique

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

- Déclarer les refs DOM des nouvelles sections (`seoQuestionSection`, `seoQuestionText`, `periodSummarySection`, `periodSummaryBody`)
- Implémenter `renderSeoQuestion(commune1Name, commune2Name, startDate, endDate)`
- Implémenter `buildPeriodSummaryParagraph(agg, communeName)`
- Implémenter `renderPeriodSummary(agg, communeName)` (mode simple)
- Implémenter `renderComparisonPeriodSummary(agg1, agg2, nom1, nom2)` (mode comparaison)
- Appeler dans `renderSimpleResults` et `renderComparisonResults`
- Masquer les sections dans `clearResults`

**Testable** : effectuer une recherche → la question SEO s'affiche, le résumé textuel s'affiche avec les données correctes.

---

### Étape 6 — JS : Navigation interne + liens période

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

- Déclarer les refs DOM (`resultsNav`, `periodLinksDiv`, `prevPeriodButton`, `nextPeriodButton`)
- Afficher `resultsNav` dans `renderSimpleResults` et `renderComparisonResults`
- Masquer `resultsNav` dans `clearResults`
- Implémenter `renderPeriodLinks(startDate, endDate)`
- Implémenter `shiftPeriod(direction)`
- Connecter les event listeners sur les boutons prev/next
- Valider les limites (1940 / hier)

**Testable** : recherche affichée → nav sticky visible → clic sur ancre → scroll fluide. Liens période visibles → clic → nouvelle période chargée.

---

### Étape 7 — JS : Comparaison améliorée (delta + badge)

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

- Modifier `renderComparisonSummary` pour afficher un delta numérique et un badge dans la colonne Note
- Appliquer `.comparison-winner` sur la cellule de la valeur dominante (pas seulement dans la colonne Note)

**Testable** : recherche comparative → le tableau comparatif affiche des deltas et badges corrects.

---

## 5) Guide pour le Développeur

### Pièges fréquents

1. **Formatage des dates dans la question SEO** : utiliser `toLocaleDateString("fr-FR", { day: "numeric", month: "long", year: "numeric" })` et non pas un formatage manuel. Attention : passer `new Date(isoDate + "T00:00:00")` pour éviter les décalages de fuseau (une date ISO sans heure est interprétée en UTC par le constructeur `Date`, ce qui peut donner la veille en UTC+1).

2. **Scroll behavior** : `scroll-behavior: smooth` sur `html` est suffisant pour les ancres `<a href="#...">`. Pour le `scrollIntoView` programmatique (liens période), passer `{ behavior: "smooth" }`. Ne pas ajouter de polyfill — les navigateurs modernes le supportent.

3. **`shiftPeriod` et `performSearch`** : `shiftPeriod` modifie les champs date puis relance `performSearch`. Ne pas appeler `updateSearchButtonState()` entre les deux — les dates sont forcément valides car on vient de les calculer en respectant les contraintes. L'appel à `isDateRangeValid()` suffit pour mettre à jour le message de validation.

4. **Nouvelle section masquée dans `clearResults`** : Penser à masquer les 4 nouvelles sections (`seoQuestionSection`, `periodSummarySection`, `resultsNav`, `periodLinksDiv`) dans `clearResults`. Oublier l'une d'entre elles laisserait une section orpheline visible après effacement des résultats.

5. **`buildSummaryRows` et cohérence** : Lors de la factorisation R12, s'assurer que l'ordre des colonnes dans `buildSummaryRows` correspond exactement au `<thead>` défini dans le HTML. L'ordre est : date, temp min, temp max, pluie, humidité moy., vent moy., conditions.

### Zones de dérive

- **Ne pas ajouter de sections supplémentaires** dans la navigation interne (UV, pression, etc.) — se limiter aux 3 ancres spécifiées.
- **Ne pas générer de contenu IA** pour le résumé — le texte est purement template + données numériques.
- **Ne pas créer de routes backend** pour les nouvelles fonctionnalités — tout est frontend.
- **Ne pas modifier la structure de réponse de `/api/weather`** ni aucun fichier dans `src/`.
- **Ne pas ajouter de `localStorage`** pour mémoriser la dernière période consultée — INV-1.

### Simplifications autorisées

- La navigation interne peut être un simple `<nav>` avec des `<a>` — pas besoin de `IntersectionObserver` pour surligner le lien actif. C'est une V1 minimale.
- Les liens période affichent les dates dans les boutons — pas besoin d'un tooltip.
- Le résumé textuel est un template fixe. Pas besoin de variantes stylistiques aléatoires.
- En mobile, la navigation sticky peut revenir en `position: static` si elle prend trop de place. Non obligatoire mais autorisé.

### Décisions explicitement interdites

- Modifier la structure de la réponse de `/api/weather`
- Modifier un fichier dans `src/`
- Ajouter un routeur frontend
- Introduire un bundler, un state manager ou un framework JS
- Ajouter du `localStorage`, `sessionStorage` ou des cookies

---

## 6) Stratégie de tests

### Tests unitaires backend

Les 27 tests existants restent inchangés et doivent continuer à passer. Aucun nouveau test backend n'est nécessaire (toutes les modifications sont frontend).

### Vérification `requirements.txt`

| Vérification                      | Résultat attendu                              |
| --------------------------------- | --------------------------------------------- |
| `pip install -r requirements.txt` | Installe `pytest-asyncio==0.25.3` sans erreur |
| `pytest`                          | 27 tests PASSING, 0 SKIPPED                   |

### Tests manuels frontend (checklist)

| #   | Scénario                                           | Résultat attendu                                            |
| --- | -------------------------------------------------- | ----------------------------------------------------------- |
| M12 | Recherche simple → question SEO visible            | Texte « Quel temps faisait-il à {commune} du ... au ... ? » |
| M13 | Recherche simple, période 1 jour                   | Texte « Quel temps faisait-il à {commune} le ... ? »        |
| M14 | Recherche comparaison → question SEO               | Texte inclut les deux noms de villes                        |
| M15 | Recherche simple → résumé période visible          | Paragraphe avec tempMin, tempMax, précipTotal, condition    |
| M16 | Recherche comparaison → résumé période             | Deux paragraphes, un par ville                              |
| M17 | Résultats affichés → navigation interne visible    | 3 liens : Résumé, Graphique, Détail horaire                 |
| M18 | Clic sur ancre « Graphique »                       | Scroll fluide vers la section graphique                     |
| M19 | Navigation sticky au scroll                        | La barre reste visible en haut de page                      |
| M20 | Liens période visibles après résultats             | Deux boutons avec dates calculées                           |
| M21 | Clic « Période précédente »                        | Dates décalées -1 jour, recherche relancée                  |
| M22 | Clic « Période suivante »                          | Dates décalées +1 jour, recherche relancée                  |
| M23 | Lien suivant désactivé si dateEnd = hier           | Bouton grisé, non cliquable                                 |
| M24 | Lien précédent désactivé si dateStart ≤ 1940-01-01 | Bouton grisé, non cliquable                                 |
| M25 | Comparaison → delta dans tableau comparatif        | Colonne Note affiche « {ville} +X °C »                      |
| M26 | Comparaison → winner highlighted                   | Valeur dominante en gras accent                             |
| M27 | Comparaison → toggle déplier/replier par ville     | Chaque bloc ville a son propre bouton toggle fonctionnel    |
| M28 | Responsive 360px — nav interne                     | S'affiche sans débordement                                  |
| M29 | Responsive 360px — liens période                   | Empilés verticalement                                       |
| M30 | Annuler comparaison → nouvelles sections masquées  | seo-question, résumé, nav, liens tous masqués               |
| M31 | Aucun résultat → nouvelles sections masquées       | Toutes les nouvelles sections restent hidden                |

### Edge cases

- Période à cheval sur 1940-01-01 : le lien précédent doit être désactivé si `dateStart - 1 < 1940-01-01`
- Période d'un seul jour → le texte SEO utilise « le » au lieu de « du ... au ... »
- Résumé avec toutes les valeurs null (rare mais possible pour des dates très anciennes) → affiche « — » pour les valeurs manquantes, condition « variées »
- Mode comparaison annulé puis relancé en mode simple → les sections comparaison sont nettoyées, les sections simples s'affichent correctement
- Lien période cliqué en mode comparaison → les deux communes sont conservées, la recherche comparative est relancée

---

## 7) Risques techniques

| #   | Risque                                                                                                                                               | Mitigation                                                                                                                                                                                                                                             |
| --- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| 1   | **Surcharge visuelle** — 3 nouvelles sections au-dessus des résultats tabulaires augmentent la distance entre la recherche et les données détaillées | La navigation sticky compense : l'utilisateur peut toujours accéder aux sections profondes en un clic. Les nouvelles sections sont compactes (1 paragraphe chacune).                                                                                   |
| 2   | **Liens période et race condition** — l'utilisateur clique rapidement sur prev/next avant la fin du chargement                                       | Le bouton Rechercher passe en « Chargement... » (comportement existant). `shiftPeriod` appelle `performSearch` qui gère déjà le loading state. Pas de risque de double requête car `performSearch` est synchrone dans son exécution (une seule await). |
| 3   | **Taille de `app.js`** — le fichier va dépasser ~1500 lignes                                                                                         | Acceptable pour un MVP. La factorisation R12 et la clarté des fonctions (noms descriptifs, fonctions pures) maintiennent la lisibilité. Pas de refactoring en modules cette itération.                                                                 |
