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

> **Itération** : v14 — intègre le feedback Reviewer v13 (R41–R43) + nouvelle feature **partage social et previews Open Graph** (bloc de partage, balises OG/Twitter Card dynamiques, génération d'image OG, texte de partage pré-rempli, copier le lien).

---

## 0) Contract

- **Source of truth** : ce document (`001-histometeo-mvp.tech.v14.md`)
- **Functional integrity** : aucun critère d'acceptation de `001-histometeo-mvp.md` ne peut être modifié, ignoré ou réinterprété. Les AC1–AC11 restent la référence fonctionnelle absolue.
- **Scope** : fichiers autorisés à modifier :
  - `public/index.html` — ajout du bloc de partage social `#share-block`, ajout des balises `<meta name="twitter:...">`, ajout de `<meta property="og:url">`
  - `public/app.js` — fonctions de partage social (`renderShareBlock`, `buildShareText`, `copyLinkToClipboard`, ouverture des liens de partage), intégration R41 (`setPeriodSummaryTitle()` dans `clearResults`)
  - `public/style.css` — styles du bloc de partage (`.share-block`, `.share-buttons`, `.share-btn`, `.share-feedback`), intégration R42 (réduction `min-width` du tableau `#daily-summary table`)
  - `src/main.py` — injection dynamique des balises OG/Twitter Card/canonical dans les réponses HTML des routes `/meteo/...`, nouvel endpoint `GET /api/og-image/{slug_dept}/{start}/{end}`
  - `src/og_service.py` — **NOUVEAU** : service de génération d'images OG (Pillow)
  - `src/config.py` — constantes OG (nom du site, cache image TTL)
  - `requirements.txt` — ajout de `Pillow`
  - `Dockerfile` — installation de la police Inter pour le rendu texte OG
  - `README.md` — mise à jour documentation (R43)
  - `src/assets/fonts/` — **NOUVEAU** : police Inter-SemiBold.ttf pour le rendu des images OG
- **Forbidden changes** :
  - `src/weather_service.py`, `src/cache.py`, `src/commune_service.py`, `src/normals_service.py` — aucune modification
  - `tests/` — aucune modification des tests existants (ajout de nouveaux tests autorisé)
  - `docs/` — aucune modification des specs existantes (hors ce fichier)
  - `.github/` — aucune modification
  - `pyproject.toml` — pas de modification
  - `public/assets/` — pas de modification des fichiers existants
- **Invariants** (tous hérités et préservés) :
  - INV-1 : Aucune donnée utilisateur stockée (ni serveur, ni client)
  - INV-2 : Heures en fuseau `Europe/Paris`
  - INV-3 : Période maximale 31 jours
  - INV-4 : Aucune clé API
  - INV-5 : Interface en français avec accents (`HistoMétéo`)
  - INV-6b : Page unique, URL reflète l'état via des routes propres (path segments)
  - INV-7 : Aucune injection HTML — `textContent`, `createElement`, `replaceChildren()` uniquement, jamais `innerHTML`
  - INV-8 : Le flux recherche simple fonctionne indépendamment du mode comparaison
  - INV-9 : Les normales climatiques sont un enrichissement progressif — leur indisponibilité ne bloque pas l'affichage des données météo ni la navigation
  - INV-10 : Les informations commune sont un enrichissement progressif — leur absence partielle n'empêche pas l'affichage du bloc
  - INV-11 : Le texte contextuel saisonnier et le bloc climat mensuel sont des enrichissements progressifs liés aux normales — leur absence (échec normales) ne dégrade rien
  - INV-12 : Le comportement responsive est 100% CSS. Aucune détection de user-agent, aucun JS conditionnel basé sur la taille d'écran. Les media queries CSS sont la seule source de vérité pour l'adaptation au viewport.
  - INV-13 : Le format interne des dates reste `YYYY-MM-DD`. Toute saisie utilisateur est convertie vers ce format avant utilisation par la logique métier. L'affichage utilisateur est en `JJ/MM/AAAA`.
  - **INV-14** (nouveau) : Les balises OG/Twitter Card sont injectées côté serveur. Le frontend ne modifie jamais les `<meta>` OG/Twitter (il modifie uniquement `<meta name="description">` et `<link rel="canonical">` comme actuellement).
  - **INV-15** (nouveau) : L'endpoint OG image est un GET pur, sans effet de bord. Il retourne une image PNG et ne modifie aucun état côté serveur (hors cache en mémoire).
  - **INV-16** (nouveau) : Le texte de partage est généré côté frontend à partir des données agrégées déjà disponibles (`computeAggregates`). Aucun nouvel appel API n'est nécessaire pour construire ce texte.
  - **INV-17** (nouveau) : Le bloc de partage n'est visible qu'en mode recherche simple (pas en mode comparaison — la comparaison est une évolution future du partage).
- **Done when** :
  - Les 11 critères d'acceptation originaux (AC1–AC11) restent vérifiables
  - Toutes les fonctionnalités v2–v13 restent opérationnelles
  - **Bloc de partage social** visible sous le résumé de la période après une recherche, avec 5 boutons : « Copier le lien », « Facebook », « X », « WhatsApp », « LinkedIn »
  - **Copier le lien** copie l'URL canonique dans le presse-papier et affiche un feedback temporaire « Lien copié dans le presse-papier »
  - **Boutons de partage** ouvrent le service tiers correspondant avec l'URL et un texte pré-rempli
  - **Texte de partage** construit dynamiquement à partir du nom de commune, des dates et des données météo agrégées
  - **Balises OG dynamiques** (`og:title`, `og:description`, `og:image`, `og:url`, `og:type`) injectées côté serveur pour les routes `/meteo/{slug_dept}/{start}/{end}`
  - **Balises Twitter Card** (`twitter:card`, `twitter:title`, `twitter:description`, `twitter:image`) injectées côté serveur au même titre que les OG
  - **Image OG dynamique** (1200×630 px) générée à la demande via `GET /api/og-image/{slug_dept}/{start}/{end}`, incluant le nom de ville, les dates, les températures min/max, la condition dominante et le logo
  - **URL canonique** mise à jour dynamiquement côté serveur dans le `<link rel="canonical">` et le `<meta property="og:url">`
  - R41 intégré : `setPeriodSummaryTitle()` sans argument appelé dans `clearResults()`
  - R42 intégré : `#daily-summary table { min-width }` réduit de 860px à 780px
  - R43 intégré : `README.md` ajouté au scope autorisé
  - Tous les tests backend existants (61/61) passent sans modification
  - Les nouveaux tests backend (OG service, injection meta) passent
  - Le site reste utilisable à 360px (iPhone SE) et identique visuellement sur desktop ≥ 768px

---

## 1) Objectif technique

Ajouter un mécanisme de partage social aux pages météo d'HistoMétéo :

- **Côté frontend** : un bloc de partage avec boutons (copier, Facebook, X, WhatsApp, LinkedIn) et un texte de partage pré-rempli construit à partir des données météo.
- **Côté backend** : injection dynamique des métadonnées Open Graph et Twitter Card dans le HTML servi pour les routes SEO, ainsi qu'un endpoint de génération d'images OG (1200×630 px) à partir de Pillow.

Intégrer les recommandations R41–R43 du feedback v13. Aucune régression sur les fonctionnalités existantes v2–v13.

---

## 2) Analyse du brief

### Besoins principaux

| #   | Besoin                                                        | Source          | Complexité | Impact      |
| --- | ------------------------------------------------------------- | --------------- | ---------- | ----------- |
| D20 | `setPeriodSummaryTitle()` sans argument dans `clearResults()` | Feedback v13    | Trivial    | Cohérence   |
| D21 | Réduire `#daily-summary table { min-width: 860px }` à 780px   | Feedback v13    | Trivial    | UX mobile   |
| D22 | `README.md` dans le scope autorisé                            | Feedback v13    | Trivial    | Contractuel |
| D23 | Bloc de partage social (UI) sous le résumé de la période      | Demande produit | Faible     | Partage     |
| D24 | Boutons de partage (Facebook, X, WhatsApp, LinkedIn)          | Demande produit | Faible     | Partage     |
| D25 | Fonction « copier le lien » avec feedback temporaire          | Demande produit | Faible     | Partage     |
| D26 | Texte de partage pré-rempli à partir des données météo        | Demande produit | Faible     | Partage     |
| D27 | Balises Open Graph dynamiques (serveur)                       | Demande produit | Moyen      | SEO/Social  |
| D28 | Balises Twitter Card                                          | Demande produit | Faible     | Social      |
| D29 | Image OG dynamique 1200×630 (serveur, Pillow)                 | Demande produit | Moyen      | Social      |
| D30 | URL canonique dynamique dans le HTML servi                    | Demande produit | Faible     | SEO         |

### Contraintes

- **Une seule nouvelle dépendance backend** : `Pillow` pour la génération d'image OG. Aucune nouvelle dépendance frontend.
- **Pas de modification des services existants** : `weather_service`, `commune_service`, `normals_service`, `cache.py` ne sont pas modifiés. Le nouveau `og_service.py` les utilise en composition.
- **INV-7 absolu** — le bloc de partage est construit via `createElement` / `textContent`. Zéro `innerHTML`.
- **INV-12 respecté** — aucun JS conditionnel responsive pour le bloc de partage. La mise en forme est 100% CSS.
- **INV-14** — les OG tags sont injectés côté serveur dans `main.py`. Le JS frontend ne touche pas aux `<meta property="og:...">`.
- **Les liens de partage utilisent des URL publiques documentées** des plateformes (Facebook Sharer, Twitter Intent, WhatsApp API, LinkedIn Share). Aucune clé API requise (INV-4 préservé).
- **Le texte de partage est construit à partir de `computeAggregates()`** qui est déjà calculé — aucun recalcul ni appel API supplémentaire (INV-16).
- **L'image OG est optionnelle pour l'UX** : si le service d'image échoue, le og:image pointe sur le logo statique. Aucune dégradation de l'expérience utilisateur.
- **Les crawlers sociaux ne lisent pas le JavaScript** : c'est pourquoi les balises OG doivent être injectées dans le HTML côté serveur avant l'envoi au client.

### Risques

| #   | Risque                                                                                                     | Probabilité | Impact | Mitigation                                                                                                                                              |
| --- | ---------------------------------------------------------------------------------------------------------- | ----------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 1   | Les crawlers sociaux ne voient pas les OG tags si l'injection serveur échoue                               | Faible      | Élevé  | Fallback : les valeurs statiques (homepage) restent dans le HTML template. L'injection remplace les valeurs ; en cas d'erreur, les défauts sont servis. |
| 2   | La génération d'image Pillow est lente sur la première requête (résolution commune + fetch météo + render) | Moyenne     | Faible | Le cache TTL météo (7j) et le cache image (7j) minimisent les re-génération. Headers `Cache-Control: public, max-age=86400` sur l'image.                |
| 3   | Pillow ne rend pas les emojis Unicode dans l'image OG                                                      | Élevée      | Faible | Utiliser la description textuelle de la condition dominante au lieu de l'emoji. L'emoji n'est pas inclus dans l'image.                                  |

---

## 3) Design minimal proposé

### 3.1) Bloc de partage social (frontend)

**Emplacement DOM** — nouvelle section `#share-block`, positionnée entre `#period-summary` et `#commune-info` :

```
#period-summary
#share-block          ← NOUVEAU
#commune-info
#comparison-summary
...
```

**HTML** (`public/index.html`) :

```html
<section
  id="share-block"
  class="share-block hidden"
  aria-label="Partager cette page"
></section>
```

Le contenu est construit dynamiquement par le JS (titre + boutons). La section est masquée par défaut (`hidden` class) et révélée après un `renderPeriodSummary` en mode simple.

**Structure DOM construite par JS** :

```html
<!-- Construit par renderShareBlock() -->
<h2>Partager cette page</h2>
<div class="share-buttons">
  <button
    type="button"
    class="share-btn share-btn-copy"
    aria-label="Copier le lien"
  >
    📋 Copier le lien
  </button>
  <a
    class="share-btn share-btn-facebook"
    href="..."
    target="_blank"
    rel="noopener"
    aria-label="Partager sur Facebook"
  >
    Facebook
  </a>
  <a
    class="share-btn share-btn-x"
    href="..."
    target="_blank"
    rel="noopener"
    aria-label="Partager sur X"
  >
    X
  </a>
  <a
    class="share-btn share-btn-whatsapp"
    href="..."
    target="_blank"
    rel="noopener"
    aria-label="Partager sur WhatsApp"
  >
    WhatsApp
  </a>
  <a
    class="share-btn share-btn-linkedin"
    href="..."
    target="_blank"
    rel="noopener"
    aria-label="Partager sur LinkedIn"
  >
    LinkedIn
  </a>
</div>
<p class="share-feedback hidden" aria-live="polite"></p>
```

**Pourquoi `<a>` plutôt que `<button>` pour les réseaux** : les boutons de partage social sont des liens vers des URL externes. Utiliser `<a>` avec `target="_blank" rel="noopener"` est sémantiquement correct et plus sûr (pas de `window.open()` avec risques de reverse tab-napping). Le bouton « Copier le lien » reste un `<button>` car il déclenche une action locale.

**Visibilité** : la section est affichée uniquement quand les résultats sont visibles en mode simple (pas en comparaison, cf. INV-17). Elle est masquée par `clearResults()`.

### 3.2) Texte de partage pré-rempli (frontend)

**Fonction** `buildShareText(agg, communeName, startDate, endDate)` :

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

  const tempMin = agg.tempMin !== null ? agg.tempMin.toFixed(1) : "—";
  const tempMax = agg.tempMax !== null ? agg.tempMax.toFixed(1) : "—";
  const precip = agg.precipTotal !== null ? agg.precipTotal.toFixed(1) : "—";

  return (
    `À ${communeName}, ${dateText}, la température a varié ` +
    `entre ${tempMin}\u00a0°C et ${tempMax}\u00a0°C ` +
    `avec ${precip}\u00a0mm de pluie.\n` +
    `Voir le détail heure par heure sur HistoMétéo.`
  );
}
```

Cette fonction réutilise `formatCompactDateRange` et `formatDateRangeText` qui existent déjà. Les données proviennent de `computeAggregates()` déjà calculé lors du render des résultats.

### 3.3) Fonction « Copier le lien » (frontend)

```js
async function copyLinkToClipboard() {
  const url = window.location.href;
  try {
    await navigator.clipboard.writeText(url);
    showShareFeedback("Lien copié dans le presse-papier");
  } catch {
    showShareFeedback("Impossible de copier le lien");
  }
}

