# Bargraph de remplissage de subnet — Plan d'implémentation

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Ajouter dans `subnet.php` une barre horizontale unique (inspirée de la vue Patch d'Avolites Titan) qui visualise le remplissage du subnet — blocs occupés colorés par type, trous calculés, infobulle au survol, clic-filtre / clic-ajout.

**Architecture:** Composant 100 % côté client en 3 couches pures (métier → layout → rendu DOM) dans un module autonome `subnet-bargraph.js`. `subnet.php` injecte un objet `SUBNET` (bornes entières + pools) à côté de `ROWS` déjà présent, monte le composant dans le `card-header`, et enrichit son filtre existant pour comprendre une syntaxe de range IP. Aucune table, migration SQL ou endpoint. Offline (aucun CDN), thème clair/sombre via `@media (prefers-color-scheme: dark)`.

**Tech Stack:** PHP vanilla + PDO (existant), JavaScript ES5 (aligné sur le style de `subnet.php`, IIFE, `var`), Bootstrap 5 (déjà servi en local), CSS custom properties.

---

## Structure des fichiers

| Fichier | Responsabilité |
|---------|----------------|
| `public/assets/js/subnet-bargraph.js` | **nouveau** — module complet : couche métier (`computeBlocks`/`computeHoles`/`largestAlignedCidr`/`maskForCount`), couche layout (`layoutSegments`), couche rendu (`renderBar` + survol/clic + infobulle). Expose `window.SubnetBargraph`. |
| `public/assets/js/subnet-bargraph.test.html` | **nouveau** — page de test autonome, charge le module, assertions sur la couche métier + layout, affichage vert/rouge. Offline. |
| `public/assets/js/subnet-bargraph-demo.html` | **nouveau** — démo statique /23 en dur (fixtures partagées avec le test). |
| `public/subnet.php` | **modifié** — injecte `SUBNET`, ajoute le point de montage dans le header, charge le script, enrichit `matches()` pour la syntaxe de range. |
| `public/assets/css/app.css` | **modifié** — styles `.sbg-*` (barre, segments, infobulle, texte de taux), thème clair/sombre. |

**Convention API du module (verrouillée ici, réutilisée partout) :**

```js
window.SubnetBargraph = {
  // Couche métier (pures) — exposées pour les tests
  computeBlocks: function (rows, subnet) { /* → [block] */ },
  computeHoles:  function (blocks, subnet) { /* → [hole] */ },
  largestAlignedCidr: function (startInt, endInt) { /* → "a.b.c.d/NN" */ },
  maskForCount:  function (startInt, endInt) { /* → "255.255.255.240" */ },
  layoutSegments: function (blocks, holes, subnet, opts) { /* → [seg] */ },
  intToIp: function (n) { /* → "a.b.c.d" */ },
  // Point d'entrée (rendu)
  init: function (cfg) { /* cfg = {mount, rows, subnet, onFilter, canEdit} */ }
};
```

**Types (verrouillés) :**

```js
// block
{ type: "reservation"|"dynamic"|"short-pool",
  startInt, endInt, count,
  groups: { "<nom>": <n>, "": <n> },  // réservations uniquement, sinon {}
  shortLeaseCount: <n>,               // réservations short-lease dans le bloc
  hostIds: [<int>, ...] }
// hole
{ startInt, endInt, count, largestCidr: "a.b.c.d/NN" }
// segment (sortie layout)
{ kind: "block"|"hole", ref: <block|hole>, leftPct: <float>, widthPct: <float> }
// subnet (entrée, = objet SUBNET injecté)
{ cidr, networkInt, broadcastInt, firstUsable, lastUsable, total,
  dynStartInt, dynEndInt, shortStartInt, shortEndInt, addUrl }
```

**Comment lancer les tests :** ouvrir `public/assets/js/subnet-bargraph.test.html` dans un navigateur. Chaque assertion s'affiche en ligne (vert = PASS, rouge = FAIL) + un compteur global « N/N PASS ». Aucune commande CLI ; c'est un harnais navigateur volontaire (projet sans framework JS, contrainte offline).

---

## Task 1: Squelette du module + helpers IP + harnais de test

**Files:**
- Create: `public/assets/js/subnet-bargraph.js`
- Test: `public/assets/js/subnet-bargraph.test.html`

- [ ] **Step 1: Écrire le harnais de test avec les 1ers cas (helpers IP)**

Créer `public/assets/js/subnet-bargraph.test.html` :

```html
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="utf-8">
<title>Tests — subnet-bargraph</title>
<style>
  body { font-family: system-ui, sans-serif; margin: 20px; background:#f0f2f5; }
  h1 { font-size: 1.2rem; }
  .summary { font-weight: 700; padding: 8px 12px; border-radius: 6px; margin: 10px 0; display:inline-block; }
  .summary.ok { background:#d1e7dd; color:#0f5132; }
  .summary.ko { background:#f8d7da; color:#842029; }
  .case { padding: 3px 8px; border-radius: 4px; margin: 2px 0; font-family: monospace; font-size: 13px; }
  .pass { background:#e7f4ec; color:#0f5132; }
  .fail { background:#fbe3e5; color:#842029; font-weight:700; }
</style>
</head>
<body>
<h1>Tests — subnet-bargraph.js</h1>
<div id="summary"></div>
<div id="out"></div>
<script src="subnet-bargraph.js"></script>
<script>
(function () {
  var out = document.getElementById('out');
  var pass = 0, fail = 0;
  function eq(a, b) { return JSON.stringify(a) === JSON.stringify(b); }
  function test(name, got, want) {
    var ok = eq(got, want);
    if (ok) pass++; else fail++;
    var d = document.createElement('div');
    d.className = 'case ' + (ok ? 'pass' : 'fail');
    d.textContent = (ok ? '✓ ' : '✗ ') + name +
      (ok ? '' : '  → got ' + JSON.stringify(got) + '  want ' + JSON.stringify(want));
    out.appendChild(d);
  }
  var S = window.SubnetBargraph;

  // ── Helpers IP ──
  test('intToIp 3232236033', S.intToIp(3232236033), '192.168.2.1');
  test('intToIp 0', S.intToIp(0), '0.0.0.0');

  // (les blocs de tests suivants sont ajoutés dans les tâches ultérieures)

  var sum = document.getElementById('summary');
  sum.className = 'summary ' + (fail === 0 ? 'ok' : 'ko');
  sum.textContent = pass + '/' + (pass + fail) + ' PASS' + (fail ? '  — ' + fail + ' FAIL' : '');
  window.__TEST_RESULT__ = { pass: pass, fail: fail };
}());
</script>
</body>
</html>
```

- [ ] **Step 2: Ouvrir le test et vérifier qu'il échoue**

Ouvrir `public/assets/js/subnet-bargraph.test.html` au navigateur.
Attendu : ERREUR JS (`window.SubnetBargraph` indéfini) → le résumé n'affiche rien / la console montre `Cannot read properties of undefined`. C'est le "rouge" attendu avant implémentation.

- [ ] **Step 3: Créer le module avec les helpers IP**

Créer `public/assets/js/subnet-bargraph.js` :

```js
/*!
 * subnet-bargraph.js — barre de remplissage d'un subnet (inspiré Avolites Titan).
 * 100 % client, 3 couches pures : métier → layout → rendu.
 * ES5 / IIFE, aligné sur le style de subnet.php. Aucune dépendance.
 */
window.SubnetBargraph = (function () {
    'use strict';

    // ── Helpers IP (entier non signé ↔ pointé) ──
    function intToIp(n) {
        n = n >>> 0;
        return [(n >>> 24) & 255, (n >>> 16) & 255, (n >>> 8) & 255, n & 255].join('.');
    }

    return {
        intToIp: intToIp
    };
}());
```

- [ ] **Step 4: Recharger le test et vérifier le vert**

Recharger `subnet-bargraph.test.html`.
Attendu : résumé vert « 2/2 PASS ».

- [ ] **Step 5: Commit**

```bash
git add public/assets/js/subnet-bargraph.js public/assets/js/subnet-bargraph.test.html
git commit -m "feat(bargraph): squelette module + helper intToIp + harnais de test"
```

---

## Task 2: `maskForCount` et `largestAlignedCidr`

**Files:**
- Modify: `public/assets/js/subnet-bargraph.js`
- Test: `public/assets/js/subnet-bargraph.test.html`

- [ ] **Step 1: Ajouter les cas de test**

Dans `subnet-bargraph.test.html`, sous le commentaire `// (les blocs de tests suivants…)`, insérer :

```js
  // ── maskForCount : masque du plus petit bloc alignable contenant [start,end] ──
  test('maskForCount /28 (16)', S.maskForCount(3232236048, 3232236063), '255.255.255.240');
  test('maskForCount /32 (1)', S.maskForCount(3232236042, 3232236042), '255.255.255.255');
  test('maskForCount /30 (4)', S.maskForCount(3232236040, 3232236043), '255.255.255.252');

  // ── largestAlignedCidr : plus grand CIDR aligné TENANT dans [start,end] ──
  // trou 192.168.2.16 → .31 (16 adresses, aligné) => /28
  test('largestAlignedCidr .16-.31', S.largestAlignedCidr(3232236048, 3232236063), '192.168.2.16/28');
  // trou 192.168.2.10 → .14 (5 adresses, non aligné) => plus grand bloc = /30 à .12 ? non : doit tenir DANS le trou et être aligné.
  //   .12/30 (.12-.15) déborde (.15>.14). .10/31? .10-.11 ok mais /31 pas le plus grand. .12/31 (.12-.13) ok.
  //   Plus grand aligné contenu : parmi tailles 2^k, la plus grande dont un multiple aligné ∈ [10,14] avec fin ≤14.
  //   taille 4: bases alignées 8(→8-11, <10) ,12(→12-15,>14) → aucune. taille 2: base 10(10-11),12(12-13) ok → /31.
  test('largestAlignedCidr .10-.14', S.largestAlignedCidr(3232236042, 3232236046), '192.168.2.10/31');
  // trou d'une seule adresse => /32
  test('largestAlignedCidr .42-.42', S.largestAlignedCidr(3232236074, 3232236074), '192.168.2.42/32');
```

- [ ] **Step 2: Ouvrir le test, vérifier l'échec**

Recharger `subnet-bargraph.test.html`.
Attendu : les 6 nouveaux cas en rouge (`S.maskForCount is not a function` → capté comme FAIL car l'appel throw ; si l'exception casse la page, c'est aussi un échec visible). Le résumé n'est pas vert.

- [ ] **Step 3: Implémenter les deux fonctions**

Dans `subnet-bargraph.js`, ajouter avant le `return` :

```js
    // Nombre de bits d'hôte pour couvrir `count` adresses (arrondi sup. puissance de 2).
    function hostBitsFor(count) {
        var bits = 0, size = 1;
        while (size < count) { size *= 2; bits++; }
        return bits;
    }
    // Masque (pointé) du plus petit bloc CIDR contenant [startInt, endInt].
    function maskForCount(startInt, endInt) {
        var bits = hostBitsFor(endInt - startInt + 1);
        var mask = bits >= 32 ? 0 : (0xFFFFFFFF << bits) >>> 0;
        return intToIp(mask);
    }
    // Plus grand CIDR aligné entièrement contenu dans [startInt, endInt].
    // On teste les tailles décroissantes ; pour chaque taille, la 1re base alignée ≥ start
    // dont la fin ≤ end convient. Retourne "base/prefix".
    function largestAlignedCidr(startInt, endInt) {
        var span = endInt - startInt + 1;
        var bits = hostBitsFor(span); // borne haute de recherche
        for (; bits >= 0; bits--) {
            var size = Math.pow(2, bits);
            var mask = bits >= 32 ? 0 : (0xFFFFFFFF << bits) >>> 0;
            // 1re base alignée ≥ startInt
            var base = (startInt & mask) >>> 0;
            if (base < startInt) base = (base + size) >>> 0;
            if (base >= startInt && (base + size - 1) <= endInt) {
                return intToIp(base) + '/' + (32 - bits);
            }
        }
        // fallback (ne devrait pas arriver) : /32 sur start
        return intToIp(startInt) + '/32';
    }
```

Puis étendre l'objet retourné :

```js
    return {
        intToIp: intToIp,
        maskForCount: maskForCount,
        largestAlignedCidr: largestAlignedCidr
    };
```

- [ ] **Step 4: Recharger, vérifier le vert**

Recharger `subnet-bargraph.test.html`.
Attendu : résumé vert « 8/8 PASS ».

- [ ] **Step 5: Commit**

```bash
git add public/assets/js/subnet-bargraph.js public/assets/js/subnet-bargraph.test.html
git commit -m "feat(bargraph): maskForCount + largestAlignedCidr (couche métier)"
```

---

## Task 3: `computeBlocks` — fusion des réservations et injection des pools

**Files:**
- Modify: `public/assets/js/subnet-bargraph.js`
- Test: `public/assets/js/subnet-bargraph.test.html`

- [ ] **Step 1: Ajouter les cas de test (fixtures /23)**

Dans `subnet-bargraph.test.html`, ajouter. Le /23 de base : `192.168.2.0/23`,
`networkInt = 3232236032`, `firstUsable = 3232236033`, `lastUsable = 3232237566`,
`total = 1534`.

```js
  // ── Fixtures /23 (partagées avec la démo) ──
  var SUBNET23 = {
    cidr: '192.168.2.0/23',
    networkInt: 3232236032, broadcastInt: 3232237567,
    firstUsable: 3232236033, lastUsable: 3232237566, total: 1534,
    dynStartInt: 3232236232, dynEndInt: 3232236331,   // .200 - .... (dyn)
    shortStartInt: 3232236482, shortEndInt: 3232236511, // short pool
    addUrl: 'reservation_add.php?subnet_id=99'
  };
  function ip(n) { return n; } // lisibilité
  // .10,.11,.12 groupe serveurs (contigus) ; .13 groupe reseau (contigu, fusionne : même type) ;
  // .50 isolé sans groupe ; .51 short-lease avec IP fixe (bleu, fusionne avec .50)
  var ROWS23 = [
    { host_id:1, hostname:'srv1', ip_int:3232236042, group_name:'serveurs', short_lease:0 }, // .10
    { host_id:2, hostname:'srv2', ip_int:3232236043, group_name:'serveurs', short_lease:0 }, // .11
    { host_id:3, hostname:'srv3', ip_int:3232236044, group_name:'serveurs', short_lease:0 }, // .12
    { host_id:4, hostname:'sw1',  ip_int:3232236045, group_name:'reseau',   short_lease:0 }, // .13
    { host_id:5, hostname:'iso',  ip_int:3232236082, group_name:'',         short_lease:0 }, // .50
    { host_id:6, hostname:'tmp',  ip_int:3232236083, group_name:'',         short_lease:1 }, // .51 short-lease
    { host_id:7, hostname:'dyn',  ip_int:0,          group_name:'',         short_lease:0 }  // sans IP → ignorée
  ];

  var blocks = S.computeBlocks(ROWS23, SUBNET23);
  // Attendu, trié par startInt :
  //  bloc1 réservation .10-.13 (4), groups {serveurs:3, reseau:1}, shortLeaseCount 0, hostIds [1,2,3,4]
  //  bloc2 réservation .50-.51 (2), groups {"":2}, shortLeaseCount 1, hostIds [5,6]
  //  bloc3 dynamic  (pool)   .200 range
  //  bloc4 short-pool (pool)
  test('computeBlocks count', blocks.length, 4);
  test('computeBlocks b1 type', blocks[0].type, 'reservation');
  test('computeBlocks b1 range', [blocks[0].startInt, blocks[0].endInt], [3232236042, 3232236045]);
  test('computeBlocks b1 count', blocks[0].count, 4);
  test('computeBlocks b1 groups', blocks[0].groups, { serveurs:3, reseau:1 });
  test('computeBlocks b1 hostIds', blocks[0].hostIds, [1,2,3,4]);
  test('computeBlocks b2 range', [blocks[1].startInt, blocks[1].endInt], [3232236082, 3232236083]);
  test('computeBlocks b2 shortLease', blocks[1].shortLeaseCount, 1);
  test('computeBlocks b2 groups', blocks[1].groups, { '':2 });
  test('computeBlocks dyn present', blocks[2].type, 'dynamic');
  test('computeBlocks dyn range', [blocks[2].startInt, blocks[2].endInt], [3232236232, 3232236331]);
  test('computeBlocks short present', blocks[3].type, 'short-pool');
```

- [ ] **Step 2: Ouvrir le test, vérifier l'échec**

Recharger. Attendu : les nouveaux cas rouges (`S.computeBlocks is not a function`).

- [ ] **Step 3: Implémenter `computeBlocks`**

Dans `subnet-bargraph.js`, ajouter avant le `return` :

```js
    // Fusionne les réservations contiguës de MÊME type (toutes "reservation" ici),
    // puis injecte les pools comme blocs propres. Retourne la liste triée par startInt.
    function computeBlocks(rows, subnet) {
        var resv = [];
        for (var i = 0; i < rows.length; i++) {
            var r = rows[i];
            var ipInt = r.ip_int >>> 0;
            if (!ipInt) continue; // sans IP fixe → vit dans le pool
            if (ipInt < subnet.firstUsable || ipInt > subnet.lastUsable) {
                if (window.console) console.warn('bargraph: réservation hors CIDR ignorée', r);
                continue;
            }
            resv.push(r);
        }
        resv.sort(function (a, b) { return (a.ip_int >>> 0) - (b.ip_int >>> 0); });

        var blocks = [];
        var cur = null;
        for (var j = 0; j < resv.length; j++) {
            var row = resv[j];
            var ip = row.ip_int >>> 0;
            if (cur && ip === cur.endInt + 1) {
                cur.endInt = ip;
                cur.count++;
                cur.hostIds.push(row.host_id);
                accGroup(cur.groups, row.group_name);
                if (row.short_lease) cur.shortLeaseCount++;
            } else {
                cur = {
                    type: 'reservation',
                    startInt: ip, endInt: ip, count: 1,
                    groups: {}, shortLeaseCount: row.short_lease ? 1 : 0,
                    hostIds: [row.host_id]
                };
                accGroup(cur.groups, row.group_name);
                blocks.push(cur);
            }
        }

        // Pools
        if (subnet.dynStartInt != null && subnet.dynEndInt != null) {
            blocks.push(poolBlock('dynamic', subnet.dynStartInt, subnet.dynEndInt));
        }
        if (subnet.shortStartInt != null && subnet.shortEndInt != null) {
            blocks.push(poolBlock('short-pool', subnet.shortStartInt, subnet.shortEndInt));
        }

        blocks.sort(function (a, b) { return a.startInt - b.startInt; });
        return blocks;
    }
    function accGroup(groups, name) {
        var k = name || '';
        groups[k] = (groups[k] || 0) + 1;
    }
    function poolBlock(type, startInt, endInt) {
        return {
            type: type, startInt: startInt >>> 0, endInt: endInt >>> 0,
            count: (endInt >>> 0) - (startInt >>> 0) + 1,
            groups: {}, shortLeaseCount: 0, hostIds: []
        };
    }
```

Étendre l'objet retourné : ajouter `computeBlocks: computeBlocks,`.

- [ ] **Step 4: Recharger, vérifier le vert**

Recharger. Attendu : résumé vert « 20/20 PASS ».

- [ ] **Step 5: Commit**

```bash
git add public/assets/js/subnet-bargraph.js public/assets/js/subnet-bargraph.test.html
git commit -m "feat(bargraph): computeBlocks (fusion par type + injection pools)"
```

---

## Task 4: `computeHoles` — trous dérivés

**Files:**
- Modify: `public/assets/js/subnet-bargraph.js`
- Test: `public/assets/js/subnet-bargraph.test.html`

- [ ] **Step 1: Ajouter les cas de test**

Dans `subnet-bargraph.test.html`, après les tests `computeBlocks` :

```js
  var holes = S.computeHoles(blocks, SUBNET23);
  // Séquence occupée : .10-.13, .50-.51, dyn .200-.., short .. Le reste = trous.
  //  trou avant .10 : firstUsable(.1) → .9
  //  trou .14 → .49
  //  etc. On vérifie le 1er trou et qu'aucun trou ne chevauche un bloc.
  test('computeHoles first hole start', holes[0].startInt, SUBNET23.firstUsable);
  test('computeHoles first hole end', holes[0].endInt, 3232236041); // .9
  test('computeHoles first hole cidr', holes[0].largestCidr, S.largestAlignedCidr(SUBNET23.firstUsable, 3232236041));
  // aucun trou ne chevauche un bloc (invariant)
  var overlap = false;
  holes.forEach(function (h) {
    blocks.forEach(function (b) {
      if (h.startInt <= b.endInt && b.startInt <= h.endInt) overlap = true;
    });
  });
  test('computeHoles no overlap with blocks', overlap, false);
  // couverture totale : somme(counts blocs + trous) = total
  var cover = 0;
  blocks.forEach(function (b){ cover += b.count; });
  holes.forEach(function (h){ cover += h.count; });
  test('computeHoles full coverage', cover, SUBNET23.total);
```

- [ ] **Step 2: Ouvrir le test, vérifier l'échec**

Recharger. Attendu : nouveaux cas rouges (`S.computeHoles is not a function`).

- [ ] **Step 3: Implémenter `computeHoles`**

Dans `subnet-bargraph.js`, ajouter avant le `return` :

```js
    // Trous = intervalles [firstUsable, lastUsable] non couverts par les blocs (déjà triés).
    function computeHoles(blocks, subnet) {
        var holes = [];
        var cursor = subnet.firstUsable;
        for (var i = 0; i < blocks.length; i++) {
            var b = blocks[i];
            if (b.startInt > cursor) {
                holes.push(makeHole(cursor, b.startInt - 1));
            }
            if (b.endInt + 1 > cursor) cursor = b.endInt + 1;
        }
        if (cursor <= subnet.lastUsable) {
            holes.push(makeHole(cursor, subnet.lastUsable));
        }
        return holes;
    }
    function makeHole(startInt, endInt) {
        return {
            startInt: startInt, endInt: endInt,
            count: endInt - startInt + 1,
            largestCidr: largestAlignedCidr(startInt, endInt)
        };
    }
```

Étendre l'objet retourné : ajouter `computeHoles: computeHoles,`.

- [ ] **Step 4: Recharger, vérifier le vert**

Recharger. Attendu : résumé vert « 24/24 PASS ».

- [ ] **Step 5: Commit**

```bash
git add public/assets/js/subnet-bargraph.js public/assets/js/subnet-bargraph.test.html
git commit -m "feat(bargraph): computeHoles (trous dérivés + invariant couverture)"
```

---

## Task 5: `layoutSegments` — échelle, plancher 5px, renormalisation globale

**Files:**
- Modify: `public/assets/js/subnet-bargraph.js`
- Test: `public/assets/js/subnet-bargraph.test.html`

- [ ] **Step 1: Ajouter les cas de test**

Dans `subnet-bargraph.test.html` :

```js
  // ── layoutSegments ──
  // Sans plancher (minPx=0) : les % sont strictement proportionnels et somment à 100.
  var segs0 = S.layoutSegments(blocks, holes, SUBNET23, { barWidthPx: 1000, minPx: 0 });
  var sum0 = 0; segs0.forEach(function (s){ sum0 += s.widthPct; });
  test('layout Σ=100 (no floor)', Math.round(sum0 * 100) / 100, 100);
  // ordre gauche→droite : leftPct croissant, et 1er segment leftPct=0
  test('layout first leftPct 0', Math.round(segs0[0].leftPct), 0);
  var mono = true;
  for (var k = 1; k < segs0.length; k++) { if (segs0[k].leftPct < segs0[k-1].leftPct) mono = false; }
  test('layout leftPct monotone', mono, true);

  // Avec plancher : chaque BLOC occupé fait ≥ minPct ; Σ reste 100 ; trous non planchés.
  var segsF = S.layoutSegments(blocks, holes, SUBNET23, { barWidthPx: 400, minPx: 5 });
  var minPct = 5 / 400 * 100; // 1.25%
  var allBlocksAboveFloor = true;
  segsF.forEach(function (s) {
    if (s.kind === 'block' && s.widthPct < minPct - 1e-6) allBlocksAboveFloor = false;
  });
  test('layout blocks ≥ floor', allBlocksAboveFloor, true);
  var sumF = 0; segsF.forEach(function (s){ sumF += s.widthPct; });
  test('layout Σ=100 (floored)', Math.round(sumF * 100) / 100, 100);
```

- [ ] **Step 2: Ouvrir le test, vérifier l'échec**

Recharger. Attendu : nouveaux cas rouges (`S.layoutSegments is not a function`).

- [ ] **Step 3: Implémenter `layoutSegments`**

Dans `subnet-bargraph.js`, ajouter avant le `return` :

```js
    // Projette blocs + trous en segments {kind, ref, leftPct, widthPct}.
    // Plancher minPx sur les BLOCS occupés uniquement, puis renormalisation globale (Σ=100).
    function layoutSegments(blocks, holes, subnet, opts) {
        opts = opts || {};
        var barWidthPx = opts.barWidthPx || 0;
        var minPx = opts.minPx != null ? opts.minPx : 5;
        var minPct = barWidthPx > 0 ? (minPx / barWidthPx * 100) : 0;

        // 1) Séquence ordonnée blocs+trous
        var items = [];
        blocks.forEach(function (b) { items.push({ kind: 'block', ref: b, count: b.count }); });
        holes.forEach(function (h) { items.push({ kind: 'hole', ref: h, count: h.count }); });
        items.sort(function (a, b) { return a.ref.startInt - b.ref.startInt; });

        var total = subnet.total || 1;

        // 2) Largeur idéale
        items.forEach(function (it) { it.widthPct = it.count / total * 100; });

        // 3) Plancher sur les blocs
        if (minPct > 0) {
            items.forEach(function (it) {
                if (it.kind === 'block' && it.widthPct < minPct) {
                    it.floored = true;
                    it.widthPct = minPct;
                }
            });
            // 4) Renormalisation globale : retirer le surplus aux segments non planchés,
            //    proportionnellement, en itérant si un segment repasserait sous son plancher.
            renormalize(items, minPct);
        }

        // 5) Positions cumulées
        var left = 0, out = [];
        items.forEach(function (it) {
            out.push({ kind: it.kind, ref: it.ref, leftPct: left, widthPct: it.widthPct });
            left += it.widthPct;
        });
        return out;
    }
    // Ramène Σ widthPct à 100 en réduisant proportionnellement les segments "réductibles"
    // (au-dessus de leur plancher). Un bloc ne descend jamais sous minPct.
    function renormalize(items, minPct) {
        for (var pass = 0; pass < 20; pass++) {
            var sum = 0; items.forEach(function (it){ sum += it.widthPct; });
            var excess = sum - 100;
            if (Math.abs(excess) < 1e-6) return;
            // masse réductible = somme des (widthPct - plancher) des segments réductibles
            var reducible = 0;
            items.forEach(function (it) {
                var floor = (it.kind === 'block') ? minPct : 0;
                if (it.widthPct > floor) reducible += (it.widthPct - floor);
            });
            if (reducible <= 1e-9) return; // rien à réduire (barre saturée de planchers)
            var ratio = excess / reducible; // part du surplus à retirer par unité réductible
            items.forEach(function (it) {
                var floor = (it.kind === 'block') ? minPct : 0;
                if (it.widthPct > floor) {
                    var room = it.widthPct - floor;
                    it.widthPct -= room * ratio;
                    if (it.widthPct < floor) it.widthPct = floor;
                }
            });
        }
    }
```

Étendre l'objet retourné : ajouter `layoutSegments: layoutSegments,`.

- [ ] **Step 4: Recharger, vérifier le vert**

Recharger. Attendu : résumé vert « 30/30 PASS ».

- [ ] **Step 5: Commit**

```bash
git add public/assets/js/subnet-bargraph.js public/assets/js/subnet-bargraph.test.html
git commit -m "feat(bargraph): layoutSegments (plancher 5px + renormalisation globale)"
```

---

## Task 6: Couche rendu — `init`, DOM de la barre, texte de taux

**Files:**
- Modify: `public/assets/js/subnet-bargraph.js`
- Modify: `public/assets/css/app.css`

Cette couche touche le DOM → non couverte par le harnais d'assertions. Vérification visuelle via la démo (Task 8). Ici on écrit le rendu + le CSS.

- [ ] **Step 1: Ajouter le CSS de la barre**

Dans `public/assets/css/app.css`, à la fin du fichier (avant tout bloc `@media prefers-color-scheme` final s'il existe ; sinon en fin de fichier), ajouter :

```css
/* ── Bargraph de remplissage de subnet ── */
:root {
    --sbg-reservation: #4f7cc4;
    --sbg-dynamic:     #d99a2b;
    --sbg-short:       #a855c0;
    --sbg-hole-a:      #c3ccd6;
    --sbg-hole-b:      #d7dde3;
    --sbg-border:      #cdd5df;
}
.sbg-wrap { margin-top: .5rem; }
.sbg-rate { font-size: .8rem; color: #64748b; margin-bottom: .25rem; }
.sbg-bar {
    position: relative;
    height: 30px;
    border-radius: 6px;
    overflow: hidden;
    border: 1px solid var(--sbg-border);
    background: repeating-linear-gradient(45deg,
        var(--sbg-hole-a), var(--sbg-hole-a) 5px,
        var(--sbg-hole-b) 5px, var(--sbg-hole-b) 10px);
}
.sbg-seg {
    position: absolute; top: 0; bottom: 0;
    box-shadow: inset 0 0 0 1px rgba(255,255,255,.22);
    cursor: default;
}
.sbg-seg.sbg-reservation { background: var(--sbg-reservation); cursor: pointer; }
.sbg-seg.sbg-dynamic     { background: var(--sbg-dynamic); }
.sbg-seg.sbg-short-pool  { background: var(--sbg-short); }
.sbg-seg.sbg-hole        { background: transparent; cursor: pointer; }
.sbg-seg:hover           { filter: brightness(1.08); outline: 2px solid rgba(0,0,0,.25); outline-offset: -2px; z-index: 2; }
/* Infobulle maison */
.sbg-tip {
    position: fixed; z-index: 3000; pointer-events: none;
    max-width: 280px; padding: 8px 11px;
    background: #fff; border: 1px solid var(--sbg-border); border-radius: 8px;
    box-shadow: 0 6px 18px rgba(0,0,0,.14);
    font-size: 12.5px; color: #334; display: none;
}
.sbg-tip.show { display: block; }
.sbg-tip .sbg-tip-title { font-weight: 700; margin-bottom: 3px; }
.sbg-tip .sbg-tip-row { display: flex; justify-content: space-between; gap: 12px; color: #556; }
.sbg-tip .sbg-tip-row b { color: #223; font-weight: 600; }
.sbg-tip .sbg-grp { display: inline-block; background: #eef2f7; border-radius: 20px;
    padding: 1px 8px; margin: 3px 4px 0 0; font-size: 11px; color: #334; }

@media (prefers-color-scheme: dark) {
    :root {
        --sbg-hole-a: #2a2f36;
        --sbg-hole-b: #232830;
        --sbg-border: #3a424d;
    }
    .sbg-rate { color: #94a3b8; }
    .sbg-tip { background: #1c2128; color: #c9d3df; }
    .sbg-tip .sbg-tip-row { color: #9aa7b4; }
    .sbg-tip .sbg-tip-row b { color: #e6edf3; }
    .sbg-tip .sbg-grp { background: #2a2f36; color: #c9d3df; }
    .sbg-seg:hover { outline-color: rgba(255,255,255,.35); }
}
```

- [ ] **Step 2: Implémenter le rendu dans le module**

Dans `subnet-bargraph.js`, ajouter avant le `return`. On implémente `init`, `renderBar`, le texte de taux, et on branche survol/clic (l'infobulle et les clics sont complétés en Task 7 ; ici on met les hooks).

```js
    var TYPE_LABEL = {
        'reservation': 'Réservation',
        'dynamic': 'Pool dynamique',
        'short-pool': 'Pool short-lease'
    };

    // Taux d'occupation (pools comptés comme occupés).
    function occupancy(blocks, subnet) {
        var occ = 0;
        blocks.forEach(function (b) { occ += b.count; });
        var pct = subnet.total ? Math.round(occ / subnet.total * 100) : 0;
        return { occ: occ, total: subnet.total, pct: pct };
    }

    function el(tag, cls) { var e = document.createElement(tag); if (cls) e.className = cls; return e; }

    function init(cfg) {
        var mount = cfg.mount;
        if (!mount) return;
        var rows = cfg.rows || [];
        var subnet = cfg.subnet;
        var onFilter = cfg.onFilter || function () {};
        var canEdit = !!cfg.canEdit;

        var blocks = computeBlocks(rows, subnet);
        var holes = computeHoles(blocks, subnet);

        mount.innerHTML = '';
        var wrap = el('div', 'sbg-wrap');
        var rate = el('div', 'sbg-rate');
        var o = occupancy(blocks, subnet);
        rate.textContent = o.occ + ' / ' + o.total + ' occupées · ' + o.pct + ' %';
        var bar = el('div', 'sbg-bar');
        wrap.appendChild(rate);
        wrap.appendChild(bar);
        mount.appendChild(wrap);

        // Infobulle unique réutilisée
        var tip = el('div', 'sbg-tip');
        document.body.appendChild(tip);

        function draw() {
            var barWidthPx = bar.clientWidth;
            if (barWidthPx <= 0) { requestAnimationFrame(draw); return; }
            bar.innerHTML = '';
            var segs = layoutSegments(blocks, holes, subnet, { barWidthPx: barWidthPx, minPx: 5 });
            segs.forEach(function (s) {
                var seg = el('div', 'sbg-seg sbg-' + (s.kind === 'hole' ? 'hole' : s.ref.type));
                seg.style.left = s.leftPct + '%';
                seg.style.width = s.widthPct + '%';
                bindSegment(seg, s, tip, subnet, onFilter, canEdit);
                bar.appendChild(seg);
            });
        }
        draw();

        var t;
        window.addEventListener('resize', function () {
            clearTimeout(t); t = setTimeout(draw, 120);
        });
    }
```

Étendre l'objet retourné :

```js
    return {
        intToIp: intToIp,
        maskForCount: maskForCount,
        largestAlignedCidr: largestAlignedCidr,
        computeBlocks: computeBlocks,
        computeHoles: computeHoles,
        layoutSegments: layoutSegments,
        init: init
    };
```

> Note : `bindSegment` est défini en Task 7. Pour compiler dès maintenant sans erreur, ajouter un stub temporaire juste avant `init` :
> ```js
> function bindSegment() { /* complété en Task 7 */ }
> ```
> Il sera remplacé à la Task 7, Step 2.

- [ ] **Step 3: Vérifier l'absence d'erreur de syntaxe**

Recharger `subnet-bargraph.test.html`.
Attendu : toujours vert « 30/30 PASS » (le nouveau code ne casse pas la couche métier ; `init`/`bindSegment` ne sont pas appelés par le test).

- [ ] **Step 4: Commit**

```bash
git add public/assets/js/subnet-bargraph.js public/assets/css/app.css
git commit -m "feat(bargraph): couche rendu (init, barre, taux) + styles CSS (thème clair/sombre)"
```

---

## Task 7: Survol (infobulle maison) + clics (filtre / ajout)

**Files:**
- Modify: `public/assets/js/subnet-bargraph.js`

- [ ] **Step 1: Écrire le contenu d'infobulle + liaison des événements**

Dans `subnet-bargraph.js`, **remplacer** le stub `function bindSegment() {}` par :

```js
    // Construit le HTML d'infobulle d'un segment.
    function tipHtml(s, subnet) {
        var ref = s.ref;
        var startIp = intToIp(ref.startInt), endIp = intToIp(ref.endInt);
        var range = (ref.startInt === ref.endInt) ? startIp : (startIp + ' – ' + endIp);
        var rows = '';
        function row(k, v) { return '<div class="sbg-tip-row"><span>' + k + '</span><b>' + v + '</b></div>'; }

        if (s.kind === 'hole') {
            rows += row('Plage', range);
            rows += row('Adresses', ref.count);
            rows += row('Plus grand bloc', ref.largestCidr);
            return '<div class="sbg-tip-title">Libre</div>' + rows;
        }
        rows += row('Type', TYPE_LABEL[ref.type] || ref.type);
        rows += row('Plage', range);
        rows += row('Adresses', ref.count + ' (' + maskForCount(ref.startInt, ref.endInt) + ')');
        var title = TYPE_LABEL[ref.type] || 'Bloc';
        if (ref.type === 'reservation') {
            var chips = '';
            for (var g in ref.groups) {
                if (!ref.groups.hasOwnProperty(g)) continue;
                var label = g === '' ? '(sans groupe)' : g;
                chips += '<span class="sbg-grp">' + escapeHtml(label) + ' · ' + ref.groups[g] + '</span>';
            }
            if (chips) rows += '<div style="margin-top:4px">' + chips + '</div>';
            if (ref.shortLeaseCount > 0) rows += row('Dont short-lease', ref.shortLeaseCount);
        }
        return '<div class="sbg-tip-title">' + title + '</div>' + rows;
    }
    function escapeHtml(str) {
        return String(str).replace(/[&<>"']/g, function (c) {
            return { '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;' }[c];
        });
    }

    // Branche survol (infobulle) + clic (filtre / ajout) sur un segment.
    function bindSegment(seg, s, tip, subnet, onFilter, canEdit) {
        seg.addEventListener('mouseenter', function () {
            tip.innerHTML = tipHtml(s, subnet);
            tip.classList.add('show');
        });
        seg.addEventListener('mousemove', function (ev) {
            var pad = 14;
            var x = ev.clientX + pad, y = ev.clientY + pad;
            var w = tip.offsetWidth, h = tip.offsetHeight;
            if (x + w > window.innerWidth)  x = ev.clientX - w - pad;
            if (y + h > window.innerHeight) y = ev.clientY - h - pad;
            tip.style.left = x + 'px';
            tip.style.top = y + 'px';
        });
        seg.addEventListener('mouseleave', function () {
            tip.classList.remove('show');
        });

        if (s.kind === 'block' && s.ref.type === 'reservation') {
            seg.addEventListener('click', function () {
                var q = intToIp(s.ref.startInt) + '-' + intToIp(s.ref.endInt);
                onFilter(q);
            });
        } else if (s.kind === 'hole' && canEdit) {
            seg.addEventListener('click', function () {
                var firstFreeIp = intToIp(s.ref.startInt);
                window.location.href = subnet.addUrl + '&ip=' + encodeURIComponent(firstFreeIp);
            });
        }
        // blocs pool : pas de clic (infobulle seule)
    }
```

- [ ] **Step 2: Vérifier l'absence d'erreur de syntaxe**

Recharger `subnet-bargraph.test.html`.
Attendu : vert « 30/30 PASS » (métier intact ; `bindSegment` compile).

- [ ] **Step 3: Commit**

```bash
git add public/assets/js/subnet-bargraph.js
git commit -m "feat(bargraph): infobulle maison au survol + clic filtre/ajout"
```

---

## Task 8: Page démo statique /23

**Files:**
- Create: `public/assets/js/subnet-bargraph-demo.html`

- [ ] **Step 1: Créer la page démo**

Créer `public/assets/js/subnet-bargraph-demo.html`. Elle réutilise les mêmes fixtures /23 que le test, avec assez de réservations et **deux trous de tailles différentes** (un grand entre .14 et .49, un petit .48… — en pratique la structure produit plusieurs trous). Elle charge `app.css` + `bootstrap` (chemins relatifs `../css/...`) pour un rendu fidèle.

```html
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Démo — Bargraph subnet /23</title>
<link rel="stylesheet" href="../css/bootstrap.min.css">
<link rel="stylesheet" href="../css/bootstrap-icons.min.css">
<link rel="stylesheet" href="../css/app.css">
<style> body { padding: 30px; } .demo-card { max-width: 1000px; margin: 0 auto; } </style>
</head>
<body>
<div class="demo-card">
  <h5 class="mb-3">Démo — remplissage d'un subnet /23 (192.168.2.0/23)</h5>
  <div class="card">
    <div class="card-header">
      <div class="d-flex justify-content-between align-items-center">
        <span><i class="bi bi-hdd-network me-2"></i>6 réservations</span>
        <small class="text-muted">Pool dynamique · Pool short-lease</small>
      </div>
      <div id="demo-bargraph"></div>
    </div>
    <div class="card-body">
      <input id="demo-filter" class="form-control form-control-sm" placeholder="Filtre (rempli au clic sur un bloc)…">
      <p class="text-muted small mt-2 mb-0">Survole les blocs et les trous. Clique un bloc bleu → le filtre se remplit avec la plage. Clique un trou → (en vrai) ouvre l'ajout.</p>
    </div>
  </div>
</div>

<script src="subnet-bargraph.js"></script>
<script>
(function () {
  var SUBNET23 = {
    cidr: '192.168.2.0/23',
    networkInt: 3232236032, broadcastInt: 3232237567,
    firstUsable: 3232236033, lastUsable: 3232237566, total: 1534,
    dynStartInt: 3232236232, dynEndInt: 3232236331,
    shortStartInt: 3232236482, shortEndInt: 3232236511,
    addUrl: 'reservation_add.php?subnet_id=99'
  };
  var ROWS23 = [
    { host_id:1, hostname:'srv1', ip_int:3232236042, group_name:'serveurs', short_lease:0 },
    { host_id:2, hostname:'srv2', ip_int:3232236043, group_name:'serveurs', short_lease:0 },
    { host_id:3, hostname:'srv3', ip_int:3232236044, group_name:'serveurs', short_lease:0 },
    { host_id:4, hostname:'sw1',  ip_int:3232236045, group_name:'reseau',   short_lease:0 },
    { host_id:5, hostname:'iso',  ip_int:3232236082, group_name:'',         short_lease:0 },
    { host_id:6, hostname:'tmp',  ip_int:3232236083, group_name:'',         short_lease:1 }
  ];
  SubnetBargraph.init({
    mount: document.getElementById('demo-bargraph'),
    rows: ROWS23,
    subnet: SUBNET23,
    canEdit: true,
    onFilter: function (q) { document.getElementById('demo-filter').value = q; }
  });
}());
</script>
</body>
</html>
```

- [ ] **Step 2: Vérification visuelle**

Ouvrir `public/assets/js/subnet-bargraph-demo.html` au navigateur. Vérifier :
- La barre s'affiche avec un bloc bleu (à gauche, réservations .10-.13), un 2e bloc bleu (.50-.51), un bloc ambre (pool dynamique), un bloc violet (pool short-lease), le reste hachuré (trous).
- Survol d'un bloc bleu → infobulle avec groupes (`serveurs · 3`, `reseau · 1`) et masque.
- Survol d'un trou → « Libre » + plus grand CIDR.
- Clic sur le 1er bloc bleu → le champ filtre affiche `192.168.2.10-192.168.2.13`.
- Basculer le thème OS clair/sombre → couleurs et infobulle restent lisibles.

- [ ] **Step 3: Commit**

```bash
git add public/assets/js/subnet-bargraph-demo.html
git commit -m "feat(bargraph): page démo statique /23 (deux trous, tous les types)"
```

---

## Task 9: Intégration dans `subnet.php` — injection SUBNET, montage, filtre range

**Files:**
- Modify: `public/subnet.php`

- [ ] **Step 1: Calculer et injecter l'objet SUBNET (PHP)**

Dans `public/subnet.php`, après la ligne `$reservations = Reservation::getBySubnet(...)` (ligne ~13) et avant la construction de `$rows`, ajouter le calcul des bornes du subnet à partir du CIDR. `$networkInt` est calculé via `sprintf('%u', …)` pour rester un entier positif sur 32 bits (cohérent avec `ipToInt`). Pour /31 et /32 il n'y a pas d'adresse réseau/broadcast réservée, donc toute la plage est utilisable :

```php
// Bornes entières du subnet pour le bargraph
[$netStr, $bitsStr] = array_pad(explode('/', $subnet['cidr']), 2, '32');
$bits        = (int)$bitsStr;
$maskLong    = $bits >= 32 ? 0xFFFFFFFF : (~((1 << (32 - $bits)) - 1) & 0xFFFFFFFF);
$networkInt  = (int)sprintf('%u', ip2long($netStr) & $maskLong);
$hostCount   = 1 << (32 - $bits);
$broadcast   = $networkInt + $hostCount - 1;
$firstUsable = $bits >= 31 ? $networkInt : $networkInt + 1;   // /31,/32 : pas de net/bcast réservés
$lastUsable  = $bits >= 31 ? $broadcast : $broadcast - 1;
$sbgSubnet = [
    'cidr'          => $subnet['cidr'],
    'networkInt'    => $networkInt,
    'broadcastInt'  => $broadcast,
    'firstUsable'   => $firstUsable,
    'lastUsable'    => $lastUsable,
    'total'         => $lastUsable - $firstUsable + 1,
    'dynStartInt'   => ($subnet['has_dynamic_pool'] && $subnet['range_start']) ? (int)ipToInt($subnet['range_start']) : null,
    'dynEndInt'     => ($subnet['has_dynamic_pool'] && $subnet['range_end'])   ? (int)ipToInt($subnet['range_end'])   : null,
    'shortStartInt' => !empty($subnet['short_lease_pool_start']) ? (int)ipToInt($subnet['short_lease_pool_start']) : null,
    'shortEndInt'   => !empty($subnet['short_lease_pool_end'])   ? (int)ipToInt($subnet['short_lease_pool_end'])   : null,
    'addUrl'        => 'reservation_add.php?subnet_id=' . (int)$subnet['id'],
];
```

- [ ] **Step 2: Ajouter le point de montage dans le card-header**

Dans `public/subnet.php`, le `card-header` actuel (lignes ~111-128) enveloppe le compteur de réservations et les infos de pool dans un `d-flex justify-content-between`. On garde cette ligne d'infos et on ajoute la barre **en dessous**, à l'intérieur du même `card-header`.

Repérer :
```php
    <div class="card-header d-flex justify-content-between align-items-center py-3">
        <span>
            <i class="bi bi-hdd-network me-2"></i>
            <?= count($reservations) ?> réservation<?= count($reservations) > 1 ? 's' : '' ?>
        </span>
        <div class="d-flex flex-column gap-1 text-end">
        ...
        </div>
    </div>
```

Le remplacer par (on retire `d-flex justify-content-between align-items-center` du `card-header` pour permettre l'empilement vertical, et on remet la ligne d'infos dans un sous-`div` en flex) :

```php
    <div class="card-header py-3">
      <div class="d-flex justify-content-between align-items-center">
        <span>
            <i class="bi bi-hdd-network me-2"></i>
            <?= count($reservations) ?> réservation<?= count($reservations) > 1 ? 's' : '' ?>
        </span>
        <div class="d-flex flex-column gap-1 text-end">
        <?php if ($subnet['has_dynamic_pool'] && $subnet['range_start']): ?>
        <small class="text-muted">
            Pool dynamique : <?= h($subnet['range_start']) ?> – <?= h($subnet['range_end']) ?>
        </small>
        <?php endif; ?>
        <?php if (!empty($subnet['short_lease_pool_start'])): ?>
        <small class="text-muted">
            <i class="bi bi-hourglass-split text-warning me-1"></i>Pool short-lease : <?= h($subnet['short_lease_pool_start']) ?> – <?= h($subnet['short_lease_pool_end']) ?>
        </small>
        <?php endif; ?>
        </div>
      </div>
      <div id="subnet-bargraph"></div>
    </div>
```

- [ ] **Step 3: Charger le script + initialiser + injecter SUBNET**

Dans `public/subnet.php`, tout en bas, le `<script>` existant est une IIFE `(function () { … }())` avec `var ROWS = …`. Juste **après** la ligne `var ROWS = <?= json_encode($rows, …) ?>;` (ligne ~198), ajouter l'injection de SUBNET :

```js
    var SUBNET = <?= json_encode($sbgSubnet, JSON_HEX_TAG|JSON_HEX_APOS|JSON_HEX_QUOT|JSON_HEX_AMP) ?>;
```

Puis, **avant** la fermeture de l'IIFE (avant la ligne `render();` finale et `}());`), ajouter l'initialisation du bargraph, en lui passant `onFilter` qui pilote le champ filtre existant :

```js
    // ── Bargraph de remplissage ──
    if (window.SubnetBargraph) {
        SubnetBargraph.init({
            mount: document.getElementById('subnet-bargraph'),
            rows: ROWS,
            subnet: SUBNET,
            canEdit: CAN_EDIT,
            onFilter: function (q) {
                filterInput.value = q;
                filterInput.dispatchEvent(new Event('input'));
                filterInput.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
            }
        });
    }
```

> `filterInput`, `ROWS`, `CAN_EDIT` sont déjà déclarés dans cette IIFE. `render()` reste appelé après.

- [ ] **Step 4: Charger le fichier JS du module**

Dans `public/subnet.php`, la page inclut `layout_footer.php` (qui charge bootstrap) puis ouvre son `<script>` inline. Ajouter le `<script src>` du module **avant** le `<script>` inline. Repérer la ligne :

```php
<?php require_once APP_ROOT . '/lib/layout_footer.php'; ?>
<script>
```

La remplacer par :

```php
<?php require_once APP_ROOT . '/lib/layout_footer.php'; ?>
<script src="assets/js/subnet-bargraph.js"></script>
<script>
```

- [ ] **Step 5: Enrichir `matches()` pour la syntaxe de range**

Dans `public/subnet.php`, `matches(r, q)` (lignes ~224-230) fait une recherche sous-chaîne. Ajouter en tête une détection de range. Repérer :

```js
    function matches(r, q) {
        if (q === '') return true;
        var macNoSep = r.mac_hex;
        var hay = norm(r.hostname) + ' ' + norm(r.ip) + ' ' + norm(r.mac) + ' '
                + macNoSep + ' ' + norm(r.description) + ' ' + norm(r.group_name);
        return hay.indexOf(q) !== -1;
    }
```

Le remplacer par (ajout d'un pré-traitement range ; le reste inchangé) :

```js
    // Détecte une requête "range IP" : "a.b.c.d-a.b.c.d" ou forme courte ".10-.20" / "10-20".
    // Retourne [loInt, hiInt] ou null.
    function parseRange(q) {
        var m = q.match(/^\s*(\d{1,3}(?:\.\d{1,3}){0,3})\s*-\s*(\d{1,3}(?:\.\d{1,3}){0,3})\s*$/);
        if (!m) return null;
        function toInt(part) {
            // forme courte : compléter avec le préfixe réseau du subnet courant
            var octets = part.split('.');
            if (octets.length < 4) {
                var base = SUBNET.networkInt >>> 0;
                var baseOct = [(base>>>24)&255,(base>>>16)&255,(base>>>8)&255,base&255];
                // les octets fournis remplacent les derniers
                var full = baseOct.slice(0, 4 - octets.length).concat(octets.map(Number));
                octets = full;
            } else {
                octets = octets.map(Number);
            }
            if (octets.length !== 4 || octets.some(function (o){ return isNaN(o) || o < 0 || o > 255; })) return null;
            return (octets[0] * 16777216 + octets[1] * 65536 + octets[2] * 256 + octets[3]) >>> 0;
        }
        var lo = toInt(m[1]), hi = toInt(m[2]);
        if (lo == null || hi == null) return null;
        return lo <= hi ? [lo, hi] : [hi, lo];
    }
    function matches(r, q) {
        if (q === '') return true;
        var range = parseRange(q);
        if (range) {
            return r.ip_int >= range[0] && r.ip_int <= range[1];
        }
        var macNoSep = r.mac_hex;
        var hay = norm(r.hostname) + ' ' + norm(r.ip) + ' ' + norm(r.mac) + ' '
                + macNoSep + ' ' + norm(r.description) + ' ' + norm(r.group_name);
        return hay.indexOf(q) !== -1;
    }
```

> `SUBNET` est accessible : il est déclaré dans la même IIFE (Step 3). `parseRange` doit être défini **avant** `matches` dans le fichier, ou dans la même portée — les `function` sont hoistées, donc l'ordre d'écriture est indifférent tant qu'elles sont dans la même IIFE.

- [ ] **Step 6: Vérification manuelle dans l'app**

Ouvrir un subnet réel (ex. `http://dhcpman.local/subnet.php?id=1`). Vérifier :
- La barre apparaît sous le compteur de réservations dans le header.
- Survol des blocs/trous → infobulle.
- Clic sur un bloc de réservation → le champ filtre se remplit (`x.x.x.x-x.x.x.x`) et la liste se réduit.
- Taper `.30-.80` dans le filtre → réduit aux réservations de cette plage.
- Clic sur un trou (si connecté en admin/tech) → ouvre `reservation_add.php` avec l'IP libre.
- Les modes Groupés/Plat, le filtre texte et la sélection multiple fonctionnent toujours.

- [ ] **Step 7: Commit**

```bash
git add public/subnet.php
git commit -m "feat(bargraph): intégration dans subnet.php (montage, SUBNET, filtre range)"
```

---

## Task 10: Synchronisation projet + documentation CLAUDE.md

**Files:**
- Modify: `CLAUDE.md`

- [ ] **Step 1: Documenter le composant dans CLAUDE.md**

Dans `CLAUDE.md`, dans la description de `subnet.php` (section « Structure du projet »), compléter la ligne :

```
  subnet.php                    — Détail subnet + liste réservations (rendu JS, 2 modes groupés/plat, tri IP, filtre multi-champs, sélection multiple + popup mass update)
```

en ajoutant la mention du bargraph :

```
  subnet.php                    — Détail subnet + liste réservations (rendu JS, 2 modes groupés/plat, tri IP, filtre multi-champs + syntaxe range IP, sélection multiple + popup mass update, bargraph de remplissage dans le header)
```

Et ajouter dans la liste `public/` (près des assets) :

```
  assets/js/subnet-bargraph.js       — Composant bargraph de remplissage subnet (métier/layout/rendu, inspiré Titan)
  assets/js/subnet-bargraph.test.html — Tests navigateur de la couche métier du bargraph
  assets/js/subnet-bargraph-demo.html — Démo statique /23
```

- [ ] **Step 2: Ajouter une section descriptive**

Dans `CLAUDE.md`, ajouter une nouvelle section après « Mass update des réservations (v0.14) » :

```markdown
## Bargraph de remplissage de subnet (v0.15)

Barre horizontale unique dans le header du tableau de `subnet.php`, inspirée de la
vue Patch d'Avolites Titan. 100 % côté client, aucune table/migration.

- Module `public/assets/js/subnet-bargraph.js` en 3 couches pures :
  - **métier** : `computeBlocks` (fusion des réservations contiguës par type + injection
    des pools), `computeHoles` (trous dérivés), `largestAlignedCidr`, `maskForCount`.
  - **layout** : `layoutSegments` — proportionnel + plancher 5px sur les blocs occupés +
    renormalisation globale (Σ = 100 %). Les trous ne sont jamais planchés.
  - **rendu** : `init({mount, rows, subnet, onFilter, canEdit})`, infobulle maison au survol.
- Couleurs par **type** : réservation (bleu), pool dynamique (ambre), pool short-lease
  (violet) ; trous hachurés. Une réservation short-lease à IP fixe reste bleue (statut
  dans l'infobulle). Fusion **par type** (pas par groupe) ; l'infobulle liste les groupes.
- **Clic sur un bloc réservation** → remplit le filtre existant avec la plage
  (`a.b.c.d-a.b.c.d`). Le filtre de `subnet.php` comprend désormais une **syntaxe de
  range** (`parseRange`), aussi tapable au clavier (forme courte `.30-.80` acceptée).
- **Clic sur un trou** (admin|tech) → `reservation_add.php` avec la 1re IP libre.
- Objet `SUBNET` injecté par `subnet.php` (bornes entières + pools + addUrl).
- Tests : ouvrir `public/assets/js/subnet-bargraph.test.html` au navigateur (vert/rouge).
  Démo : `public/assets/js/subnet-bargraph-demo.html` (/23, deux trous, tous les types).
```

- [ ] **Step 3: Commit + sync**

```bash
git add CLAUDE.md
git commit -m "docs: bargraph de remplissage de subnet (v0.15)"
bash ~/sync.sh dhcpman
```

---

## Récapitulatif des tâches

1. Squelette module + `intToIp` + harnais de test
2. `maskForCount` + `largestAlignedCidr`
3. `computeBlocks` (fusion par type + pools)
4. `computeHoles` (trous dérivés)
5. `layoutSegments` (plancher 5px + renormalisation globale)
6. Couche rendu (`init`, barre, taux) + CSS
7. Survol (infobulle) + clics (filtre / ajout)
8. Page démo statique /23
9. Intégration `subnet.php` (SUBNET, montage, filtre range)
10. Doc CLAUDE.md + sync

Chaque tâche produit un commit autonome. Les tâches 1-5 sont couvertes par des tests d'assertion (couche pure) ; 6-9 par vérification visuelle (démo + app réelle).
