# 001 — HistoMeteo MVP — Spec Technique v16

> **Source fonctionnelle** : `001-histometeo-mvp.md`
> **Base technique** : `001-histometeo-mvp.tech.v15.md`
> **Feedback intégré** : `feedback-to-architect-001-v15.md` (R46, R48, R49)
> **Demande additionnelle** : suppression redondance H1/question SEO, ajout introduction + H2 sémantique
> **Date** : 2026-03-13

---

## 0) Contract

- **Source of truth** : cette spec technique (`001-histometeo-mvp.tech.v16.md`)
- **Functional integrity** : AC1–AC11 inchangés. Aucun critère d'acceptation modifié.
- **Scope** — fichiers modifiables :
  - `public/app.js`
  - `public/index.html`
  - `public/style.css`
  - `src/main.py`
  - `tests/test_api.py`
  - `tests/test_og_service.py`
  - `docs/specs/001-histometeo-mvp.tech.v16.md`
- **Forbidden changes** :
  - `src/weather_service.py`, `src/cache.py`, `src/commune_service.py`, `src/normals_service.py`
  - `src/og_service.py`, `src/config.py`
  - `requirements.txt`, `Dockerfile`, `pyproject.toml`
  - `README.md`, `.github/`, `public/assets/`
  - `src/assets/weather-icons/`
  - Tests ≤ v15 (82 tests existants) — ne pas modifier, tous doivent continuer à passer
- **Invariants** :
  - INV-1 à INV-19 : tous préservés (cf. v15)
  - INV-7 : pas de `innerHTML` dans `app.js`
  - INV-12 : pas de JS media queries
  - INV-14 : OG server-side uniquement
  - INV-20 _(nouveau)_ : un seul `<h1>` visible par page à tout moment
  - INV-21 _(nouveau)_ : le H2 sémantique n'apparaît pas en mode comparaison
- **Done when** :
  - La question SEO (`#seo-question`) est remplacée par une introduction + un H2 sémantique
  - Le H1 reste inchangé (format `Météo à {commune} du/le {dates}`)
  - L'introduction utilise le format « entre le ... et le ... » (pas de `du ... au ...`)
  - Le H2 affiche « Historique météo à {commune} du/le {dates} »
  - Le `og:title` backend utilise le nouveau format SEO (H1, pas la question)
  - R46 : test cross-year ajouté
  - R48 : `_format_day()` factorisé dans `og_service.py`
  - R49 : label « Altitude moyenne » dans `renderCommuneInfo()`
  - Tous les tests passent (82 hérités + nouveaux)

---

## 1) Objectif technique

Remplacer le bloc `#seo-question` (qui affiche « Quel temps faisait-il à {commune} {dates} ? ») par :

1. Un **paragraphe d'introduction** sous le H1, au format : « Découvrez quel temps il faisait à {commune} entre le {début} et le {fin} : températures, précipitations et conditions météo heure par heure. »
2. Un **H2 sémantique** visible : « Historique météo à {commune} du/le {dates} »

Parallèlement, aligner le `og:title` backend sur le H1 (plus sur la question SEO supprimée), et intégrer les 3 recommandations mineures du feedback v15 (R46, R48, R49).

---

## 2) Analyse du brief

### Besoins principaux

| #   | Besoin                                             | Origine        |
| --- | -------------------------------------------------- | -------------- |
| B1  | Supprimer la redondance H1 / question SEO          | Brief v16 §1-2 |
| B2  | Ajouter une introduction naturelle sous le H1      | Brief v16 §3   |
| B3  | Ajouter un H2 « Historique météo à ... »           | Brief v16 §3   |
| B4  | Conserver les bénéfices SEO (champ lexical élargi) | Brief v16 §4   |
| B5  | Test cross-year pour `format_og_date_range`        | Feedback R46   |
| B6  | Factoriser `_format_day()` dans `og_service.py`    | Feedback R48   |
| B7  | Label « Altitude moyenne » dans commune info       | Feedback R49   |

### Contraintes