function showShareFeedback(message) {
  const feedbackEl = document.querySelector(".share-feedback");
  if (!feedbackEl) return;
  feedbackEl.textContent = message;
  feedbackEl.classList.remove("hidden");
  clearTimeout(feedbackEl._timeout);
  feedbackEl._timeout = setTimeout(() => {
    feedbackEl.classList.add("hidden");
    feedbackEl.textContent = "";
  }, 3000);
}
```

**Points clés** :

- `navigator.clipboard.writeText` est la méthode standard (supportée par tous les navigateurs modernes).
- Le feedback disparaît après 3 secondes.
- En cas d'échec (permission refusée, contexte non-sécurisé), un message d'erreur est affiché.

### 3.4) Construction des liens de partage (frontend)

**Fonction** `renderShareBlock(agg, communeName, startDate, endDate, isComparison)` :

```js
function renderShareBlock(agg, communeName, startDate, endDate, isComparison) {
  const shareSection = document.getElementById("share-block");
  if (!shareSection) return;

  if (isComparison) {
    shareSection.classList.add("hidden");
    return;
  }

  shareSection.replaceChildren();

  const url = window.location.href;
  const text = buildShareText(agg, communeName, startDate, endDate);
  const encodedUrl = encodeURIComponent(url);
  const encodedText = encodeURIComponent(text);

  const title = document.createElement("h2");
  title.textContent = "Partager cette page";

  const buttonsDiv = document.createElement("div");
  buttonsDiv.className = "share-buttons";

  // Bouton « Copier le lien »
  const copyBtn = document.createElement("button");
  copyBtn.type = "button";
  copyBtn.className = "share-btn share-btn-copy";
  copyBtn.textContent = "📋 Copier le lien";
  copyBtn.setAttribute("aria-label", "Copier le lien de cette page");
  copyBtn.addEventListener("click", copyLinkToClipboard);

  // Liens de partage
  const shareLinks = [
    {
      className: "share-btn-facebook",
      label: "Facebook",
      href: `https://www.facebook.com/sharer/sharer.php?u=${encodedUrl}`,
    },
    {
      className: "share-btn-x",
      label: "X",
      href: `https://twitter.com/intent/tweet?url=${encodedUrl}&text=${encodedText}`,
    },
    {
      className: "share-btn-whatsapp",
      label: "WhatsApp",
      href: `https://api.whatsapp.com/send?text=${encodedText}%0A${encodedUrl}`,
    },
    {
      className: "share-btn-linkedin",
      label: "LinkedIn",
      href: `https://www.linkedin.com/sharing/share-offsite/?url=${encodedUrl}`,
    },
  ];

  buttonsDiv.appendChild(copyBtn);

  for (const link of shareLinks) {
    const a = document.createElement("a");
    a.className = `share-btn ${link.className}`;
    a.href = link.href;
    a.target = "_blank";
    a.rel = "noopener";
    a.textContent = link.label;
    a.setAttribute("aria-label", `Partager sur ${link.label}`);
    buttonsDiv.appendChild(a);
  }

  // Feedback (copier le lien)
  const feedback = document.createElement("p");
  feedback.className = "share-feedback hidden";
  feedback.setAttribute("aria-live", "polite");

  shareSection.appendChild(title);
  shareSection.appendChild(buttonsDiv);
  shareSection.appendChild(feedback);
  shareSection.classList.remove("hidden");
}
```

**URLs de partage utilisées** (publiques, sans clé API) :

| Plateforme | URL pattern                                                 |
| ---------- | ----------------------------------------------------------- |
| Facebook   | `https://www.facebook.com/sharer/sharer.php?u={url}`        |
| X          | `https://twitter.com/intent/tweet?url={url}&text={text}`    |
| WhatsApp   | `https://api.whatsapp.com/send?text={text}%0A{url}`         |
| LinkedIn   | `https://www.linkedin.com/sharing/share-offsite/?url={url}` |

