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

> **Itération** : v12 — intègre le feedback Reviewer v11 (R35–R37, C1 confirmé corrigé) + nouvelle demande « Amélioration du bloc de recherche et de la saisie des dates ».

---

## 0) Contract

- **Source of truth** : ce document (`001-histometeo-mvp.tech.v12.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` — restructuration du bloc `#search` (ordre des éléments, remplacement des `input[type="date"]` par des champs texte + bouton calendrier, ajout flèche visuelle, mise à jour du texte du bouton principal)
  - `public/app.js` — composant de saisie de date (masque `JJ/MM/AAAA`, parsing multi-format, saisie rapide 8 chiffres, synchronisation calendrier natif), intégration R37 (`passive: true`), mise à jour des interactions de date
  - `public/style.css` — styles du nouveau composant date (wrapper, icône, flèche), intégration R35 (déduplication CSS tactile), R36 (`min-height: unset`)
- **Forbidden changes** :
  - `src/` — **aucun fichier backend modifié** (zéro changement Python)
  - `src/weather_service.py`, `src/cache.py`, `src/commune_service.py`, `src/config.py`, `src/normals_service.py`, `src/main.py` — aucune modification
  - `tests/` — aucune modification
  - `docs/` — aucune modification des specs existantes (hors ce fichier)
  - `.github/` — aucune modification
  - `Dockerfile`, `pyproject.toml`, `requirements.txt` — pas de modification
  - `public/assets/` — pas de modification
- **Invariants** (tous hérités et préservés) :
  - INV-1 : Aucune donnée utilisateur stockée (ni serveur, ni client)
  - INV-2 : Heures en fuseau `Europe/Paris`
  - INV-3 : Période maximale 31 jours
  - INV-4 : Aucune clé API
  - INV-5 : Interface en français avec accents (`HistoMétéo`)
  - INV-6b : Page unique, URL reflète l'état via des routes propres (path segments)
  - INV-7 : Aucune injection HTML — `textContent`, `createElement`, `replaceChildren()` uniquement, jamais `innerHTML`
  - INV-8 : Le flux recherche simple fonctionne indépendamment du mode comparaison
  - INV-9 : Les normales climatiques sont un enrichissement progressif — leur indisponibilité ne bloque pas l'affichage des données météo ni la navigation
  - INV-10 : Les informations commune sont un enrichissement progressif — leur absence partielle n'empêche pas l'affichage du bloc
  - INV-11 : 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** (nouveau) : 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`.
- **Done when** :
  - Les 11 critères d'acceptation originaux (AC1–AC11) restent vérifiables
  - Toutes les fonctionnalités v2–v11 restent opérationnelles
  - Le bloc de recherche suit l'ordre : commune → période → durées rapides → comparaison → bouton principal
  - Les champs `input[type="date"]` sont remplacés par des champs texte avec masque `JJ/MM/AAAA`
  - La saisie manuelle accepte les formats `JJ/MM/AAAA`, `JJ-MM-AAAA`, `AAAA-MM-JJ`
  - La saisie rapide 8 chiffres (`29121996`) est auto-formatée en `29/12/1996`
  - Un bouton calendrier permet d'ouvrir le sélecteur natif du navigateur, positionné sur l'année correspondant à la valeur du champ
  - Une flèche visuelle `→` sépare les deux champs de date sur desktop ; empilés sur mobile
  - Le placeholder du champ commune est mis à jour (`Rechercher une commune...`)
  - Le bouton principal affiche `Voir la météo`
  - Les dates invalides ou hors plage affichent un message d'erreur clair et empêchent la soumission
  - R35 intégré : déduplication des propriétés tactiles CSS
  - R36 intégré : `min-height: unset` au lieu de `min-height: 0`
  - R37 intégré : `{ passive: true }` sur le listener `touchstart`
  - Tous les tests backend existants (61/61) passent sans modification
  - Le site reste utilisable à 360px (iPhone SE) et identique visuellement sur desktop ≥ 768px

---

## 1) Objectif technique

Remplacer les sélecteurs de date natifs (`input[type="date"]`) par un composant de saisie de date personnalisé combinant champ texte à masque et calendrier natif optionnel, afin de permettre la saisie rapide de dates anciennes. Réorganiser le bloc de recherche pour une meilleure lisibilité. Intégrer les recommandations R35–R37 du feedback v11.

---

## 2) Analyse du brief

### Besoins principaux

| #   | Besoin                                                 | Source             | Complexité | Impact             |
| --- | ------------------------------------------------------ | ------------------ | ---------- | ------------------ |
| D1  | Composant date texte + masque `JJ/MM/AAAA`             | Demande UX §5–7    | Moyenne    | Saisie rapide      |
| D2  | Support multi-format (`JJ/MM/AAAA`, `JJ-MM-AAAA`, ISO) | Demande UX §6      | Faible     | Flexibilité        |
| D3  | Saisie rapide 8 chiffres → auto-format                 | Demande UX §8      | Faible     | Efficacité         |
| D4  | Bouton calendrier avec ouverture sur la bonne année    | Demande UX §5, §10 | Moyenne    | Accessibilité      |
| D5  | Flèche visuelle `→` entre les deux champs date         | Demande UX §3      | Trivial    | Clarté             |
| D6  | Réorganisation visuelle du bloc recherche              | Demande UX §1      | Faible     | Structure          |
| D7  | Mise à jour placeholder commune                        | Demande UX §2      | Trivial    | Clarté             |
| D8  | Bouton `Voir la météo` au lieu de `Rechercher`         | Demande UX §13     | Trivial    | Clarté             |
| D9  | Validation dates avec messages d'erreur clairs         | Demande UX §9      | Déjà fait  | Déjà en place      |
| R35 | Déduplication CSS propriétés tactiles                  | Feedback v11       | Faible     | Maintenabilité CSS |
| R36 | `min-height: unset` au lieu de `min-height: 0`         | Feedback v11       | Trivial    | Sémantique CSS     |
| R37 | `{ passive: true }` sur `touchstart`                   | Feedback v11       | Trivial    | Performance scroll |

### Contraintes

- **Aucune nouvelle dépendance** — le composant date est implémenté en JS vanilla. Pas de librairie date-picker tierce.
- **Aucun changement backend** — le périmètre est exclusivement frontend.
- **INV-7 absolu** — tout le DOM du composant date est construit via `createElement` / `textContent`. Zéro `innerHTML`.
- **INV-12 respecté** — la flèche `→` entre les dates est visible/masquée par CSS uniquement (media query), pas par JS.
- **INV-13** — le format interne reste `YYYY-MM-DD`. La valeur interne (stockée dans un attribut `data-value`) est utilisée par toute la logique existante (`dateStart.value`, `dateEnd.value`). Le champ texte visible affiche `JJ/MM/AAAA`.
- **Le calendrier natif est un fallback** — il est déclenché par un `input[type="date"]` masqué. Ce choix garantit la compatibilité cross-browser sans code de calendrier custom.
- **La validation existante (`isDateRangeValid`) reste la source de vérité** — le composant date alimente les mêmes variables et appelle la même fonction de validation.

### Risques

| #   | Risque                                                                                     | Probabilité | Impact | Mitigation                                                                                                                                               |
| --- | ------------------------------------------------------------------------------------------ | ----------- | ------ | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 1   | `showPicker()` non supporté sur certains navigateurs anciens                               | Faible      | Moyen  | Fallback : un `click()` sur l'`input[type="date"]` masqué. Sur les navigateurs sans support, le clic est ignoré silencieusement — la saisie texte reste. |
| 2   | Le masque de saisie interfère avec les lecteurs d'écran                                    | Faible      | Moyen  | Attribut `aria-label` descriptif + `aria-describedby` pointant vers le format attendu. Le champ est `inputmode="numeric"`.                               |
| 3   | La saisie de dates sur mobile avec clavier tactile est moins fluide qu'un datepicker natif | Moyenne     | Moyen  | Le bouton calendrier reste disponible pour ceux qui préfèrent le sélecteur natif. Le masque guide la saisie (`__/__/____`).                              |

---

## 3) Design minimal proposé

### 3.1) Réorganisation du bloc `#search`

L'ordre actuel du DOM est déjà conforme à la demande. Vérification :

| Position | Élément actuel                  | Élément demandé         | Action            |
| -------- | ------------------------------- | ----------------------- | ----------------- |
| 1        | `h2` « Recherche »              | —                       | Conservé          |
| 2        | Champ commune                   | Sélection de la commune | Conservé          |
| 3        | Note période disponible         | —                       | Conservé          |
| 4        | Grille de dates                 | Sélection de la période | **Modifié**       |
| 5        | Boutons presets                 | Raccourcis de durée     | Conservé          |
| 6        | Bouton comparaison              | Comparaison             | Conservé          |
| 7        | Champs comparaison (masqués)    | —                       | Conservé          |
| 8        | Bouton principal « Rechercher » | « Voir la météo »       | **Texte modifié** |

**Modifications DOM** :

- Le placeholder du champ `#commune-input` passe de `Ex: Paris` à `Rechercher une commune...`
- Le texte du bouton `#search-button` passe de `Rechercher` à `Voir la météo` (et `Chargement...` pendant la requête reste inchangé)
- Le label `Période` remplace les labels séparés `Date de début` / `Date de fin` au niveau du groupe
- Les `input[type="date"]` sont remplacés par le composant date décrit en §3.2

### 3.2) Composant de saisie de date

Chaque champ date est remplacé par un wrapper contenant :

```
┌───────────────────────────────────┐
│  [  JJ / MM / AAAA  ]  [📅]      │
└───────────────────────────────────┘
```

**Structure HTML cible** (par champ) :

```html
<div class="date-input-wrapper">
  <input
    id="date-start-text"
    type="text"
    inputmode="numeric"
    placeholder="JJ/MM/AAAA"
    autocomplete="off"
    aria-label="Date de début (format JJ/MM/AAAA)"
    maxlength="10"
  />
  <button type="button" class="btn-calendar" aria-label="Ouvrir le calendrier">
    <svg
      width="20"
      height="20"
      viewBox="0 0 20 20"
      fill="none"
      aria-hidden="true"
    >
      <rect
        x="2"
        y="4"
        width="16"
        height="14"
        rx="2"
        stroke="currentColor"
        stroke-width="1.5"
      />
      <path d="M2 8h16" stroke="currentColor" stroke-width="1.5" />
      <path
        d="M6 2v4M14 2v4"
        stroke="currentColor"
        stroke-width="1.5"
        stroke-linecap="round"
      />
    </svg>
  </button>
  <input
    id="date-start"
    type="date"
    class="sr-only"
    tabindex="-1"
    aria-hidden="true"
  />
</div>
```

**Points clés** :

- Le `input[type="date"]` original est conservé mais masqué (`sr-only`) — il sert de bridge pour le calendrier natif.
- Le `input[type="text"]` est le champ visible, avec masque.
- Le bouton calendrier déclenche `showPicker()` sur l'input date masqué (ou `click()` en fallback).
- Quand l'utilisateur sélectionne une date via le calendrier natif, la valeur est récupérée et affichée formatée dans le champ texte.
- Le `id` de l'input date masqué reste `date-start` / `date-end` pour que le code existant (`dateStart.value`, `dateEnd.value`) continue de fonctionner **sans modification de la logique métier**.

### 3.3) Flèche visuelle entre les champs date

Un élément `<span class="date-arrow" aria-hidden="true">→</span>` est ajouté entre les deux wrappers dans la grille de dates. Sur mobile, il est masqué par CSS (les champs sont empilés).

**Structure HTML de la grille de dates** :

```html
<fieldset class="date-fieldset">
  <legend>Période</legend>
  <p class="date-range-note">Période disponible : du 01/01/1940 à hier.</p>
  <div class="date-grid">
    <div>
      <label for="date-start-text">Date de début</label>
      <div class="date-input-wrapper">
        <input
          id="date-start-text"
          type="text"
          inputmode="numeric"
          placeholder="JJ/MM/AAAA"
          autocomplete="off"
          aria-label="Date de début (format JJ/MM/AAAA)"
          maxlength="10"
          disabled
        />
        <button
          type="button"
          class="btn-calendar"
          aria-label="Ouvrir le calendrier de début"
          disabled
        >
          <!-- SVG calendrier -->
        </button>
        <input
          id="date-start"
          type="date"
          class="sr-only"
          tabindex="-1"
          aria-hidden="true"
        />
      </div>
    </div>
    <span class="date-arrow" aria-hidden="true">→</span>
    <div>
      <label for="date-end-text">Date de fin</label>
      <div class="date-input-wrapper">
        <input
          id="date-end-text"
          type="text"
          inputmode="numeric"
          placeholder="JJ/MM/AAAA"
          autocomplete="off"
          aria-label="Date de fin (format JJ/MM/AAAA)"
          maxlength="10"
          disabled
        />
        <button
          type="button"
          class="btn-calendar"
          aria-label="Ouvrir le calendrier de fin"
          disabled
        >
          <!-- SVG calendrier -->
        </button>
        <input
          id="date-end"
          type="date"
          class="sr-only"
          tabindex="-1"
          aria-hidden="true"
        />
      </div>
    </div>
  </div>
  <p id="date-message" class="field-message" aria-live="polite"></p>
</fieldset>
```

**Note** : le `<fieldset>` + `<legend>` remplace le label « Période » et améliore l'accessibilité du groupe. Le style du fieldset est remis à zéro (pas de bordure native).

### 3.4) Masque de saisie — logique JS

Le masque gère :

1. **Insertion automatique des séparateurs** — quand l'utilisateur tape `29`, le curseur ajoute `/` automatiquement → `29/`. Idem après le mois.
2. **Caractères autorisés** — uniquement les chiffres et `/` et `-`. Tout autre caractère est ignoré.
3. **Suppression** — `Backspace` supprime le caractère précédent. Si le caractère précédent est un `/`, il supprime aussi le chiffre avant.
4. **Saisie rapide 8 chiffres** — si l'utilisateur colle ou tape `29121996`, le masque le convertit en `29/12/1996`.
5. **Format ISO collé** — si l'utilisateur colle `1996-12-29`, le masque le reconnaît et affiche `29/12/1996`.

**Algorithme du masque** (pseudo-code) :

```
onInput(rawValue):
  digits = rawValue.replace(/\D/g, "")

  // Saisie ISO collée (commence par 4 chiffres > 1900)
  if rawValue matches /^\d{4}[-/]\d{2}[-/]\d{2}$/
    parse as YYYY-MM-DD → display as DD/MM/YYYY
    return

  // Format européen avec tirets collé
  if rawValue matches /^\d{2}-\d{2}-\d{4}$/
    parse as DD-MM-YYYY → display as DD/MM/YYYY
    return

  // Masque progressif sur les chiffres seuls
  if digits.length <= 2:
    display = digits
  else if digits.length <= 4:
    display = digits[0..1] + "/" + digits[2..]
  else:
    display = digits[0..1] + "/" + digits[2..3] + "/" + digits[4..7]

  set input.value = display
  if display.length == 10:
    validate and sync to hidden input
```

### 3.5) Parsing et validation des dates saisies

Quand le champ texte contient une date complète (10 caractères `JJ/MM/AAAA`), le composant :

1. **Parse** le jour, mois, année.
2. **Vérifie la validité** — le jour existe dans le mois (ex: pas de 31/02), l'année est un nombre valide.
3. **Vérifie la plage** — entre `01/01/1940` et hier inclus.
4. **Synchronise** — écrit la valeur ISO (`YYYY-MM-DD`) dans l'`input[type="date"]` masqué (`dateStart.value` / `dateEnd.value`).
5. **Appelle** `isDateRangeValid()` et `updateSearchButtonState()` — la chaîne de validation existante prend le relais.

**En cas de date invalide** (jour inexistant, année incohérente) :

- Le message `dateMessage.textContent` affiche une erreur claire : `Date invalide. Utilisez le format JJ/MM/AAAA.`
- L'input date masqué est vidé, ce qui désactive le bouton recherche via la chaîne existante.

**En cas de date hors plage** :

- La validation existante (`isDateRangeValid()`) gère déjà les messages `La date de début ne peut pas être antérieure au 1er janvier 1940` et `Seules les dates passées sont disponibles`.

### 3.6) Synchronisation avec le calendrier natif

Quand l'utilisateur clique sur le bouton calendrier :

1. **Avant ouverture** — la valeur actuelle du champ texte (si valide) est écrite dans l'`input[type="date"]` masqué. Ceci garantit que le calendrier natif s'ouvre sur la bonne année.
2. **Ouverture** — `hiddenInput.showPicker()` est appelé. Fallback : `hiddenInput.focus()` puis `hiddenInput.click()`.
3. **Après sélection** — le listener `change` sur l'input date masqué récupère la valeur ISO, la formate en `JJ/MM/AAAA`, et l'affiche dans le champ texte.

```js
function openCalendar(textInput, hiddenInput) {
  // Sync text → hidden before opening
  const parsed = parseDisplayDate(textInput.value);
  if (parsed) {
    hiddenInput.value = parsed; // ISO format
  }
  // Open native picker
  if (typeof hiddenInput.showPicker === "function") {
    hiddenInput.showPicker();
  } else {
    hiddenInput.focus();
    hiddenInput.click();
  }
}
```

### 3.7) Activation/désactivation des champs date

Le comportement actuel est conservé : les inputs date sont `disabled` tant qu'aucune commune n'est sélectionnée. Ce `disabled` s'applique désormais :

- aux `input[type="text"]` visibles (`#date-start-text`, `#date-end-text`)
- aux boutons calendrier (`.btn-calendar`)
- les `input[type="date"]` masqués restent disabled aussi (inchangé)

Le code existant qui fait `dateStart.disabled = false` / `dateEnd.disabled = false` doit être étendu pour inclure les nouveaux éléments.

### 3.8) Initialisation depuis l'URL

Le flux existant `restoreFromURL()` écrit déjà dans `dateStart.value` et `dateEnd.value`. Après cette écriture, le composant doit synchroniser la valeur vers le champ texte visible :

```js
function syncHiddenToText(hiddenInput, textInput) {
  if (hiddenInput.value) {
    textInput.value = isoToDisplay(hiddenInput.value); // "1996-12-29" → "29/12/1996"
  }
}
```

Cette synchronisation est appelée :

- après `restoreFromURL()`
- après `applyPresetPeriod()`
- après `shiftPeriod()`

### 3.9) Intégration R35 — Déduplication CSS tactile

Actuellement, la règle partagée (L161–168) déclare `min-height: 44px; display: inline-flex; align-items: center; justify-content: center;` pour 6 sélecteurs. Mais `.btn-secondary`, `.btn-cancel`, `.btn-period-link` et `.results-nav a` redéclarent ces mêmes 4 propriétés dans leurs blocs individuels.

**Correction** : supprimer les 4 propriétés dupliquées des blocs individuels. Ne conserver dans chaque bloc que les propriétés spécifiques (padding, font-size, border, color, etc.).

### 3.10) Intégration R36 — `min-height: unset`

Dans le breakpoint `@media (min-width: 640px)`, la règle `.results-nav a` déclare `min-height: 0`. Remplacer par `min-height: unset` — plus sémantique.

### 3.11) Intégration R37 — `{ passive: true }` sur `touchstart`

Le listener `touchstart` de fermeture des suggestions devient :

```js
document.addEventListener(
  "touchstart",
  (e) => {
    if (!e.target.closest(".autocomplete")) {
      autocompleteMain.hideSuggestions();
      autocompleteCompare.hideSuggestions();
    }
  },
  { passive: true },
);
```

### 3.12) Fonctions utilitaires date

Nouvelles fonctions à ajouter dans `app.js` :

```js
/**
 * Convertit une date ISO (YYYY-MM-DD) en format d'affichage (JJ/MM/AAAA).
 */
function isoToDisplay(isoDate) {
  const [year, month, day] = isoDate.split("-");
  return `${day}/${month}/${year}`;
}

/**
 * Parse une date saisie par l'utilisateur (formats JJ/MM/AAAA, JJ-MM-AAAA, AAAA-MM-JJ)
 * et retourne la date ISO (YYYY-MM-DD) ou null si invalide.
 */
function parseDisplayDate(displayValue) {
  const trimmed = displayValue.trim();

  // Format ISO : AAAA-MM-JJ
  const isoMatch = trimmed.match(/^(\d{4})[-/](\d{2})[-/](\d{2})$/);
  if (isoMatch) {
    return validateAndFormat(isoMatch[1], isoMatch[2], isoMatch[3]);
  }

  // Format européen : JJ/MM/AAAA ou JJ-MM-AAAA
  const euMatch = trimmed.match(/^(\d{2})[-/](\d{2})[-/](\d{4})$/);
  if (euMatch) {
    return validateAndFormat(euMatch[3], euMatch[2], euMatch[1]);
  }

  return null;
}

/**
 * Vérifie que les composants forment une date calendaire valide
 * et retourne YYYY-MM-DD ou null.
 */
function validateAndFormat(yearStr, monthStr, dayStr) {
  const year = parseInt(yearStr, 10);
  const month = parseInt(monthStr, 10);
  const day = parseInt(dayStr, 10);

  if (month < 1 || month > 12 || day < 1 || day > 31) {
    return null;
  }

  const dateObj = new Date(year, month - 1, day);
  if (
    dateObj.getFullYear() !== year ||
    dateObj.getMonth() !== month - 1 ||
    dateObj.getDate() !== day
  ) {
    return null; // Date inexistante (ex: 31/02/2024)
  }

  const iso = `${yearStr.padStart(4, "0")}-${monthStr.padStart(2, "0")}-${dayStr.padStart(2, "0")}`;
  return iso;
}
```

### 3.13) Création du contrôleur de saisie date

Une factory function `createDateInputController` encapsule la logique du masque, du parsing et de la synchronisation calendrier :

```js
function createDateInputController({
  textInput,
  hiddenInput,
  calendarButton,
  onDateChange,
}) {
  // --- Masque de saisie ---
  textInput.addEventListener("input", () => {
    const raw = textInput.value;

    // Détection d'un collage ISO
    const isoMatch = raw.match(/^(\d{4})[-/](\d{2})[-/](\d{2})$/);
    if (isoMatch) {
      textInput.value = `${isoMatch[3]}/${isoMatch[2]}/${isoMatch[1]}`;
      syncTextToHidden();
      return;
    }

    // Détection d'un collage EU avec tirets
    const euDashMatch = raw.match(/^(\d{2})-(\d{2})-(\d{4})$/);
    if (euDashMatch) {
      textInput.value = `${euDashMatch[1]}/${euDashMatch[2]}/${euDashMatch[3]}`;
      syncTextToHidden();
      return;
    }

    // Masque : ne garder que les chiffres
    const digits = raw.replace(/\D/g, "").slice(0, 8);
    let formatted = "";
    if (digits.length <= 2) {
      formatted = digits;
    } else if (digits.length <= 4) {
      formatted = digits.slice(0, 2) + "/" + digits.slice(2);
    } else {
      formatted =
        digits.slice(0, 2) + "/" + digits.slice(2, 4) + "/" + digits.slice(4);
    }
    textInput.value = formatted;

    if (formatted.length === 10) {
      syncTextToHidden();
    } else {
      hiddenInput.value = "";
      onDateChange();
    }
  });

  textInput.addEventListener("blur", () => {
    if (textInput.value.length > 0 && textInput.value.length < 10) {
      // Date incomplète au blur → message
      onDateChange();
    }
  });

  // --- Synchronisation texte → caché ---
  function syncTextToHidden() {
    const iso = parseDisplayDate(textInput.value);
    if (iso) {
      hiddenInput.value = iso;
    } else {
      hiddenInput.value = "";
    }
    onDateChange();
  }

  // --- Synchronisation caché → texte (pour preset, URL, shift) ---
  function syncHiddenToText() {
    if (hiddenInput.value) {
      textInput.value = isoToDisplay(hiddenInput.value);
    } else {
      textInput.value = "";
    }
  }

  // --- Bouton calendrier ---
  calendarButton.addEventListener("click", () => {
    const parsed = parseDisplayDate(textInput.value);
    if (parsed) {
      hiddenInput.value = parsed;
    }
    if (typeof hiddenInput.showPicker === "function") {
      hiddenInput.showPicker();
    } else {
      hiddenInput.focus();
      hiddenInput.click();
    }
  });

  // --- Sélection depuis le calendrier natif → texte ---
  hiddenInput.addEventListener("change", () => {
    syncHiddenToText();
    onDateChange();
  });

  // --- Activation/désactivation ---
  function setDisabled(disabled) {
    textInput.disabled = disabled;
    calendarButton.disabled = disabled;
    hiddenInput.disabled = disabled;
  }

  return {
    syncHiddenToText,
    syncTextToHidden,
    setDisabled,
  };
}
```

### 3.14) Intégration avec le code existant

**Points d'intégration** (modifications minimales du code existant) :

| Code existant                                 | Modification                                                                                                                     |
| --------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
| `dateStart.disabled = false`                  | Remplacé par `dateStartController.setDisabled(false)`                                                                            |
| `dateEnd.disabled = false`                    | Remplacé par `dateEndController.setDisabled(false)`                                                                              |
| `applyPresetPeriod(days)`                     | Après l'écriture dans `dateStart.value` / `dateEnd.value`, appeler `dateStartController.syncHiddenToText()` et idem              |
| `shiftPeriod(direction)`                      | Idem — sync après écriture dans les hidden inputs                                                                                |
| `restoreFromURL()`                            | Idem — sync après écriture dans les hidden inputs                                                                                |
| `searchButton.textContent = "Rechercher"`     | Remplacé par `"Voir la météo"`                                                                                                   |
| `searchButton.textContent = "Chargement..."`  | Conservé tel quel                                                                                                                |
| événements `change` sur `dateStart`/`dateEnd` | Les listeners existants sur les input date masqués continuent de fonctionner — le `change` est déclenché par le calendrier natif |

### 3.15) Styles CSS du composant date

```css
/* Fieldset reset */
.date-fieldset {
  border: none;
  margin: 0;
  padding: 0;
}

.date-fieldset legend {
  display: block;
  font-weight: 600;
  margin: 0.4rem 0;
  padding: 0;
}

/* Wrapper du composant date */
.date-input-wrapper {
  display: flex;
  align-items: center;
  gap: 0;
  position: relative;
}

.date-input-wrapper input[type="text"] {
  flex: 1;
  border-top-right-radius: 0;
  border-bottom-right-radius: 0;
  border-right: none;
}

.btn-calendar {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-height: 44px;
  width: 44px;
  padding: 0;
  border: 1px solid var(--border);
  border-left: none;
  border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
  background: var(--bg);
  color: var(--muted);
  cursor: pointer;
  transition:
    color 0.15s,
    background 0.15s;
  flex-shrink: 0;
}

.btn-calendar:hover:not([disabled]) {
  color: var(--accent);
  background: var(--accent-soft);
}

.btn-calendar[disabled] {
  opacity: 0.55;
  cursor: not-allowed;
}

/* Flèche entre les dates */
.date-arrow {
  display: none; /* Masquée sur mobile */
  font-size: 1.2rem;
  color: var(--muted);
  align-self: end;
  padding-bottom: 0.65rem;
}

/* Desktop : date-grid en 3 colonnes avec flèche */
@media (min-width: 768px) {
  .date-grid {
    grid-template-columns: 1fr auto 1fr;
    align-items: end;
  }

  .date-arrow {
    display: block;
  }
}
```

---

## 4) Plan d'implémentation

### Étape 1 — HTML : restructuration du bloc `#search`

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

1. Remplacer le placeholder de `#commune-input` : `Ex: Paris` → `Rechercher une commune...`
2. Envelopper la zone dates dans un `<fieldset class="date-fieldset">` avec `<legend>Période</legend>`.
3. Déplacer la note `date-range-note` à l'intérieur du fieldset.
4. Remplacer chaque `input[type="date"]` par le wrapper décrit en §3.2 (`date-input-wrapper` contenant : `input[type="text"]` + bouton calendrier SVG + `input[type="date"]` masqué).
5. Ajouter l'élément `<span class="date-arrow" aria-hidden="true">→</span>` entre les deux wrappers dans `.date-grid`.
6. Changer le texte du bouton `#search-button` de `Rechercher` à `Voir la météo`.
7. Supprimer les `<label for="date-start">` et `<label for="date-end">` actuels et les remplacer par des labels pointant vers les nouveaux IDs (`date-start-text`, `date-end-text`).

**Vérifiable** : le HTML est valide, les champs texte sont visibles, les inputs date sont masqués. Le bouton affiche `Voir la météo`.

### Étape 2 — JS : composant de saisie date

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

1. Ajouter les fonctions utilitaires : `isoToDisplay()`, `parseDisplayDate()`, `validateAndFormat()`.
2. Ajouter la factory `createDateInputController()` décrite en §3.13.
3. Instancier deux contrôleurs (début et fin) au démarrage :

   ```js
   const dateStartText = document.getElementById("date-start-text");
   const dateEndText = document.getElementById("date-end-text");
   const calendarStartBtn =
     document.querySelector("#date-start-text + .btn-calendar") ||
     dateStartText.parentElement.querySelector(".btn-calendar");
   const calendarEndBtn =
     document.querySelector("#date-end-text + .btn-calendar") ||
     dateEndText.parentElement.querySelector(".btn-calendar");

   const dateStartController = createDateInputController({
     textInput: dateStartText,
     hiddenInput: dateStart,
     calendarButton: calendarStartBtn,
     onDateChange: () => {
       isDateRangeValid();
       updateSearchButtonState();
     },
   });

   const dateEndController = createDateInputController({
     textInput: dateEndText,
     hiddenInput: dateEnd,
     calendarButton: calendarEndBtn,
     onDateChange: () => {
       isDateRangeValid();
       updateSearchButtonState();
     },
   });
   ```

4. Modifier tous les endroits qui écrivent dans `dateStart.value` / `dateEnd.value` pour appeler `syncHiddenToText()` ensuite.
5. Modifier le code d'activation/désactivation pour utiliser `setDisabled()`.

**Vérifiable** : la saisie `29121996` produit `29/12/1996`. Le collage de `1996-12-29` produit `29/12/1996`. Le bouton calendrier ouvre le sélecteur natif.

### Étape 3 — JS : mise à jour du texte bouton + R37

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

1. Remplacer `searchButton.textContent = "Rechercher"` par `searchButton.textContent = "Voir la météo"` dans `performSearch()` (bloc `finally`).
2. Ajouter `{ passive: true }` au listener `touchstart` existant.

**Vérifiable** : le bouton affiche `Voir la météo` après chaque recherche. Le `touchstart` est passif.

### Étape 4 — CSS : styles composant date + R35 + R36

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

1. Ajouter les styles du composant date (`.date-fieldset`, `.date-input-wrapper`, `.btn-calendar`, `.date-arrow`) décrits en §3.15.
2. Ajouter `.btn-calendar` au sélecteur de la règle partagée tactile (L161–168).
3. **R35** : supprimer les propriétés `min-height`, `display`, `align-items`, `justify-content` dupliquées dans les blocs `.btn-secondary`, `.btn-cancel`, `.btn-period-link`, `.results-nav a`. La règle partagée les couvre.
4. **R36** : dans `@media (min-width: 640px)`, remplacer `.results-nav a { min-height: 0; }` par `min-height: unset;`.
5. Ajuster `.date-grid` pour supporter la grille 3 colonnes (1fr auto 1fr) sur desktop avec la flèche.

**Vérifiable** : les champs date ont le style attendu. La flèche `→` est visible sur desktop, masquée sur mobile. Pas de régression visuelle.

### Étape 5 — Validation finale

1. Vérifier visuellement à 360px (Chrome DevTools → iPhone SE) : champs empilés, pas de débordement, bouton pleine largeur.
2. Vérifier visuellement à 1024px : grille de dates côte à côte avec flèche, bouton `Voir la météo` largeur auto.
3. Tester la saisie manuelle : `29121996` → `29/12/1996`, `1996-12-29` → `29/12/1996`, date invalide `32/13/2024` → message d'erreur.
4. Tester le calendrier : clic sur l'icône → calendrier natif s'ouvre → sélection → le champ texte se met à jour.
5. Tester les presets : clic sur `7 jours` → les deux champs texte affichent les dates formatées.
6. Tester la navigation de période (`← →`) : les champs texte se mettent à jour.
7. Tester la restauration depuis URL : les champs texte affichent les dates formatées.
8. Exécuter `pytest tests/ -v` — 61/61 tests doivent passer.
9. Grep `innerHTML` dans `app.js` → 0 occurrence (INV-7 préservé).
10. Grep `window.innerWidth|matchMedia|userAgent` dans `app.js` → 0 occurrence (INV-12 préservé).

---

## 5) Guide pour le Développeur

### Pièges fréquents

1. **`showPicker()` n'est pas universellement supporté** — Safari < 16 ne le supporte pas. Le fallback `focus()` + `click()` ne garantit pas l'ouverture du calendrier, mais ne cause pas d'erreur. La saisie texte reste toujours disponible. Protéger l'appel avec `typeof hiddenInput.showPicker === "function"`.

2. **`inputmode="numeric"` sur le champ texte date** — affiche le pavé numérique sur mobile, ce qui est le comportement souhaité. Ne PAS utiliser `type="number"` (incompatible avec le masque `/`).

3. **Curseur déplacé par le masque** — après chaque reformatage du champ, le curseur du navigateur se retrouve en fin de champ. C'est le comportement par défaut et le plus prévisible. Ne pas essayer de repositionner le curseur manuellement (source de bugs cross-browser).

4. **`dateStart.value` / `dateEnd.value` = source de vérité interne** — tout le reste du code lit ces propriétés. Le champ texte visible est un affichage. S'assurer que chaque modification du champ texte synchronise vers l'input masqué, et vice versa.

5. **Presets et `shiftPeriod` écrivent dans les inputs masqués** — ils ne connaissent pas le champ texte. Toujours appeler `syncHiddenToText()` après toute écriture programmatique dans `dateStart.value` ou `dateEnd.value`.

6. **Le `change` event de l'input date masqué** — il ne se déclenche que quand l'utilisateur interagit avec le calendrier natif. Il ne se déclenche PAS quand la valeur est modifiée par JS (`.value = ...`). D'où la nécessité d'appeler `syncHiddenToText()` explicitement.

### Zones de dérive

- **Ne pas créer un calendrier en JS** — le calendrier natif via `input[type="date"]` masqué est la solution retenue. Pas de date-picker custom.
- **Ne pas ajouter de dépendance** (Flatpickr, date-fns, moment, etc.) — tout est en vanilla JS.
- **Ne pas modifier le format interne** — il reste `YYYY-MM-DD`. L'affichage `JJ/MM/AAAA` est uniquement visuel.
- **Ne pas casser le flux de validation existant** — `isDateRangeValid()` lit `dateStart.value` et `dateEnd.value` (les inputs masqués). Cette fonction ne doit PAS être modifiée.
- **Ne pas ajouter de JS conditionnel responsive** — INV-12 reste en vigueur.
- **Ne pas supprimer le SVG de l'icône calendrier et le remplacer par un emoji** — le SVG inline est plus fiable cross-browser et plus accessible.

### Simplifications autorisées

- Si `showPicker()` pose problème sur un navigateur spécifique, le fallback est silencieux — l'utilisateur utilise le champ texte.
- Le repositionnement du curseur dans le masque peut être omis (fin de champ par défaut).
- Si le `<fieldset>` natif pose des problèmes de style sur certains navigateurs, utiliser un `<div role="group" aria-labelledby="...">` à la place.

### Décisions explicitement interdites

- **Interdit** : utiliser `innerHTML` pour construire le DOM du composant date.
- **Interdit** : utiliser `!important` dans les nouvelles règles CSS.
- **Interdit** : modifier `isDateRangeValid()`, `updateSearchButtonState()`, ou `performSearch()` dans leur logique de validation. Seul le texte du bouton change.
- **Interdit** : supprimer ou modifier les attributs `min` / `max` des inputs date masqués — ils servent au calendrier natif.
- **Interdit** : supprimer les classes `.anomaly-warm`, `.anomaly-cold`, `.anomaly-wet`, `.anomaly-dry`, `.comparison-winner`.

---

## 6) Stratégie de tests

### Tests automatisés existants

Les 61 tests backend (`pytest tests/ -v`) doivent passer à l'identique. Aucun test n'est ajouté, modifié ou supprimé — il n'y a pas de changement backend.

### Tests manuels requis

| #     | Scénario                                                    | Device simulé  | Résultat attendu                                                                                                              |
| ----- | ----------------------------------------------------------- | -------------- | ----------------------------------------------------------------------------------------------------------------------------- |
| TM-11 | Saisie rapide `29121996` dans le champ date début           | 360px          | Le champ affiche `29/12/1996`. La valeur interne est `1996-12-29`.                                                            |
| TM-12 | Collage `1996-12-29` dans le champ date début               | 1024px         | Le champ affiche `29/12/1996`. La valeur interne est `1996-12-29`.                                                            |
| TM-13 | Collage `29-12-1996` dans le champ date début               | 1024px         | Le champ affiche `29/12/1996`. La valeur interne est `1996-12-29`.                                                            |
| TM-14 | Saisie progressive `2` → `29` → `291` → `2912` → `29121996` | 360px          | Le masque formate en temps réel : `2` → `29` → `29/1` → `29/12` → `29/12/1996`.                                               |
| TM-15 | Saisie d'une date invalide `32/13/2024`                     | 1024px         | Message d'erreur : `Date invalide. Utilisez le format JJ/MM/AAAA.` Le bouton recherche reste désactivé.                       |
| TM-16 | Clic sur l'icône calendrier                                 | 360px          | Le sélecteur de date natif du navigateur s'ouvre. Si une date est déjà saisie, le calendrier s'ouvre sur la bonne année.      |
| TM-17 | Sélection d'une date via le calendrier natif                | 1024px         | Le champ texte affiche la date sélectionnée en format `JJ/MM/AAAA`. La validation s'enchaîne.                                 |
| TM-18 | Clic sur un preset (`7 jours`) après sélection commune      | 360px          | Les deux champs texte affichent les dates correctes au format `JJ/MM/AAAA`.                                                   |
| TM-19 | Navigation de période (← →)                                 | 1024px         | Les champs texte se mettent à jour avec les nouvelles dates au format `JJ/MM/AAAA`.                                           |
| TM-20 | Restauration depuis une URL avec dates                      | 1024px         | Les champs texte affichent les dates de l'URL au format `JJ/MM/AAAA`.                                                         |
| TM-21 | Flèche `→` entre les dates sur desktop vs mobile            | 360px + 1024px | La flèche est visible sur desktop (≥ 768px), masquée sur mobile. Les champs sont empilés sur mobile, côte à côte sur desktop. |
| TM-22 | Bouton principal affiche `Voir la météo`                    | 360px          | Le bouton affiche `Voir la météo` au repos, `Chargement...` pendant la requête, puis `Voir la météo` à nouveau.               |
| TM-23 | Placeholder du champ commune                                | 360px          | Le placeholder affiche `Rechercher une commune...`                                                                            |
| TM-24 | Mode comparaison complet                                    | 360px          | Le formulaire comparaison fonctionne. Les champs date texte sont partagés. Aucune régression.                                 |

### Edge cases critiques

- **Saisie de `00/00/0000`** — doit être rejetée (date invalide).
- **Saisie de `29/02/2023`** (année non bissextile) — doit être rejetée (date invalide).
- **Saisie de `29/02/2024`** (année bissextile) — doit être acceptée.
- **Saisie de `01/01/1939`** (avant la limite) — la validation existante affiche `La date de début ne peut pas être antérieure au 1er janvier 1940`.
- **Saisie d'une date future** — la validation existante affiche `Seules les dates passées sont disponibles`.
- **Champ texte vidé manuellement** — le bouton recherche se désactive (date obligatoire).
- **`showPicker()` indisponible** — le clic sur l'icône calendrier ne génère pas d'erreur, la saisie texte reste fonctionnelle.

---

## 7) Risques techniques

| #   | Risque                                                                        | Probabilité | Impact | Mitigation                                                                                                                                                   |
| --- | ----------------------------------------------------------------------------- | ----------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| 1   | `showPicker()` non supporté sur des navigateurs encore utilisés (Safari < 16) | Faible      | Moyen  | Fallback `focus()` + `click()`. La saisie texte est toujours disponible comme canal principal.                                                               |
| 2   | Le masque de saisie perturbe les utilisateurs habitués au date-picker natif   | Moyenne     | Faible | Le bouton calendrier reste disponible. Les deux méthodes d'entrée coexistent.                                                                                |
| 3   | Le collage de texte non-date (ex: « bonjour ») dans le champ date             | Faible      | Faible | Le masque supprime tous les caractères non-numériques. Le résultat est une chaîne vide ou un fragment de chiffres — pas de crash, le bouton reste désactivé. |