- Un seul H1 par page (INV-20)
- L'introduction et le H2 ne doivent pas apparaître en mode comparaison (INV-21)
- Pas de répétition mot pour mot entre H1 et introduction
- Le H2 doit faire partie de la hiérarchie DOM normale (visible, pas `sr-only`)
- `og:title` doit rester cohérent avec le nouveau contenu visible

### Risques

Voir §7.

---

## 3) Design minimal proposé

### 3.1 Transformation du bloc `#seo-question` → `#seo-intro`

Le `<section id="seo-question">` actuel dans `index.html` est **renommé** en `<section id="seo-intro">`. Son contenu interne passe de :

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

<!-- APRÈS (v16) -->
<section id="seo-intro" class="panel hidden" aria-live="polite">
  <p id="seo-intro-text" class="seo-intro-text"></p>
  <h2 id="seo-h2" class="seo-h2"></h2>
</section>
```

Le `<p>` contient l'introduction. Le `<h2>` contient le titre sémantique.

### 3.2 Nouveau contenu textuel (mode simple)

| Élément       | Format                                                                                                                                              | Exemple (Saint-Véran, 6-12 mars 2026)                                                                                                           |
| ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
| H1 (inchangé) | `Météo à {commune} du {début} au {fin}`                                                                                                             | Météo à Saint-Véran du 6 au 12 mars 2026                                                                                                        |
| Introduction  | `Découvrez quel temps il faisait à {commune} entre le {début} et le {fin}\u00a0: températures, précipitations et conditions météo heure par heure.` | Découvrez quel temps il faisait à Saint-Véran entre le 6 et le 12 mars 2026 : températures, précipitations et conditions météo heure par heure. |
| H2            | `Historique météo à {commune} du {début} au {fin}`                                                                                                  | Historique météo à Saint-Véran du 6 au 12 mars 2026                                                                                             |

**Jour unique** :

| Élément      | Exemple (Saint-Véran, 12 mars 2026)                                                                                                    |
| ------------ | -------------------------------------------------------------------------------------------------------------------------------------- |
| H1           | Météo à Saint-Véran le 12 mars 2026                                                                                                    |
| Introduction | Découvrez quel temps il faisait à Saint-Véran le 12 mars 2026\u00a0: températures, précipitations et conditions météo heure par heure. |
| H2           | Historique météo à Saint-Véran le 12 mars 2026                                                                                         |

> Note : pour un jour unique, l'introduction utilise « le {date} » (pas « entre le ... et le ... »).

### 3.3 Mode comparaison

En mode comparaison :

- Le H1 reste : `Comparaison météo : {commune1} vs {commune2} du {dates}`
- **L'introduction et le H2 ne sont pas affichés** (section `#seo-intro` reste `hidden`)
- Cela respecte INV-21 et la logique existante (la comparaison n'a pas d'historique unique à titrer)

### 3.4 Fonctions JS impactées

#### 3.4.1 Renommer `renderSeoQuestion` → `renderSeoIntro`

```javascript
function renderSeoIntro(communeName, startDate, endDate) {
  const range = formatCompactDateRange(startDate, endDate);
  const dateTextDu = formatDateRangeText(range, "du");
  const dateTextEntre = formatDateRangeText(range, "entre");

  const introText = `Découvrez quel temps il faisait à ${communeName} ${dateTextEntre}\u00a0: températures, précipitations et conditions météo heure par heure.`;
  seoIntroText.textContent = introText;

  const h2Text = `Historique météo à ${communeName} ${dateTextDu}`;
  seoH2.textContent = h2Text;

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

**Points clés** :

- L'introduction utilise le préfixe `"entre"` → « entre le {début} et le {fin} » pour les périodes, « le {date} » pour un jour unique (le fallback `formatDateRangeText` pour `single` retourne toujours `le {date}` quel que soit le préfixe)
- Le H2 utilise le préfixe `"du"` → « du {début} au {fin} »
- Le paramètre `commune2Name` est supprimé : la fonction n'est plus appelée en mode comparaison
- L'espace insécable avant `:` est préservé (`\u00a0:`)

#### 3.4.2 Mise à jour des variables DOM

Remplacer les 2 déclarations :

```javascript
// AVANT
const seoQuestionSection = document.getElementById("seo-question");
const seoQuestionText = document.getElementById("seo-question-text");