### 3.5) Balises OG et Twitter Card dynamiques (backend)

**Principe** : actuellement, `seo_meteo_page()` retourne `FileResponse(index_file)` — un fichier HTML statique. Pour injecter des OG tags dynamiques, il faut :

1. Lire le template HTML une fois au démarrage (mise en mémoire).
2. Pour chaque requête `/meteo/{slug_dept}/{start}/{end}`, extraire le nom de commune du slug et construire les valeurs OG.
3. Remplacer les valeurs par défaut des balises OG, Twitter Card et canonical dans le HTML.
4. Retourner le HTML modifié comme `Response(content=..., media_type="text/html")`.

**Ajout des balises Twitter Card dans `index.html`** (à placer après les OG existants dans le `<head>`) :

```html
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="HistoMétéo — Historique de la Météo" />
<meta
  name="twitter:description"
  content="Retrouvez la météo passée, heure par heure, pour n'importe quelle commune de France."
/>
<meta name="twitter:image" content="/assets/logo-histometeo.png" />
```

**Ajout de `og:url`** (manquant actuellement) :

```html
<meta property="og:url" content="/" />
```

**Fonction d'injection** (dans `src/main.py`) :

```python
import re as _re
from html import escape as _html_escape

_html_template: str = ""  # Chargé au démarrage via lifespan

def _inject_og_tags(template: str, og: dict[str, str]) -> str:
    html = template
    for prop, value in og.items():
        if prop.startswith("twitter:"):
            html = _re.sub(
                rf'<meta name="{_re.escape(prop)}" content="[^"]*"',
                f'<meta name="{prop}" content="{_html_escape(value, quote=True)}"',
                html,
                count=1,
            )
        elif prop == "canonical":
            html = _re.sub(
                r'<link rel="canonical" href="[^"]*"',
                f'<link rel="canonical" href="{_html_escape(value, quote=True)}"',
                html,
                count=1,
            )
        else:
            html = _re.sub(
                rf'<meta property="{_re.escape(prop)}" content="[^"]*"',
                f'<meta property="{prop}" content="{_html_escape(value, quote=True)}"',
                html,
                count=1,
            )
    return html
```

