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

> **Itération** : v3 — intègre le feedback Reviewer (`feedback-to-architect-001-v2.md`) + trois nouvelles fonctionnalités (graphique météo, URL partageable, regroupement par jour).

---

## 0) Contract

- **Source of truth** : ce document (`001-histometeo-mvp.tech.v3.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/` — fichiers frontend (HTML, CSS, JS)
  - `src/` — code backend Python
  - `tests/` — tests (y compris `conftest.py`)
  - Fichiers racine : `requirements.txt`, `.env.example`, `Dockerfile`, `.dockerignore`, `pyproject.toml`, `README.md`
- **Forbidden changes** :
  - `docs/` — aucune modification des specs existantes
  - `.github/` — aucune modification du workflow agent
- **Invariants** :
  - 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, mais l'URL reflète l'état de recherche via query parameters
  - INV-7 : Aucune injection HTML — tout contenu dynamique est inséré via `textContent` ou création d'éléments DOM, jamais via `innerHTML` avec des données non-échappées
- **Done when** :
  - Les 11 critères d'acceptation originaux (AC1–AC11) restent vérifiables
  - Les 5 améliorations recommandées du feedback v2 (R1–R5) sont intégrées
  - Les 3 nouvelles fonctionnalités sont opérationnelles : graphique météo, URL partageable, regroupement par jour
  - **Tous** les tests passent (aucun test SKIPPED, aucun DeprecationWarning pytest-asyncio)
  - L'application se lance via `uvicorn src.main:app` ou `docker compose up`

---

## 1) Objectif technique

Enrichir l'application HistoMétéo MVP avec trois fonctionnalités et intégrer les améliorations mineures du feedback v2 :

1. **Graphique météo** — Visualiser l'évolution de la température (courbe) et des précipitations (barres) sur la période sélectionnée, au-dessus du tableau horaire
2. **URL partageable** — Encoder les paramètres de recherche dans l'URL pour permettre le partage direct d'un résultat météo
3. **Regroupement par jour** — Réorganiser le tableau horaire en sections dépliables par jour pour améliorer la lisibilité sur les longues périodes
4. **Corrections mineures feedback v2** — Accents nom application, typographie française, tests edge cases manquants, deprecation pytest-asyncio, cohérence Dockerfile

---

## 2) Analyse du brief

### Nouveaux besoins fonctionnels

| Besoin                                      | Complexité | Risque                                              |
| ------------------------------------------- | ---------- | --------------------------------------------------- |
| Graphique météo (Chart.js)                  | Moyenne    | Faible — librairie mature, données déjà disponibles |
| URL partageable (query params)              | Faible     | Faible — API native `URLSearchParams`               |
| Regroupement par jour (sections dépliables) | Moyenne    | Faible — restructuration du rendu existant          |

### Améliorations feedback v2 (R1–R5)

| Amélioration                                                                                                    | Nature                   |
| --------------------------------------------------------------------------------------------------------------- | ------------------------ |
| R1 — Accents nom application : `HistoMeteo` → `HistoMétéo` dans `<title>` et `<h1>`                             | INV-5 complétude         |
| R2 — Typographie française : espace avant `:` dans les labels                                                   | Convention typographique |
| R3 — Tests edge cases manquants (période 31j exactement, coordonnées hors range, résumé all-null weather codes) | Couverture               |
| R4 — Deprecation pytest-asyncio : ajouter `asyncio_default_fixture_loop_scope`                                  | Configuration            |
| R5 — Cohérence Dockerfile : aligner version Python                                                              | Infrastructure           |

### Contraintes

- **Chart.js** est la seule dépendance frontend ajoutée. Chargée via CDN (pas de bundler, pas de `node_modules`). Version fixée dans l'URL CDN.
- **Pas de Next.js** — le projet est en vanilla JS + FastAPI. Les URL partageables utilisent l'API native `URLSearchParams` + `history.replaceState`.
- Le graphique est **purement frontend** — aucune modification du backend pour cette fonctionnalité.
- Le regroupement par jour est **purement frontend** — réutilise les données `daily_summary` et `data` déjà retournées par l'API.

---

## 3) Design minimal proposé

### 3.1 Architecture globale

L'architecture backend reste **inchangée** : mêmes routes, mêmes services, même structure de réponse API. Les trois nouvelles fonctionnalités sont des enrichissements **frontend uniquement** (sauf les tests et corrections mineures).

### 3.2 Vue d'ensemble des modifications

```
Backend (modifications mineures)
├── src/main.py              → Accents titre FastAPI (déjà fait), aucun changement fonctionnel
├── public/index.html         → R1 accents <title>/<h1>, R2 typographie, section graphique, restructuration tableau
├── Dockerfile               → R5 version Python
├── pyproject.toml           → R4 asyncio_default_fixture_loop_scope
└── tests/                   → R3 edge cases + tests nouvelles fonctionnalités frontend

Frontend (modifications principales)
├── public/index.html         → Nouvelle section #chart, restructuration #results pour groupement jour
├── public/app.js             → Chart.js rendering, URL sync, regroupement jour, toggle sections
├── public/style.css          → Styles graphique, accordéon jour, transitions
```

### 3.3 Fonctionnalité 1 — Graphique météo

#### Choix technique : Chart.js

- **Librairie** : Chart.js v4.x — librairie standalone légère (~60 KB gzip), largement adoptée, responsive par défaut
- **Chargement** : CDN avec version fixée : `https://cdn.jsdelivr.net/npm/chart.js@4.4.8/dist/chart.umd.min.js`
- **Attribut** : `integrity` + `crossorigin="anonymous"` pour la sécurité SRI (Subresource Integrity)
- **Fallback** : si le CDN échoue, la section graphique est masquée — le tableau reste fonctionnel

#### Structure du graphique

- **Type** : graphique mixte (`mixed chart`) — courbe (ligne) pour la température, barres pour les précipitations
- **Axe X** : temps (labels issus du champ `time` des données horaires, formatés en `"JJ/MM HHh"`)
- **Axe Y gauche** : température en °C (ligne)
- **Axe Y droit** : précipitations en mm (barres)
- **Responsive** : `responsive: true`, `maintainAspectRatio: false` dans un conteneur à hauteur fixe
- **Interaction** : tooltip au survol affichant les valeurs exactes

#### Gestion des longues périodes

Pour les périodes > 7 jours, le graphique contient potentiellement 744 points (31 × 24). Chart.js gère bien cette volumétrie nativement. Optimisations :

- `pointRadius: 0` pour ne pas dessiner les points individuels (performance + lisibilité)
- `pointHitRadius: 5` pour que les tooltips fonctionnent tout de même au survol
- `tension: 0.3` pour une courbe lissée
- Labels X : afficher un tick toutes les 24h (minuit) pour éviter la surcharge. Utiliser `ticks.autoSkip: true` avec `maxTicksLimit` calculé dynamiquement selon le nombre de jours

#### Emplacement dans le DOM

La section graphique est placée **entre la section `#daily-summary` et la section `#results`** :

```
#search           → Formulaire de recherche
#daily-summary    → Résumé journalier (tableau existant)
#chart            → NOUVEAU : Graphique météo
#results          → Tableau horaire (restructuré avec accordéon par jour)
#info             → Note de transparence
```

#### HTML

```html
<section id="chart" class="panel hidden" aria-label="Graphique météo">
  <h2>Évolution météo</h2>
  <div class="chart-container">
    <canvas id="weather-chart"></canvas>
  </div>
</section>
```

#### JS — Fonction `renderChart(hourlyData)`

```javascript
let weatherChart = null; // référence globale pour destroy avant re-render

function renderChart(hourlyData) {
  const canvas = document.getElementById("weather-chart");
  if (!canvas || typeof Chart === "undefined") return; // fallback si CDN échoue

  // Destroy previous chart instance
  if (weatherChart) {
    weatherChart.destroy();
    weatherChart = null;
  }

  const labels = hourlyData.map((row) => formatChartLabel(row.time));
  const temperatures = hourlyData.map((row) => row.temperature);
  const precipitations = hourlyData.map((row) => row.precipitation);

  const nbDays = new Set(hourlyData.map((r) => r.time.split("T")[0])).size;

  weatherChart = new Chart(canvas, {
    data: {
      labels,
      datasets: [
        {
          type: "line",
          label: "Température (°C)",
          data: temperatures,
          borderColor: "#e07a3a",
          backgroundColor: "rgba(224, 122, 58, 0.1)",
          fill: true,
          yAxisID: "y-temp",
          tension: 0.3,
          pointRadius: 0,
          pointHitRadius: 5,
          spanGaps: true, // connecte les points même si null entre eux
        },
        {
          type: "bar",
          label: "Précipitations (mm)",
          data: precipitations,
          backgroundColor: "rgba(54, 162, 235, 0.6)",
          borderColor: "rgba(54, 162, 235, 1)",
          borderWidth: 1,
          yAxisID: "y-precip",
        },
      ],
    },
    options: {
      responsive: true,
      maintainAspectRatio: false,
      interaction: { mode: "index", intersect: false },
      plugins: {
        tooltip: {
          callbacks: {
            title: (items) => items[0]?.label || "",
          },
        },
        legend: { position: "top" },
      },
      scales: {
        x: {
          ticks: {
            autoSkip: true,
            maxTicksLimit: Math.min(nbDays + 1, 32),
            callback: function (val, idx) {
              // Afficher seulement les labels à minuit
              const label = this.getLabelForValue(val);
              return label.endsWith("00h") ? label : "";
            },
          },
        },
        "y-temp": {
          type: "linear",
          position: "left",
          title: { display: true, text: "°C" },
        },
        "y-precip": {
          type: "linear",
          position: "right",
          title: { display: true, text: "mm" },
          beginAtZero: true,
          grid: { drawOnChartArea: false },
        },
      },
    },
  });
}
```

#### Fonction utilitaire `formatChartLabel(isoTime)`

Formate le timestamp pour l'axe X du graphique :

```javascript
function formatChartLabel(isoTime) {
  const [datePart, timePart] = isoTime.split("T");
  const [, month, day] = datePart.split("-");
  const [hour] = timePart.split(":");
  return `${day}/${month} ${hour}h`;
}
```

#### CSS

```css
.chart-container {
  position: relative;
  height: 300px;
  width: 100%;
}

@media (min-width: 768px) {
  .chart-container {
    height: 400px;
  }
}
```

### 3.4 Fonctionnalité 2 — URL partageable

#### Principe

Les paramètres de recherche sont encodés dans les query parameters de l'URL. Cela permet de partager un lien direct vers un résultat météo.

**Format URL** : `?commune=<nom>&lat=<float>&lon=<float>&start=<YYYY-MM-DD>&end=<YYYY-MM-DD>`

Exemple : `?commune=huttenheim&lat=48.569&lon=7.596&start=2026-03-02&end=2026-03-05`

#### Paramètres URL

| Paramètre | Type                | Obligatoire | Description                    |
| --------- | ------------------- | ----------- | ------------------------------ |
| `commune` | string              | Oui         | Nom de la commune (URL-encodé) |
| `lat`     | float               | Oui         | Latitude                       |
| `lon`     | float               | Oui         | Longitude                      |
| `start`   | string (YYYY-MM-DD) | Oui         | Date de début                  |
| `end`     | string (YYYY-MM-DD) | Oui         | Date de fin                    |

> **Note** : `lat` et `lon` sont nécessaires dans l'URL car le nom de commune seul est ambigu (homonymes). L'utilisateur n'a pas besoin de les connaître — ils sont ajoutés automatiquement lors de la sélection.

#### Comportement au chargement de la page

1. Lire les paramètres via `new URLSearchParams(window.location.search)`
2. Si **tous les 5 paramètres** sont présents et valides :
   - Pré-remplir le champ commune avec la valeur de `commune`
   - Stocker `lat`/`lon` dans les variables JS (comme si l'utilisateur avait sélectionné la commune)
   - Pré-remplir les champs date de début et fin
   - Déclencher automatiquement la recherche (`fetchWeather()`)
3. Si les paramètres sont **partiels ou invalides** :
   - Ignorer silencieusement les paramètres (comportement normal de la page vide)
   - Ne pas afficher de message d'erreur spécifique pour les paramètres URL invalides — l'utilisateur lance une recherche manuellement et l'erreur de validation standard s'affichera si besoin

#### Mise à jour de l'URL après chaque recherche

Après une recherche réussie (`fetchWeather` retourne 200), mettre à jour l'URL sans recharger la page :

```javascript
function updateURL(commune, lat, lon, start, end) {
  const params = new URLSearchParams({
    commune: commune,
    lat: String(lat),
    lon: String(lon),
    start: start,
    end: end,
  });
  history.replaceState(null, "", `?${params.toString()}`);
}
```

- Utiliser `history.replaceState` (pas `pushState`) pour ne pas polluer l'historique de navigation
- Si une nouvelle recherche est lancée, l'URL est mise à jour avec les nouveaux paramètres

#### Validation des paramètres URL

Les paramètres URL sont validés **côté frontend uniquement**, avant pré-remplissage :

- `lat` : parseable en float, compris entre -90 et 90
- `lon` : parseable en float, compris entre -180 et 180
- `start` / `end` : format `YYYY-MM-DD`, dates parseable, `start <= end`
- `commune` : string non vide après trim

Si une validation échoue → ignorer tous les paramètres URL.

### 3.5 Fonctionnalité 3 — Regroupement par jour (accordéon)

#### Principe

Les données horaires sont regroupées par jour dans des sections dépliables de type accordéon. Chaque section affiche le jour avec son résumé (réutilisation des données `daily_summary`) et peut être déployée pour voir les 24 lignes horaires.

#### Structure DOM

Le conteneur `#results` est restructuré. Au lieu d'un seul `<table>` avec un `<tbody>` contenant toutes les lignes, la structure devient :

```html
<section id="results" class="panel hidden">
  <h2>Détail horaire</h2>
  <div id="day-groups">
    <!-- Généré dynamiquement par JS -->
    <!--
    <details class="day-group" open>
      <summary class="day-header">
        <span class="day-date">Lundi 2 mars 2026</span>
        <span class="day-summary">☁️ 2°C → 8°C · 1.2 mm</span>
      </summary>
      <div class="table-wrapper">
        <table>
          <thead>...</thead>
          <tbody>...24 lignes horaires...</tbody>
        </table>
      </div>
    </details>
    -->
  </div>
</section>
```

#### Choix technique : `<details>` / `<summary>` natifs

- Éléments HTML5 natifs — pas besoin de JS pour le toggle
- Accessibilité native (ARIA intégré, clavier navigable)
- Attribut `open` pour contrôler l'état initial

#### État initial des sections

- **Période ≤ 3 jours** : toutes les sections ouvertes (`open` attribut présent)
- **Période > 3 jours** : toutes les sections fermées (l'utilisateur ouvre celles qui l'intéressent)

Cela garantit une lisibilité immédiate pour les courtes périodes tout en évitant la surcharge visuelle pour les longues.

#### Bouton "Tout déplier / Tout replier"

Un bouton global au-dessus de l'accordéon permet de déplier ou replier toutes les sections d'un coup :

```html
<button id="toggle-all-days" type="button" class="btn-secondary">
  Tout déplier
</button>
```

Le texte du bouton alterne entre "Tout déplier" et "Tout replier" selon l'état actuel (majorité ouverte ou fermée).

#### JS — Fonction `renderDayGroups(hourlyData, dailySummaries)`

```javascript
function renderDayGroups(hourlyData, dailySummaries) {
  const container = document.getElementById("day-groups");
  container.innerHTML = ""; // safe — pas de données dynamiques

  // Grouper les données horaires par date
  const grouped = groupByDate(hourlyData);
  const summaryMap = Object.fromEntries(dailySummaries.map((s) => [s.date, s]));
  const nbDays = Object.keys(grouped).length;

  Object.entries(grouped).forEach(([dateStr, hours]) => {
    const summary = summaryMap[dateStr];
    const details = document.createElement("details");
    details.className = "day-group";
    if (nbDays <= 3) details.setAttribute("open", "");

    // <summary> header
    const summaryEl = document.createElement("summary");
    summaryEl.className = "day-header";

    const dateSpan = document.createElement("span");
    dateSpan.className = "day-date";
    dateSpan.textContent = formatDayHeader(dateStr);

    const infoSpan = document.createElement("span");
    infoSpan.className = "day-summary";
    infoSpan.textContent = formatDaySummaryInline(summary);

    summaryEl.appendChild(dateSpan);
    summaryEl.appendChild(infoSpan);
    details.appendChild(summaryEl);

    // Table for hourly data
    const wrapper = document.createElement("div");
    wrapper.className = "table-wrapper";
    const table = buildHourlyTable(hours);
    wrapper.appendChild(table);
    details.appendChild(wrapper);

    container.appendChild(details);
  });
}
```

#### Fonction `groupByDate(hourlyData)`

```javascript
function groupByDate(hourlyData) {
  const groups = {};
  hourlyData.forEach((row) => {
    const date = row.time.split("T")[0];
    if (!groups[date]) groups[date] = [];
    groups[date].push(row);
  });
  return groups;
}
```

#### Fonction `formatDayHeader(dateStr)`

Formate la date pour l'en-tête de section : `"Lundi 2 mars 2026"`

```javascript
function formatDayHeader(dateStr) {
  const [year, month, day] = dateStr.split("-");
  const dateObj = new Date(
    Date.UTC(parseInt(year), parseInt(month) - 1, parseInt(day)),
  );
  return new Intl.DateTimeFormat("fr-FR", {
    weekday: "long",
    day: "numeric",
    month: "long",
    year: "numeric",
    timeZone: "UTC",
  }).format(dateObj);
}
```

#### Fonction `formatDaySummaryInline(summary)`

Résumé compact pour l'en-tête de section : `"☁️ 2°C → 8°C · 1.2 mm"`

```javascript
function formatDaySummaryInline(summary) {
  if (!summary) return "";
  const icon = summary.icon || "";
  const tMin = summary.temp_min !== null ? `${summary.temp_min}°C` : "—";
  const tMax = summary.temp_max !== null ? `${summary.temp_max}°C` : "—";
  const precip =
    summary.precipitation_sum !== null
      ? `${summary.precipitation_sum} mm`
      : "—";
  return `${icon} ${tMin} → ${tMax} · ${precip}`;
}
```

#### Fonction `buildHourlyTable(hours)`

Construit un `<table>` pour les lignes horaires d'un seul jour. Mêmes colonnes que le tableau actuel, mais le header simplifié pour l'heure (pas besoin de répéter la date) :

```javascript
function buildHourlyTable(hours) {
  const table = document.createElement("table");

  const thead = document.createElement("thead");
  const headerRow = document.createElement("tr");
  [
    "Heure",
    "Temp. (°C)",
    "Précip. (mm)",
    "Humidité (%)",
    "Vent (km/h)",
    "Conditions",
  ].forEach((text) => {
    const th = document.createElement("th");
    th.textContent = text;
    headerRow.appendChild(th);
  });
  thead.appendChild(headerRow);
  table.appendChild(thead);

  const tbody = document.createElement("tbody");
  hours.forEach((row) => {
    const tr = document.createElement("tr");
    const hourStr = row.time.split("T")[1] || "00:00";
    const cells = [
      `${hourStr.split(":")[0]}h00`,
      row.temperature !== null ? row.temperature.toFixed(1) : "—",
      row.precipitation !== null ? row.precipitation.toFixed(1) : "—",
      row.humidity !== null ? String(row.humidity) : "—",
      row.wind_speed !== null ? row.wind_speed.toFixed(1) : "—",
      `${row.icon} ${row.description}`,
    ];
    cells.forEach((text) => {
      const td = document.createElement("td");
      td.textContent = text;
      tr.appendChild(td);
    });
    tbody.appendChild(tr);
  });
  table.appendChild(tbody);

  return table;
}
```

#### CSS — Styles accordéon

```css
.day-group {
  border: 1px solid var(--border);
  border-radius: 6px;
  margin-bottom: 0.5rem;
  overflow: hidden;
}

.day-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 0.75rem 1rem;
  background: var(--panel);
  cursor: pointer;
  font-weight: 600;
  user-select: none;
}

.day-header:hover {
  background: var(--bg);
}

.day-date {
  text-transform: capitalize;
}

.day-summary {
  font-weight: 400;
  color: var(--text-secondary, #666);
  font-size: 0.9rem;
}

.day-group[open] > .day-header {
  border-bottom: 1px solid var(--border);
}

.day-group .table-wrapper {
  padding: 0;
}

.day-group table {
  margin: 0;
  border-radius: 0;
}

.btn-secondary {
  background: transparent;
  border: 1px solid var(--accent);
  color: var(--accent);
  padding: 0.4rem 1rem;
  border-radius: 4px;
  cursor: pointer;
  font-size: 0.85rem;
  margin-bottom: 0.75rem;
}

.btn-secondary:hover {
  background: var(--accent);
  color: #fff;
}
```

### 3.6 Intégration feedback v2 — Corrections mineures

#### R1 — Accents nom application

Dans `public/index.html` :

- `<title>HistoMeteo</title>` → `<title>HistoMétéo</title>`
- `<h1>HistoMeteo</h1>` → `<h1>HistoMétéo</h1>`

#### R2 — Typographie française

Ajouter une espace avant les deux-points dans les labels statiques :

- `Période disponible:` → `Période disponible :`
- `Données:` → `Données :`

#### R3 — Tests edge cases manquants

Ajouter 3 tests :

1. **Période de 31 jours exactement** (`test_api.py`) : `start=2024-01-01`, `end=2024-02-01` (31 jours d'écart) → doit retourner 200
2. **Coordonnées hors range** (`test_api.py`) : `lat=100` → doit retourner 400 avec message d'erreur
3. **Résumé journalier all-null weather codes** (`test_weather_service.py`) : journée avec tous `weather_code: None` → doit retourner `DEFAULT_WMO` (icône `❓`, description `"Conditions non disponibles"`)

#### R4 — Deprecation pytest-asyncio

Ajouter dans `pyproject.toml` :

```toml
[tool.pytest.ini_options]
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"
```

#### R5 — Cohérence Dockerfile

Aligner la version Python du Dockerfile avec l'environnement de développement. Mettre à jour de `python:3.12-slim` à `python:3.14-slim` (ou à défaut `python:3.13-slim` si 3.14 n'a pas d'image slim officielle stable — vérifier au moment de l'implémentation et utiliser la version la plus récente disponible dans les images officielles Docker).

### 3.7 Flux de rendu complet après recherche (mise à jour)

Après un clic sur "Rechercher" ou un chargement par URL :

1. Afficher l'indicateur de chargement
2. Appeler `GET /api/weather`
3. En cas de succès :
   a. Mettre à jour l'URL via `history.replaceState` (§3.4)
   b. Rendre le résumé journalier (`renderDailySummary` — existant, inchangé)
   c. Rendre le graphique (`renderChart` — nouveau, §3.3)
   d. Rendre le tableau horaire en accordéon par jour (`renderDayGroups` — nouveau, §3.5)
   e. Afficher les sections `#daily-summary`, `#chart`, `#results`
4. En cas d'erreur : afficher le message d'erreur, masquer les sections résultats

### 3.8 Réponse API — Aucun changement

La structure de réponse `GET /api/weather` reste **strictement identique** à la v2 :

```json
{
  "data": [ { "time": "...", "temperature": ..., "precipitation": ..., "humidity": ..., "wind_speed": ..., "icon": "...", "description": "..." } ],
  "daily_summary": [ { "date": "...", "temp_min": ..., "temp_max": ..., "precipitation_sum": ..., "humidity_avg": ..., "wind_speed_avg": ..., "icon": "...", "description": "..." } ]
}
```

Aucune nouvelle route ni modification de payload.

---

## 4) Plan d'implémentation

### Étape 1 — Corrections mineures feedback v2 (R1, R2, R4, R5)

**Fichiers** : `public/index.html`, `pyproject.toml`, `Dockerfile`

- Remplacer `HistoMeteo` par `HistoMétéo` dans `<title>` et `<h1>` (R1)
- Corriger la typographie des deux-points (R2)
- Ajouter `asyncio_default_fixture_loop_scope = "function"` dans `pyproject.toml` (R4)
- Mettre à jour la version Python du Dockerfile (R5)

**Testable** : `pytest` passe sans DeprecationWarning. La page affiche `HistoMétéo`.

### Étape 2 — URL partageable

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

- Ajouter la fonction `updateURL(commune, lat, lon, start, end)`
- Ajouter la fonction `loadFromURL()` qui lit les query parameters au chargement
- Ajouter la validation des paramètres URL
- Appeler `updateURL` après chaque recherche réussie
- Appeler `loadFromURL` au `DOMContentLoaded`

**Testable** : Ouvrir `/?commune=paris&lat=48.86&lon=2.35&start=2025-01-01&end=2025-01-03` → les champs sont pré-remplis et la recherche se lance automatiquement. L'URL est mise à jour après une recherche manuelle.

### Étape 3 — Graphique météo (Chart.js)

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

- Ajouter le tag `<script>` Chart.js CDN dans `index.html` (avec SRI)
- Ajouter la section HTML `#chart`
- Implémenter `renderChart(hourlyData)` et `formatChartLabel(isoTime)` dans `app.js`
- Gérer le `destroy()` du chart précédent avant re-rendu
- Ajouter les styles CSS pour `.chart-container`

**Testable** : Après une recherche valide, un graphique s'affiche avec une courbe de température et des barres de précipitations. Le graphique est responsive et gère les périodes longues (31 jours).

### Étape 4 — Regroupement par jour (accordéon)

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

- Restructurer la section `#results` : remplacer le `<table>` unique par un conteneur `#day-groups`
- Implémenter `groupByDate`, `renderDayGroups`, `formatDayHeader`, `formatDaySummaryInline`, `buildHourlyTable`
- Ajouter le bouton "Tout déplier / Tout replier" avec son handler
- Ajouter les styles CSS pour l'accordéon (`.day-group`, `.day-header`, etc.)
- Retirer l'ancienne fonction `renderRows` (remplacée par `renderDayGroups`)

**Testable** : Les données horaires sont regroupées par jour dans des sections dépliables. Période ≤ 3 jours : sections ouvertes par défaut. Période > 3 jours : sections fermées. Le bouton toggle fonctionne.

### Étape 5 — Tests edge cases (R3) + tests Chart.js/URL

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

- Ajouter test période exactement 31 jours → accepté (R3a)
- Ajouter test coordonnées hors range → 400 (R3b)
- Ajouter test résumé journalier all-null weather codes → fallback `DEFAULT_WMO` (R3c)

**Testable** : `pytest -q` — tous les tests passent, aucun SKIPPED, aucun DeprecationWarning.

### Étape 6 — README

**Fichiers** : `README.md`

- Documenter les 3 nouvelles fonctionnalités (graphique, URL partageable, accordéon)
- Documenter le format de l'URL partageable

**Testable** : Le README reflète l'état actuel de l'application.

---

## 5) Guide pour le Développeur

### Pièges fréquents

- **Chart.js `destroy()`** : obligatoire avant de créer un nouveau chart sur le même canvas. Sans `destroy()`, les instances s'accumulent et provoquent des fuites mémoire et des rendus superposés.
- **Chart.js fallback CDN** : si le CDN est inaccessible (réseau, blocage), `typeof Chart === "undefined"` sera true. Le code doit vérifier ce cas et simplement ne pas afficher le graphique — pas d'erreur bloquante.
- **`history.replaceState`** : ne déclenche pas d'événement `popstate`. Pas de risque de boucle. Ne pas utiliser `pushState` pour éviter de polluer l'historique du navigateur.
- **Paramètres URL et `encodeURIComponent`** : `URLSearchParams` encode automatiquement les caractères spéciaux. Pas besoin d'encoder manuellement.
- **Ordre des `Object.entries(grouped)`** : l'ordre d'insertion dans un objet JS est garanti pour les clés string non-numériques depuis ES2015. Les dates ISO `"2024-01-15"` étant des chaînes, l'ordre d'insertion est préservé (ordre chronologique si les données horaires sont triées — ce qui est garanti par l'API Open-Meteo).
- **`<details>` open state** : l'attribut `open` est un booléen. `details.setAttribute("open", "")` l'ouvre, `details.removeAttribute("open")` le ferme.

### Zones de dérive à éviter

- **Ne pas calculer des données supplémentaires côté backend** pour le graphique — les mêmes données `data` servent au tableau et au graphique.
- **Ne pas ajouter de bibliothèque de routage** — `URLSearchParams` + `history.replaceState` suffisent.
- **Ne pas persister les paramètres URL dans `localStorage`** — l'URL est la seule source de partage (INV-1).
- **Ne pas ajouter de date picker** ou de bibliothèque de calendrier — les inputs `type="date"` natifs suffisent.
- **Ne pas ajouter d'animations CSS complexes** sur l'accordéon — les transitions sont gérées nativement par `<details>`.
- **Ne pas créer de nouveau fichier JS** — tout reste dans `app.js` (un seul fichier frontend JS, hors Chart.js CDN).

### Simplifications autorisées

- Chart.js est chargé depuis un CDN — pas de build step, pas de bundler, pas de `node_modules`.
- Les tooltips du graphique utilisent le formatter par défaut de Chart.js — pas de tooltip custom.
- Le bouton "Tout déplier" fait un simple `querySelectorAll("details").forEach(d => d.open = true/false)` — pas de système d'état complexe.
- Le champ commune est pré-rempli comme texte simple lors du chargement par URL — pas de re-fetch de la commune via l'API pour récupérer le département.

### Décisions explicitement interdites

- **Interdiction d'introduire un framework JS** (React, Vue, Next.js…).
- **Interdiction d'ajouter un bundler** (webpack, vite, esbuild…).
- **Interdiction d'utiliser `innerHTML`** avec des données dynamiques (INV-7).
- **Interdiction de stocker les paramètres** dans `localStorage`, `sessionStorage`, cookies ou toute autre forme de persistance (INV-1).
- **Interdiction d'ajouter des routes backend** pour les nouvelles fonctionnalités (tout est frontend).
- **Interdiction de charger Chart.js depuis un autre CDN** que `cdn.jsdelivr.net` — ne pas utiliser de CDN non vérifié. L'attribut `integrity` (SRI) est obligatoire.

---

## 6) Stratégie de tests

### Configuration pytest

Fichier `pyproject.toml` à la racine :

```toml
[tool.pytest.ini_options]
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"
```

### Tests unitaires existants (inchangés)

| Fichier                   | Tests | Couverture                                                                                                                                 |
| ------------------------- | ----- | ------------------------------------------------------------------------------------------------------------------------------------------ |
| `test_cache.py`           | 5     | Insert/get, expiration, FIFO eviction, clé manquante, thread safety                                                                        |
| `test_commune_service.py` | 4     | Transformation coordonnées, réponse vide, erreur upstream, accents                                                                         |
| `test_weather_service.py` | 6     | Normalisation + WMO + icônes, null WMO fallback, erreur upstream, longueurs incohérentes, nulls numériques, résumé multi-jours + tie-break |
| `test_api.py`             | 8     | Communes OK/court/manquant, Weather OK + daily_summary + icônes, période trop longue, date future, date < 1940, fin < début                |

### Nouveaux tests (R3)

| Fichier                   | Test                                        | Cas couvert                                            |
| ------------------------- | ------------------------------------------- | ------------------------------------------------------ |
| `test_api.py`             | `test_weather_period_exactly_31_days`       | Période de 31 jours exactement → 200 (pas 400)         |
| `test_api.py`             | `test_weather_invalid_coordinates`          | `lat=100` → 400 avec message d'erreur explicite        |
| `test_weather_service.py` | `test_daily_summary_all_null_weather_codes` | Journée avec tous `weather_code: None` → `DEFAULT_WMO` |

### Tests frontend (manuels)

Les nouvelles fonctionnalités sont purement frontend. Vérification manuelle :

| Scénario                                           | Résultat attendu                                                            |
| -------------------------------------------------- | --------------------------------------------------------------------------- |
| Recherche 1 jour → graphique                       | Graphique visible, 24 points, 1 section accordéon ouverte                   |
| Recherche 3 jours → graphique                      | Graphique visible, 72 points, 3 sections ouvertes                           |
| Recherche 31 jours → graphique                     | Graphique visible, labels espacés, 31 sections fermées                      |
| Recherche → copier URL → coller dans nouvel onglet | Champs pré-remplis, recherche automatique, même résultat                    |
| URL avec paramètres invalides                      | Page vide normale, pas d'erreur                                             |
| URL sans paramètres                                | Comportement par défaut                                                     |
| Clic "Tout déplier"                                | Toutes les sections ouvertes, texte → "Tout replier"                        |
| Clic "Tout replier"                                | Toutes les sections fermées, texte → "Tout déplier"                         |
| Chart.js CDN indisponible                          | Section graphique masquée, tableau et accordéon fonctionnels                |
| Mobile 360px                                       | Graphique responsive, accordéon lisible, scroll horizontal si table dépasse |

### Edge cases critiques (complétés)

- Période exactement 31 jours (limite inclusive) → acceptée
- Coordonnées hors range (`lat=100`) → erreur 400
- Résumé journalier avec tous `weather_code: None` → `DEFAULT_WMO` appliqué
- URL avec `commune` contenant des accents (`saint-étienne`) → `URLSearchParams` encode/décode correctement
- Graphique avec toutes les températures `null` sur une journée → `spanGaps: true` connecte les segments valides
- Graphique avec toutes les précipitations à 0 → barres invisibles, axe Y droit commence à 0
- Accordéon avec 1 seul jour → 1 section ouverte, bouton "Tout déplier" masqué ou inactif

---

## 7) Risques techniques

| #   | Risque                                                   | Probabilité | Impact                                                                                                                                         | Mitigation                                                                                                                                                                     |
| --- | -------------------------------------------------------- | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| 1   | **CDN Chart.js indisponible**                            | Faible      | Moyen — pas de graphique                                                                                                                       | Vérifier `typeof Chart` avant rendu. Le graphique est un enrichissement, pas une fonctionnalité critique. Tableau et accordéon restent fonctionnels.                           |
| 2   | **Performance Chart.js sur 744 points** (31 jours × 24h) | Faible      | Faible — dégradation possible sur mobile bas de gamme                                                                                          | `pointRadius: 0` réduit le rendu. Chart.js est optimisé pour cette volumétrie. Aucune action sauf monitoring.                                                                  |
| 3   | **Paramètres URL manipulés** (injection)                 | Faible      | Nul — les paramètres URL sont utilisés uniquement pour pré-remplir des champs et déclencher une requête API qui a sa propre validation backend | La validation se fait à deux niveaux : frontend (format) + backend (métier). Pas de risque XSS car les valeurs URL sont assignées via `value` (inputs) et `textContent` (DOM). |

---

## Annexe A — Traçabilité du feedback v2

| Amélioration feedback v2        | Traitement dans cette spec       |
| ------------------------------- | -------------------------------- |
| R1 — Accents nom application    | §3.6 R1                          |
| R2 — Typographie française      | §3.6 R2                          |
| R3 — Tests edge cases manquants | §3.6 R3, §6 Nouveaux tests       |
| R4 — Deprecation pytest-asyncio | §3.6 R4, §6 Configuration pytest |
| R5 — Cohérence Dockerfile       | §3.6 R5                          |

## Annexe B — Surface de modification complète

| Fichier                         | Type de modification | Fonctionnalité                                                                                                                                                                     |
| ------------------------------- | -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `public/index.html`             | Modifier             | R1 accents, R2 typo, section `#chart`, restructuration `#results`, script CDN Chart.js                                                                                             |
| `public/app.js`                 | Modifier             | `renderChart`, `loadFromURL`, `updateURL`, `renderDayGroups`, `groupByDate`, `formatDayHeader`, `formatDaySummaryInline`, `buildHourlyTable`, `formatChartLabel`, toggle accordéon |
| `public/style.css`              | Modifier             | `.chart-container`, `.day-group`, `.day-header`, `.day-summary`, `.btn-secondary`                                                                                                  |
| `pyproject.toml`                | Modifier             | `asyncio_default_fixture_loop_scope`                                                                                                                                               |
| `Dockerfile`                    | Modifier             | Version Python                                                                                                                                                                     |
| `tests/test_api.py`             | Modifier             | +2 tests (période 31j, coordonnées hors range)                                                                                                                                     |
| `tests/test_weather_service.py` | Modifier             | +1 test (résumé all-null weather codes)                                                                                                                                            |
| `README.md`                     | Modifier             | Documentation nouvelles fonctionnalités                                                                                                                                            |