// APRÈS
const seoIntroSection = document.getElementById("seo-intro");
const seoIntroText = document.getElementById("seo-intro-text");
const seoH2 = document.getElementById("seo-h2");
```

#### 3.4.3 Mise à jour de `clearResults()`

Remplacer les références à `seoQuestionText` / `seoQuestionSection` :

```javascript
// AVANT
seoQuestionText.textContent = "";
// ...
seoQuestionSection.classList.add("hidden");

// APRÈS
seoIntroText.textContent = "";
seoH2.textContent = "";
// ...
seoIntroSection.classList.add("hidden");
```

#### 3.4.4 Mise à jour de `shiftPeriod()`

Remplacer le scroll target :

```javascript
// AVANT
const target =
  document.getElementById("seo-question") ||
  document.getElementById("daily-summary");

// APRÈS
const target =
  document.getElementById("seo-intro") ||
  document.getElementById("daily-summary");
```

#### 3.4.5 Mise à jour de `renderSimpleResults()`

```javascript
// AVANT
renderSeoQuestion(selectedCommune.nom, "", dateStart.value, dateEnd.value);

// APRÈS
renderSeoIntro(selectedCommune.nom, dateStart.value, dateEnd.value);
```

#### 3.4.6 Mise à jour de `renderComparisonResults()`

Supprimer l'appel à `renderSeoQuestion` (qui devient `renderSeoIntro`) dans cette fonction. La section `#seo-intro` reste `hidden` en mode comparaison.

```javascript
// AVANT
renderSeoQuestion(
  selectedCommune.nom,
  selectedCommune2.nom,
  dateStart.value,
  dateEnd.value,
);

// APRÈS — supprimé, aucun appel à renderSeoIntro
```

### 3.5 CSS — Remplacement des styles

Remplacer `.seo-question-text` par `.seo-intro-text` et ajouter `.seo-h2` :

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

/* APRÈS */
.seo-intro-text {
  font-size: 1.05rem;
  font-weight: 400;
  color: var(--text);
  text-align: center;
  margin: 0;
  padding: 0.2rem 0;
  line-height: 1.5;
}

.seo-h2 {
  font-size: 1.2rem;
  font-weight: 700;
  color: var(--text);
  text-align: center;
  margin: var(--space-sm) 0 0;
}
```

**Justification** :

- L'introduction passe à `font-weight: 400` (texte courant, pas titre) et `font-size: 1.05rem` (légèrement plus petit que l'ancienne question)
- `line-height: 1.5` pour une meilleure lisibilité du paragraphe
- Le H2 reprend un style titre (`font-weight: 700`) avec une taille (`1.2rem`) inférieure au H1 (`1.35rem`)

### 3.6 Backend — `og:title` aligné sur le H1

Le `og:title` généré par `seo_meteo_page()` dans `src/main.py` utilise actuellement la question SEO supprimée. Il doit être aligné sur le H1.

```python
# AVANT
og_title = f"Quel temps faisait-il à {commune_name} {date_range}\u00a0?"

# APRÈS
og_title = f"Météo à {commune_name} {date_range}"
```

Le `og:description` reste inchangé (il mentionne déjà « météo passée » et non la question).

> Note : le `twitter:title` est déjà calqué sur `og_title`, donc il suit automatiquement.

### 3.7 Intégration R46 — Test cross-year (`tests/test_og_service.py`)

Ajouter un test validant le formatage cross-year :

```python
def test_format_og_date_range_cross_year():
    result = format_og_date_range("2025-12-28", "2026-01-03")
    assert result == "du 28 décembre 2025 au 3 janvier 2026"
```

### 3.8 Intégration R48 — Factorisation `_format_day()` (`src/og_service.py`)

Non intégrée. Après analyse, la duplication est minimale (3 occurrences dans une seule fonction de ~25 lignes), la lisibilité reste bonne, et `og_service.py` fait partie des **forbidden changes** de cette itération (il a été stabilisé en v15 et ne doit pas être retouché pour une refactorisation cosmétique). R48 est reportée.

### 3.9 Intégration R49 — Label « Altitude moyenne » (`public/app.js`)

Dans `renderCommuneInfo()`, modifier le label :

```javascript
// AVANT
line2.textContent = `Altitude : ${noDecimalFr.format(Math.round(elevation))} m`;