**Extraction du nom de commune depuis le slug** :

Le slug a la forme `bordeaux-33` ou `saint-etienne-42`. Le format est défini par `SLUG_WITH_DEPT_PATTERN = r"^(.+)-(\d{2,3})$"` dans `config.py`. Pour les OG tags du HTML (pas de l'image), le nom lisible suffit — les accents perdus dans le slug sont acceptables pour le titre OG car le résultat reste compréhensible (ex: `Saint-Etienne` au lieu de `Saint-Étienne`). Ce compromis évite un appel API pour chaque page servie.

```python
def _commune_name_from_slug(slug_dept: str) -> tuple[str, str]:
    """Extrait un nom de commune lisible et le code département depuis un slug."""
    match = _re.match(SLUG_WITH_DEPT_PATTERN, slug_dept)
    if not match:
        return slug_dept, ""
    name_part, dept = match.groups()
    # Capitaliser chaque mot séparé par des tirets
    name = " ".join(word.capitalize() for word in name_part.split("-"))
    return name, dept
```

**Valeurs OG dynamiques pour `/meteo/{slug_dept}/{start}/{end}`** :

```python
@app.get("/meteo/{slug_dept}/{start}/{end}")
async def seo_meteo_page(
    request: Request,
    slug_dept: str,
    start: str = FastAPIPath(pattern=ISO_DATE_PATTERN),
    end: str = FastAPIPath(pattern=ISO_DATE_PATTERN),
) -> Response:
    commune_name, dept = _commune_name_from_slug(slug_dept)
    base_url = str(request.base_url).rstrip("/")
    canonical = f"/meteo/{slug_dept}/{start}/{end}"

    # Formater les dates pour le titre OG (simplifié, sans librairie)
    og_title = f"Quel temps faisait-il à {commune_name} du {start} au {end}\u00a0?"
    og_desc = (
        f"Consultez la météo passée à {commune_name}\u00a0: "
        "températures, pluie, vent et conditions heure par heure."
    )
    og_image = f"{base_url}/api/og-image/{slug_dept}/{start}/{end}"
    og_url = f"{base_url}{canonical}"

    og = {
        "og:title": og_title,
        "og:description": og_desc,
        "og:image": og_image,
        "og:url": og_url,
        "og:type": "website",
        "twitter:card": "summary_large_image",
        "twitter:title": og_title,
        "twitter:description": og_desc,
        "twitter:image": og_image,
        "canonical": canonical,
    }

    html = _inject_og_tags(_html_template, og)
    return Response(content=html, media_type="text/html")
```

**Chargement du template au démarrage** (dans `lifespan`) :

```python
@asynccontextmanager
async def lifespan(_app: FastAPI):
    global _html_template
    _html_template = index_file.read_text(encoding="utf-8")
    yield
    await commune_service.client.aclose()
    await weather_service.client.aclose()
    await normals_service.client.aclose()
```

**Note sur les dates dans le titre OG** : le format `start` et `end` sont en ISO `YYYY-MM-DD`. Pour les crawlers, ce format est suffisant et non ambigu. Un formatage en français lisible (« du 9 au 11 mars 2026 ») nécessiterait de parser les dates et d'utiliser un mapping de mois en français côté Python. Ce formatage est une **amélioration future non bloquante**. Pour le MVP, le format ISO est acceptable dans les balises OG meta (les réseaux sociaux affichent de toute façon leur propre formatage dans certains cas).

**Alternative autorisée** : si le développeur souhaite ajouter un helper de formatage de dates en français côté Python (sans dépendance supplémentaire, via un simple dict de mois), c'est autorisé. Voir §5 « Simplifications autorisées ».

### 3.6) Image OG dynamique (backend — nouveau service)

**Nouveau fichier** : `src/og_service.py`

**Dépendance** : `Pillow` (ajouté à `requirements.txt`).

**Police** : `Inter-SemiBold.ttf` — police open source (SIL Open Font License), utilisée par le site. Le fichier est placé dans `src/assets/fonts/Inter-SemiBold.ttf`. Le développeur doit télécharger le fichier depuis Google Fonts (ou le repo GitHub de Inter) et le placer à cet emplacement.

**Classe** `OGImageService` :

```python
from io import BytesIO
from pathlib import Path
from collections import Counter

from PIL import Image, ImageDraw, ImageFont

from src.cache import TTLCache
from src.config import OG_IMAGE_CACHE_TTL_SECONDS, OG_IMAGE_CACHE_MAX_ENTRIES


class OGImageService:
    WIDTH = 1200
    HEIGHT = 630
    BG_COLOR = "#1a5276"       # Bleu foncé (cohérent avec le thème)
    TEXT_COLOR = "#ffffff"
    ACCENT_COLOR = "#85c1e9"   # Bleu clair pour les données

    def __init__(self) -> None:
        fonts_dir = Path(__file__).resolve().parent / "assets" / "fonts"
        self._font_path = fonts_dir / "Inter-SemiBold.ttf"
        self._logo_path = (
            Path(__file__).resolve().parent.parent / "public" / "assets" / "logo-histometeo.png"
        )
        self._cache: TTLCache = TTLCache(
            ttl_seconds=OG_IMAGE_CACHE_TTL_SECONDS,
            max_entries=OG_IMAGE_CACHE_MAX_ENTRIES,
        )

    def _load_font(self, size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
        try:
            return ImageFont.truetype(str(self._font_path), size=size)
        except OSError:
            return ImageFont.load_default()

    def generate(
        self,
        commune_name: str,
        start: str,
        end: str,
        temp_min: float | None,
        temp_max: float | None,
        dominant_desc: str | None,
    ) -> bytes:
        cache_key = f"{commune_name}|{start}|{end}"
        cached = self._cache.get(cache_key)
        if cached is not None:
            return cached

        img = Image.new("RGB", (self.WIDTH, self.HEIGHT), color=self.BG_COLOR)
        draw = ImageDraw.Draw(img)

        font_lg = self._load_font(52)
        font_md = self._load_font(36)
        font_sm = self._load_font(28)

        y_cursor = 40

        # Logo (si disponible)
        try:
            logo = Image.open(self._logo_path).convert("RGBA")
            logo_height = 60
            logo_width = int(logo.width * logo_height / logo.height)
            logo = logo.resize((logo_width, logo_height), Image.LANCZOS)
            img.paste(logo, (60, y_cursor), logo)
            y_cursor += logo_height + 40
        except (OSError, ValueError):
            y_cursor += 20

        # Nom de la commune
        draw.text((60, y_cursor), commune_name, font=font_lg, fill=self.TEXT_COLOR)
        y_cursor += 70

        # Dates
        date_text = f"{start}  →  {end}" if start != end else start
        draw.text((60, y_cursor), date_text, font=font_md, fill=self.ACCENT_COLOR)
        y_cursor += 60

        # Températures
        if temp_min is not None and temp_max is not None:
            temp_text = f"{temp_min:.1f} °C  →  {temp_max:.1f} °C"
            draw.text((60, y_cursor), temp_text, font=font_lg, fill=self.TEXT_COLOR)
            y_cursor += 70

        # Condition dominante (texte, pas emoji — Pillow ne gère pas les emojis)
        if dominant_desc and dominant_desc != "N/A":
            condition_label = f"Conditions : {dominant_desc.lower()}"
            draw.text((60, y_cursor), condition_label, font=font_sm, fill=self.ACCENT_COLOR)
            y_cursor += 50

        # Pied : URL du site
        draw.text(
            (60, self.HEIGHT - 60),
            "histometeo.com",
            font=font_sm,
            fill="#ffffffaa",
        )

        buf = BytesIO()
        img.save(buf, format="PNG", optimize=True)
        result = buf.getvalue()
        self._cache.set(cache_key, result)
        return result
```

**Agrégation des données météo pour l'image** — le endpoint OG image doit récupérer les données météo puis les agréger. Une fonction utilitaire dans `og_service.py` :

```python
def aggregate_for_og(daily_summary: list[dict]) -> dict:
    """Agrège les daily_summary pour extraire les infos nécessaires à l'image OG."""
    if not daily_summary:
        return {"temp_min": None, "temp_max": None, "dominant_desc": None}

    temp_mins = [d["temp_min"] for d in daily_summary if d["temp_min"] is not None]
    temp_maxs = [d["temp_max"] for d in daily_summary if d["temp_max"] is not None]

    # Condition dominante
    desc_counts: Counter[str] = Counter()
    for d in daily_summary:
        desc = d.get("description")
        if desc:
            desc_counts[desc] += 1

    dominant_desc = desc_counts.most_common(1)[0][0] if desc_counts else None

    return {
        "temp_min": min(temp_mins) if temp_mins else None,
        "temp_max": max(temp_maxs) if temp_maxs else None,
        "dominant_desc": dominant_desc,
    }
```

### 3.7) Endpoint OG image (backend)

**Route** : `GET /api/og-image/{slug_dept}/{start}/{end}`

```python
@app.get("/api/og-image/{slug_dept}/{start}/{end}")
async def og_image(
    slug_dept: str,
    start: str = FastAPIPath(pattern=ISO_DATE_PATTERN),
    end: str = FastAPIPath(pattern=ISO_DATE_PATTERN),
) -> Response:
    commune_name, _ = _commune_name_from_slug(slug_dept)

    # Tenter de récupérer les données météo pour enrichir l'image
    temp_min = None
    temp_max = None
    dominant_desc = None

    try:
        commune = await commune_service.resolve_slug(slug_dept)
        if commune:
            weather = await weather_service.get_weather(
                commune["lat"], commune["lon"], start, end,
            )
            agg = og_service.aggregate_for_og(weather.get("daily_summary", []))
            temp_min = agg["temp_min"]
            temp_max = agg["temp_max"]
            dominant_desc = agg["dominant_desc"]
            # Utiliser le nom avec accents si disponible
            commune_name = commune.get("nom", commune_name)
    except Exception:
        pass  # Fallback : image avec nom du slug + pas de données météo

    image_bytes = og_service.generate(
        commune_name=commune_name,
        start=start,
        end=end,
        temp_min=temp_min,
        temp_max=temp_max,
        dominant_desc=dominant_desc,
    )

    return Response(
        content=image_bytes,
        media_type="image/png",
        headers={"Cache-Control": "public, max-age=86400"},
    )
```

**Points clés** :

- Si la résolution du slug ou le fetch météo échoue, l'image est générée avec le nom extrait du slug et sans données météo. Pas de 500 — l'image est toujours servie.
- Le header `Cache-Control: public, max-age=86400` (24h) permet aux CDN et navigateurs de cacher l'image.
- `resolve_slug` retourne le nom avec accents (`commune["nom"]`), ce qui améliore la qualité du texte dans l'image par rapport au slug décodé.

### 3.8) Intégration R41 — `clearResults` reset h2

Dans `clearResults()` (fichier `app.js`), ajouter un appel à `setPeriodSummaryTitle()` sans argument pour réinitialiser le `<h2>` du `#period-summary` :

```js
// Dans clearResults(), après periodSummaryBody.replaceChildren() :
setPeriodSummaryTitle();
```

Cet ajout rend `clearResults()` cohérent avec les autres sections qui recréent leur `<h2>` dans cette fonction (ex: `climateNormalsSection.replaceChildren(...)`, `communeInfoSection.replaceChildren(...)`).

### 3.9) Intégration R42 — min-width daily summary réduit

Dans `public/style.css`, modifier la propriété `min-width` du tableau `#daily-summary table` :

```css
/* Avant */
#daily-summary table {
  min-width: 860px;
}

/* Après */
#daily-summary table {
  min-width: 780px;
}
```

Justification : la colonne Conditions ne contient plus de texte (juste un emoji) depuis la v13. Le tableau est désormais plus étroit, ce qui réduit le défilement horizontal sur mobile.

### 3.10) Intégration R43 — README.md dans le scope

Le `README.md` est formellement ajouté à la liste des fichiers autorisés (cf. §0 Scope). Les modifications nécessaires : documenter la feature de partage social et la commande pour télécharger la police Inter.

### 3.11) Masquage du bloc de partage dans `clearResults`

Dans `clearResults()`, ajouter :

```js
const shareBlock = document.getElementById("share-block");
if (shareBlock) {
  shareBlock.replaceChildren();
  shareBlock.classList.add("hidden");
}
```

Cela garantit que le bloc de partage est masqué quand les résultats sont effacés, cohérent avec le comportement de toutes les autres sections de résultats.

---

## 4) Plan d'implémentation

### Étape 1 — Backend : dépendances et configuration

**Fichiers** : `requirements.txt`, `src/config.py`, `Dockerfile`

1. **Ajouter** `Pillow` à `requirements.txt`.
2. **Ajouter** dans `config.py` :
   ```python
   OG_IMAGE_CACHE_TTL_SECONDS = 7 * 24 * 60 * 60  # 7 jours
   OG_IMAGE_CACHE_MAX_ENTRIES = 200
   SITE_NAME = "HistoMétéo"
   ```
3. **Modifier** le `Dockerfile` pour installer la police Inter :
   ```dockerfile
   # Après la ligne COPY requirements.txt ...
   # Créer le répertoire pour la police
   COPY src/assets /app/src/assets
   ```
   Ajouter la ligne `COPY src/assets /app/src/assets` (le `COPY src /app/src` existant la copie déjà, mais vérifier que le dossier `src/assets/fonts/` est bien inclus).
4. **Créer** le répertoire `src/assets/fonts/` et y placer le fichier `Inter-SemiBold.ttf` (téléchargé depuis https://github.com/rsms/inter/releases — fichier sous licence SIL OFL).

**Vérifiable** : `pip install -r requirements.txt` installe Pillow sans erreur. `from PIL import Image` fonctionne. La police existe à `src/assets/fonts/Inter-SemiBold.ttf`.

### Étape 2 — Backend : service OG image

**Fichier** : `src/og_service.py` (NOUVEAU)

1. **Créer** le fichier avec la classe `OGImageService` et la fonction `aggregate_for_og()` décrits en §3.6.
2. **Écrire** les tests unitaires correspondants (voir §6).

**Vérifiable** : `og_service.generate(...)` retourne des bytes PNG valides. `aggregate_for_og([...])` retourne les bonnes valeurs agrégées.

### Étape 3 — Backend : injection OG + endpoint image dans `main.py`

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

1. **Ajouter** l'import de `og_service` et instancier `OGImageService` au même niveau que les autres services.
2. **Ajouter** la variable globale `_html_template` et la charger dans `lifespan`.
3. **Ajouter** les helpers `_inject_og_tags()` et `_commune_name_from_slug()`.
4. **Modifier** `seo_meteo_page()` pour injecter les OG tags dynamiques au lieu de retourner `FileResponse`.
5. **Ajouter** le nouvel endpoint `GET /api/og-image/{slug_dept}/{start}/{end}`.
6. **Modifier** `seo_comparison_page()` pour servir le HTML depuis le template en mémoire (même si sans OG dynamiques pour l'instant), afin de garder la cohérence. Alternative : conserver `FileResponse` pour la comparaison ; les deux approches sont acceptables.

**Vérifiable** : `curl http://localhost:8000/meteo/bordeaux-33/2026-03-09/2026-03-11 | grep og:title` contient « Bordeaux ». L'image OG est accessible et est un PNG valide.

### Étape 4 — HTML : bloc de partage + balises Twitter Card

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

1. **Ajouter** la section `#share-block` entre `#period-summary` et `#commune-info`.
2. **Ajouter** les 4 balises `<meta name="twitter:...">` dans le `<head>`, après les balises OG existantes.
3. **Ajouter** `<meta property="og:url" content="/" />` dans le `<head>`.

**Vérifiable** : le HTML est valide. Les balises twitter:card, twitter:title, twitter:description, twitter:image sont présentes avec des valeurs par défaut. La section `#share-block` est entre `#period-summary` et `#commune-info`.

### Étape 5 — JS : fonctions de partage + R41

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

1. **Ajouter** les fonctions `buildShareText()`, `copyLinkToClipboard()`, `showShareFeedback()`, `renderShareBlock()`.
2. **Appeler** `renderShareBlock()` dans le flux de recherche simple, après `renderPeriodSummary()`, en passant les agrégats, le nom de commune, les dates et `isComparison = false`.
3. **En mode comparaison**, appeler `renderShareBlock()` avec `isComparison = true` (ce qui masque le bloc).
4. **Intégrer R41** : ajouter `setPeriodSummaryTitle()` dans `clearResults()`.
5. **Ajouter** le masquage du `#share-block` dans `clearResults()` (cf. §3.11).

**Vérifiable** : après une recherche, le bloc de partage apparaît avec 5 boutons. « Copier le lien » copie l'URL et affiche le feedback. Les boutons de partage ouvrent les bonnes URL. En mode comparaison, le bloc est masqué.

### Étape 6 — CSS : styles partage + R42

**Fichier** : `public/style.css`

1. **Ajouter** les styles pour `.share-block`, `.share-buttons`, `.share-btn`, `.share-btn-copy`, `.share-feedback`.
2. **Intégrer R42** : réduire `#daily-summary table { min-width: 860px }` à `780px`.

**Styles proposés** :

```css
/* Bloc de partage */
.share-block {
  text-align: center;
}

.share-buttons {
  display: flex;
  flex-wrap: wrap;
  gap: 0.5rem;
  justify-content: center;
  margin-top: 0.75rem;
}

.share-btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  padding: 0.5rem 1rem;
  border-radius: var(--radius);
  font-size: 0.9rem;
  font-weight: 500;
  text-decoration: none;
  cursor: pointer;
  min-height: 44px;
  border: 1px solid var(--border);
  background: var(--surface);
  color: var(--text);
  transition:
    background 0.15s,
    border-color 0.15s;
}

.share-btn:hover {
  background: var(--hover);
  border-color: var(--primary);
}

.share-btn-copy {
  background: var(--primary);
  color: #fff;
  border-color: var(--primary);
}

.share-btn-copy:hover {
  background: var(--primary-hover, #1a6fa0);
}

.share-feedback {
  margin-top: 0.5rem;
  font-size: 0.85rem;
  color: var(--primary);
  font-weight: 500;
}
```

Le bloc est centré et les boutons s'adaptent en `flex-wrap` pour les petits écrans (INV-12 respecté).

**Vérifiable** : le bloc de partage est visuellement cohérent avec le reste de l'interface. Les boutons sont alignés horizontalement sur desktop et passent à la ligne sur mobile. Le tableau résumé par jour a un `min-width` de 780px.

### Étape 7 — Tests + Validation

1. **Écrire** les tests unitaires pour `og_service.py` (voir §6).
2. **Écrire** les tests pour l'injection OG dans `main.py` (voir §6).
3. **Exécuter** `pytest tests/ -v` — les 61 tests existants + les nouveaux tests doivent passer.
4. **Vérifier** les invariants :
   - `grep innerHTML app.js` → 0 occurrence (INV-7)
   - `grep "window.innerWidth\|matchMedia\|userAgent" app.js` → 0 occurrence (INV-12)
5. **Tester manuellement** les scénarios TM-41 à TM-52 (§6).
6. **Vérifier** avec un outil de preview OG (ex: https://www.opengraph.xyz/) que les balises sont correctement parsées.

---

## 5) Guide pour le Développeur

### Pièges fréquents

1. **`FileResponse` → `Response`** — le changement dans `seo_meteo_page` est le point le plus impactant. Vérifier que le template chargé en mémoire est bien en UTF-8 (`index_file.read_text(encoding="utf-8")`). Ne pas oublier de spécifier `media_type="text/html"` sur la `Response`.

2. **`_html_template` est une variable globale** — elle est chargée une seule fois au démarrage dans `lifespan`. Si `index.html` est modifié à chaud en développement, le serveur doit être relancé. C'est le comportement normal avec `uvicorn` sans `--reload` ; avec `--reload`, le lifespan est ré-exécuté et le template rechargé.

3. **Les valeurs OG injectées doivent être échappées pour les attributs HTML** — utiliser `html.escape(value, quote=True)` pour éviter les injections XSS via les noms de commune. Exemple : si un slug contenait `<script>`, le `html.escape` le neutralise.

4. **Pillow et les emojis** — Pillow ne rend **pas** les emojis Unicode de manière fiable (ils nécessitent une police emoji dédiée, ex: Noto Color Emoji, ~10 MB). L'image OG utilise la **description textuelle** de la condition dominante (ex: « Principalement couvert ») et non l'emoji. N'essayez pas de dessiner des emojis sur l'image.

5. **Le endpoint OG image fait des appels réseau** — `resolve_slug()` et `get_weather()` sont asynchrones et peuvent être lents (première requête). Mais les résultats sont cachés par les services existants. Le `try/except` global dans l'endpoint garantit qu'une image est toujours retournée, même en cas d'erreur.

6. **`navigator.clipboard.writeText` nécessite un contexte sécurisé** — fonctionne en HTTPS et sur `localhost`. En HTTP simple sur un autre hostname, l'API Clipboard est bloquée par le navigateur. Le `try/catch` gère ce cas avec un message d'erreur.

7. **Le texte de partage utilise `\n` (line break)** — lors de l'encodage pour l'URL WhatsApp, `\n` est encodé en `%0A`. Vérifier que le texte est correctement encodé via `encodeURIComponent()`.

8. **Le bloc de partage avec `<a>` links** — les liens de partage utilisent `<a>` et non `window.open()`. C'est plus sûr (pas de popup bloqué) et sémantiquement correct. Le `rel="noopener"` est obligatoire sur tous les `<a target="_blank">` pour prévenir le reverse tab-napping.

### Zones de dérive

- **Ne pas implémenter de preview card côté frontend** (ex: image canvas, screenshot de la page). L'image OG est générée côté serveur uniquement.
- **Ne pas ajouter Web Share API** (`navigator.share()`) — c'est une amélioration future possible mais hors scope v14.
- **Ne pas ajouter de compteur de partages** — aucun tracking, aucune donnée stockée (INV-1).
- **Ne pas modifier les services existants** (`weather_service`, `commune_service`, etc.) — le service OG les utilise par composition, il ne les modifie pas.
- **Ne pas implémenter le partage en mode comparaison** — c'est une évolution future (INV-17).
- **Ne pas ajouter de balises OG dynamiques sur les pages de comparaison** — les balises par défaut (homepage) sont servies.

### Simplifications autorisées

- Le formatage des dates dans les balises OG peut rester en ISO (`YYYY-MM-DD`) pour le MVP. Un helper Python de formatage en français (avec un dict de mois) est autorisé si le développeur le souhaite.
- Si la police Inter n'est pas disponible, Pillow utilise sa police par défaut. L'image est moins esthétique mais fonctionnelle.
- Les couleurs de l'image OG peuvent être ajustées (`BG_COLOR`, `TEXT_COLOR`, `ACCENT_COLOR`) pour mieux correspondre à l'identité visuelle.
- Le `Cache-Control` sur l'image OG peut être ajusté (entre 3600 et 604800 secondes).

### Décisions explicitement interdites

- **Interdit** : utiliser `innerHTML` pour construire le bloc de partage.
- **Interdit** : utiliser `!important` dans les nouvelles règles CSS.
- **Interdit** : utiliser `window.open()` pour les liens de partage social (utiliser `<a>` avec `target="_blank" rel="noopener"`).
- **Interdit** : modifier `computeAggregates()`, les services existants, ou les tests existants.
- **Interdit** : stocker des données de partage (compteurs, analytics, cookies).
- **Interdit** : ajouter une dépendance frontend (pas de share widget tiers).
- **Interdit** : injecter du HTML non-échappé dans les balises OG (toujours utiliser `html.escape`).

---

## 6) Stratégie de tests

### Tests automatisés existants

Les 61 tests backend (`pytest tests/ -v`) doivent passer à l'identique. Aucun test existant n'est modifié ou supprimé.

### Nouveaux tests automatisés

**Fichier** : `tests/test_og_service.py` (NOUVEAU)

| #    | Test                                          | Type     | Assertion                                                                                  |
| ---- | --------------------------------------------- | -------- | ------------------------------------------------------------------------------------------ |
| T-01 | `test_aggregate_for_og_basic`                 | Unitaire | `aggregate_for_og` retourne les bonnes min/max température et la condition dominante.      |
| T-02 | `test_aggregate_for_og_empty`                 | Unitaire | `aggregate_for_og([])` retourne `{temp_min: None, temp_max: None, dominant_desc: None}`.   |
| T-03 | `test_aggregate_for_og_partial_data`          | Unitaire | Données partielles (certaines temp null) → agrégation correcte sur les valeurs non-null.   |
| T-04 | `test_og_image_generate_returns_png`          | Unitaire | `og_service.generate(...)` retourne des bytes commençant par la signature PNG (`\x89PNG`). |
| T-05 | `test_og_image_generate_dimensions`           | Unitaire | L'image décodée fait 1200×630 px.                                                          |
| T-06 | `test_og_image_generate_without_weather_data` | Unitaire | `generate(commune, start, end, None, None, None)` retourne un PNG valide (mode fallback).  |
| T-07 | `test_og_image_cache`                         | Unitaire | Deux appels avec les mêmes paramètres retournent le même objet bytes (cache hit).          |

**Fichier** : `tests/test_api.py` (ajouts)

| #    | Test                                      | Type        | Assertion                                                                                              |
| ---- | ----------------------------------------- | ----------- | ------------------------------------------------------------------------------------------------------ |
| T-08 | `test_seo_page_contains_dynamic_og_title` | Intégration | `GET /meteo/bordeaux-33/2026-03-09/2026-03-11` → HTML contient `og:title` avec « Bordeaux ».           |
| T-09 | `test_seo_page_contains_twitter_card`     | Intégration | `GET /meteo/bordeaux-33/2026-03-09/2026-03-11` → HTML contient `twitter:card` = `summary_large_image`. |
| T-10 | `test_seo_page_contains_canonical`        | Intégration | `GET /meteo/bordeaux-33/2026-03-09/2026-03-11` → `<link rel="canonical">` contient le bon path.        |
| T-11 | `test_og_image_endpoint_returns_png`      | Intégration | `GET /api/og-image/bordeaux-33/2026-03-09/2026-03-11` → status 200, content-type `image/png`.          |
| T-12 | `test_og_image_endpoint_invalid_dates`    | Intégration | `GET /api/og-image/bordeaux-33/invalid/invalid` → status 422 (validation FastAPI).                     |
| T-13 | `test_homepage_keeps_default_og`          | Intégration | `GET /` → HTML contient les OG par défaut (« HistoMétéo — Historique de la Météo »).                   |
| T-14 | `test_inject_og_tags_escapes_html`        | Unitaire    | `_inject_og_tags(template, {"og:title": '<script>xss</script>'})` → le titre est échappé.              |

### Tests manuels requis

| #     | Scénario                                                   | Device simulé | Résultat attendu                                                                                                                            |
| ----- | ---------------------------------------------------------- | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
| TM-41 | Bloc de partage visible après recherche simple             | 1024px        | Le bloc « Partager cette page » apparaît entre le résumé de période et les infos commune, avec 5 boutons.                                   |
| TM-42 | Copier le lien                                             | 1024px        | Clic sur « 📋 Copier le lien » → URL copiée dans le presse-papier, message « Lien copié dans le presse-papier » affiché 3 secondes.         |
| TM-43 | Partage Facebook                                           | 1024px        | Clic sur « Facebook » → nouvel onglet ouvert sur `facebook.com/sharer/sharer.php?u=...` avec l'URL de la page.                              |
| TM-44 | Partage X                                                  | 1024px        | Clic sur « X » → nouvel onglet ouvert sur `twitter.com/intent/tweet?url=...&text=...` avec l'URL et le texte pré-rempli.                    |
| TM-45 | Partage WhatsApp                                           | 360px         | Clic sur « WhatsApp » → ouverture de WhatsApp (ou web) avec le texte + URL.                                                                 |
| TM-46 | Partage LinkedIn                                           | 1024px        | Clic sur « LinkedIn » → nouvel onglet sur `linkedin.com/sharing/share-offsite/?url=...`.                                                    |
| TM-47 | Texte de partage pré-rempli                                | 1024px        | Le texte (visible dans l'URL Twitter/WhatsApp) contient le nom de commune, les dates, les températures et les précipitations.               |
| TM-48 | Bloc de partage masqué en mode comparaison                 | 1024px        | Activer la comparaison → le bloc de partage disparaît.                                                                                      |
| TM-49 | Bloc de partage masqué après clearResults                  | 1024px        | Taper un nouveau nom de commune (sélection perdue) → le bloc de partage disparaît.                                                          |
| TM-50 | OG preview avec outil en ligne                             | N/A           | Tester l'URL `/meteo/bordeaux-33/2026-03-09/2026-03-11` sur opengraph.xyz → titre, description, image OG corrects.                          |
| TM-51 | Image OG visible et correcte                               | N/A           | Accéder à `/api/og-image/bordeaux-33/2026-03-09/2026-03-11` directement → PNG 1200×630 avec nom, dates, températures.                       |
| TM-52 | Responsive 360px : bloc de partage                         | 360px         | Les 5 boutons de partage s'affichent sur 2-3 lignes sans débordement horizontal.                                                            |
| TM-53 | R41 — clearResults réinitialise le h2 du résumé de période | 1024px        | Après une recherche, les résultats affichent l'icône dans le h2. Après `clearResults` (nouvelle saisie), le h2 est réinitialisé sans icône. |
| TM-54 | R42 — tableau résumé par jour moins large                  | 360px         | Le tableau résumé par jour a un `min-width` de 780px (vérifier dans DevTools). Moins de scroll horizontal qu'avant.                         |

### Edge cases critiques

- **Commune avec caractères spéciaux dans le slug** (ex: `sainte-croix-en-plaine-68`) — le `_commune_name_from_slug` capitalise chaque mot correctement (`Sainte Croix En Plaine`).
- **`navigator.clipboard` non disponible** (HTTP non sécurisé) — le `try/catch` affiche « Impossible de copier le lien ».
- **Données météo indisponibles pour l'image OG** — l'image est générée avec uniquement le nom et les dates (pas de températures ni condition).
- **Police Inter non trouvée** — Pillow utilise la police par défaut. L'image est moins belle mais fonctionnelle.
- **Slug invalide dans `/api/og-image/`** — le pattern ISO_DATE est validé par FastAPI. Un slug sans dept retourne quand même une image (le nom est le slug brut).

---

## 7) Risques techniques

| #   | Risque                                                                                    | Probabilité | Impact | Mitigation                                                                                                                                                 |
| --- | ----------------------------------------------------------------------------------------- | ----------- | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 1   | Les crawlers sociaux timeout sur l'image OG (première requête lente : résolution + fetch) | Moyenne     | Moyen  | Cache serveur (TTL 7j) + header `Cache-Control: public, max-age=86400`. Après la première requête, les données sont en cache et l'image est instantanée.   |
| 2   | L'injection regex des OG tags casse si le format HTML du template change                  | Faible      | Élevé  | Les regex sont spécifiques aux attributs exacts (`<meta property="og:title" content="..."`). Tester avec T-08/T-09/T-10 à chaque modification du template. |
| 3   | Pillow n'est pas installable sur certaines architectures (ARM, Alpine minimal)            | Faible      | Moyen  | Le Dockerfile utilise `python:3.13-slim` (Debian-based) sur lequel le wheel Pillow pré-compilé est disponible. Tester le build Docker.                     |