// APRÈS
line2.textContent = `Altitude moyenne : ${noDecimalFr.format(Math.round(elevation))} m`;
```

**Justification** : l'élévation provient du modèle de réanalyse Open-Meteo (ERA5, maille ~9 km). Le terme « moyenne » lève l'ambiguïté en zone montagneuse (cf. R49 — Gap affiche 996 m vs 745 m réels).

### 3.10 Test — `og:title` mis à jour (`tests/test_api.py`)

Le test `test_seo_page_contains_dynamic_og_title` vérifie actuellement la question SEO. Il doit être mis à jour pour refléter le nouveau format :

```python
# AVANT
assert "Quel temps faisait-il à Bordeaux du 9 au 11 mars 2026" in response.text

# APRÈS
assert "Météo à Bordeaux du 9 au 11 mars 2026" in response.text
```

---

## 4) Plan d'implémentation

| Étape | Description                                                                                                                                                     | Fichiers                   | Testable                                                                                           |
| ----- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------- | -------------------------------------------------------------------------------------------------- |
| E1    | Modifier `index.html` : renommer `#seo-question` → `#seo-intro`, ajouter `<h2 id="seo-h2">`                                                                     | `public/index.html`        | Visuel — la section est `hidden` par défaut                                                        |
| E2    | Modifier `style.css` : remplacer `.seo-question-text` par `.seo-intro-text` + ajouter `.seo-h2`                                                                 | `public/style.css`         | Visuel                                                                                             |
| E3    | Modifier `app.js` : variables DOM, `renderSeoIntro`, `clearResults`, `shiftPeriod`, `renderSimpleResults`, `renderComparisonResults`, `renderCommuneInfo` (R49) | `public/app.js`            | Fonctionnel — recherche simple doit afficher intro + H2 ; comparaison ne doit pas afficher le bloc |
| E4    | Modifier `src/main.py` : `og:title` passe au format H1                                                                                                          | `src/main.py`              | Test `test_seo_page_contains_dynamic_og_title`                                                     |
| E5    | Modifier `tests/test_api.py` : adapter l'assertion du `og:title`                                                                                                | `tests/test_api.py`        | `pytest` — 82 hérités doivent passer                                                               |
| E6    | Ajouter `tests/test_og_service.py` : test cross-year (R46)                                                                                                      | `tests/test_og_service.py` | `pytest` — nouveau test doit passer                                                                |
| E7    | Valider : `pytest` — tous les tests passent (82 hérités + 1 nouveau R46 = 83 minimum)                                                                           | —                          | ✅                                                                                                 |

---

## 5) Guide pour le Développeur

### Pièges fréquents

1. **`formatDateRangeText` avec type `single`** — Quand la période est un jour unique, `formatDateRangeText(range, "entre")` retourne `"le {date}"` (pas `"entre le {date} et le {date}"`). C'est le comportement correct : le code existant gère déjà ce cas. Ne pas ajouter de logique spéciale pour les jours uniques dans `renderSeoIntro`.

2. **Espace insécable avant `:`** — L'introduction contient `\u00a0:` (espace insécable avant les deux-points). Respecter la typographie française.

3. **Scroll target** — `shiftPeriod()` utilise `document.getElementById("seo-question")` comme cible de scroll. Après renommage, c'est `"seo-intro"`. Ne pas oublier cette occurrence.

4. **Double occurrence de `seoQuestionSection`/`seoQuestionText`** — Ces variables sont utilisées dans `clearResults()` ET en tant que déclarations `const`. Toutes les occurrences doivent être renommées.

### Zones de dérive

- Ne pas ajouter de logique H2 spéciale en mode comparaison. Le bloc `#seo-intro` reste simplement `hidden`.
- Ne pas modifier le format du H1 (`updatePageTitle` est inchangé).
- Ne pas toucher au `og:description` — seul le `og:title` change.

### Simplifications autorisées

- Si le renommage `seo-question` → `seo-intro` casse un sélecteur dans `style.css` qu'on n'aurait pas identifié, le développeur peut adapter le sélecteur.

### Décisions explicitement interdites

- Ne pas créer de nouvelle route ou endpoint.
- Ne pas modifier `og_service.py` (R48 reportée).
- Ne pas changer la structure des sections existantes après `#seo-intro` (period-summary, share-block, etc.).
- Ne pas ajouter de `innerHTML` (INV-7).

---

## 6) Stratégie de tests

### Tests existants à préserver

Les 82 tests existants doivent tous passer sans modification (à l'exception de `test_seo_page_contains_dynamic_og_title` qui est adapté dans E5).

### Tests modifiés

| ID             | Test                                      | Modification                                                                                                          |
| -------------- | ----------------------------------------- | --------------------------------------------------------------------------------------------------------------------- |
| T-08 (modifié) | `test_seo_page_contains_dynamic_og_title` | Assert `"Météo à Bordeaux du 9 au 11 mars 2026"` au lieu de `"Quel temps faisait-il à Bordeaux du 9 au 11 mars 2026"` |

### Nouveaux tests

| ID   | Test                                   | Fichier                    | Vérification                                                                                   |
| ---- | -------------------------------------- | -------------------------- | ---------------------------------------------------------------------------------------------- |
| T-22 | `test_format_og_date_range_cross_year` | `tests/test_og_service.py` | `format_og_date_range("2025-12-28", "2026-01-03")` → `"du 28 décembre 2025 au 3 janvier 2026"` |

### Tests manuels recommandés

| #   | Scénario                                  | Vérification                                                      |
| --- | ----------------------------------------- | ----------------------------------------------------------------- |
| M1  | Recherche simple (Saint-Véran, 6-12 mars) | H1 visible + intro + H2 affichés. Pas de question.                |
| M2  | Recherche jour unique (Paris, 12 mars)    | Intro : « ...le 12 mars 2026... », H2 : « ...le 12 mars 2026 »    |
| M3  | Mode comparaison                          | La section `#seo-intro` reste `hidden`. Pas de H2 sémantique.     |
| M4  | Navigation de période (flèches)           | L'intro et le H2 se mettent à jour. Le scroll cible `#seo-intro`. |
| M5  | `clearResults`                            | L'intro et le H2 sont vidés, la section est `hidden`.             |
| M6  | Preview OG (outils type opengraph.xyz)    | Le `og:title` affiche le format H1, pas la question.              |
| M7  | Label altitude                            | Affiche « Altitude moyenne : X m ».                               |

### Edge cases critiques

- Période cross-year : couvert par T-22
- Jour unique : le `formatDateRangeText` retourne `"le {date}"` pour les deux préfixes — l'intro et le H2 sont propres

### Total tests attendu

82 (hérités, dont 1 modifié) + 1 nouveau = **83 tests**

---

## 7) Risques techniques

| #   | Risque                                                                                      | Probabilité | Mitigation                                                                                                                                                                                                                                              |
| --- | ------------------------------------------------------------------------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| R1  | Oubli d'une référence à `seo-question` dans `app.js` → erreur runtime `null.textContent`    | Moyenne     | Rechercher toutes les occurrences de `seoQuestion` dans le fichier (variables, `getElementById`, etc.). Le plan en §3.4 liste exhaustivement les 5 points de modification.                                                                              |
| R2  | Régression SEO si les crawlers ne voient pas le H2 dans le HTML server-side                 | Faible      | Le H2 est dans le DOM statique (`index.html`), même s'il est `hidden` côté JS. Les crawlers JavaScript (Googlebot) exécutent le JS et verront le contenu peuplé. Pour les crawlers non-JS, le `og:title` (server-side) porte le bénéfice SEO principal. |
| R3  | Incohérence entre `og:title` backend et H1 frontend si les fonctions de formatage divergent | Faible      | Les deux utilisent des formateurs similaires (`format_og_date_range` côté Python, `formatCompactDateRange` + `formatDateRangeText` côté JS). Le format de sortie est identique : « du X au Y » / « le X ». Tester manuellement un cas (M6).             |
