feat(release-train): Release Train Dashboard UX premium WTP-style - 388 commits 24h / 62 milestones / 38 phases / 45 doctrines / 216 intents / 98.1pct coverage UX - timeline milestones + donut features/fixes/sync + hourly barchart + tags phases/waves/doctrines + live health bar - auto-refresh 60s
Some checks failed
WEVAL NonReg / nonreg (push) Has been cancelled

This commit is contained in:
Opus
2026-04-24 15:42:47 +02:00
parent b88c66ec9e
commit 95ef75d347
2 changed files with 1834 additions and 0 deletions

1244
api/release-train-data.json Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,590 @@
<!DOCTYPE html>
<html lang="fr" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Release Train · WEVAL Technology Platform</title>
<meta name="description" content="Release Train Dashboard - Commits, Phases, Waves, Doctrines, Intents, Coverage - Multi-Claude reconciliation live">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&family=JetBrains+Mono:wght@500;700&display=swap" rel="stylesheet">
<style>
:root{
--bg-0:#05060a;--bg-1:#0a0c14;--bg-2:#11141f;--bg-3:#181c2b;--bg-card:#0e111c;
--border:#1f2436;--border-hover:#3a425f;
--text-0:#f1f5f9;--text-1:#cbd5e1;--text-2:#94a3b8;--text-3:#64748b;
--accent:#6366f1;--accent-hover:#818cf8;
--success:#10b981;--warning:#f59e0b;--danger:#ef4444;--info:#06b6d4;
--gold:#f6d572;--mint:#5cdb95;--violet:#a78bfa;--coral:#ff6b6b;--cyan:#4ecdc4;
--shadow-lg:0 16px 48px rgba(99,102,241,.2);
--radius:14px;--radius-sm:10px;
--trans:.18s cubic-bezier(.4,0,.2,1);
--font-sans:"Inter",-apple-system,system-ui,sans-serif;
--font-mono:"JetBrains Mono",Monaco,monospace;
}
*{box-sizing:border-box;margin:0;padding:0}
body{
background:var(--bg-0);
color:var(--text-0);
font-family:var(--font-sans);
min-height:100vh;
background-image:
radial-gradient(ellipse at 15% 20%,rgba(99,102,241,.08) 0%,transparent 60%),
radial-gradient(ellipse at 85% 80%,rgba(6,182,212,.06) 0%,transparent 60%),
radial-gradient(ellipse at 50% 50%,rgba(167,139,250,.04) 0%,transparent 70%);
overflow-x:hidden;
}
/* ================ HEADER ================ */
header.topbar{
display:flex;align-items:center;justify-content:space-between;
padding:14px 28px;
border-bottom:1px solid var(--border);
background:rgba(10,12,20,.85);
backdrop-filter:blur(24px);
position:sticky;top:0;z-index:100;
}
.brand{display:flex;align-items:center;gap:14px}
.brand-logo{
width:38px;height:38px;
border-radius:10px;
background:linear-gradient(135deg,var(--accent),var(--violet));
display:flex;align-items:center;justify-content:center;
font-weight:900;font-size:18px;color:#fff;
box-shadow:0 4px 20px rgba(99,102,241,.4);
}
.brand-text{display:flex;flex-direction:column}
.brand-title{font-size:14px;font-weight:700;letter-spacing:.08em;color:var(--text-0)}
.brand-sub{font-size:10px;letter-spacing:.2em;color:var(--text-3);text-transform:uppercase;font-weight:600}
.header-crumbs{display:flex;align-items:center;gap:8px;color:var(--text-2);font-size:12px}
.header-crumbs a{color:var(--accent-hover);text-decoration:none;font-weight:500}
.header-crumbs a:hover{color:var(--text-0)}
.health-bar{display:flex;gap:18px;align-items:center}
.h-item{
display:flex;align-items:center;gap:7px;
font-size:11px;letter-spacing:.06em;
color:var(--text-2);font-weight:500;
font-family:var(--font-mono);
}
.h-dot{width:7px;height:7px;border-radius:50%;display:inline-block}
.h-dot.ok{background:var(--mint);box-shadow:0 0 8px var(--mint)}
.h-dot.warn{background:var(--gold);box-shadow:0 0 8px var(--gold)}
.h-dot.err{background:var(--coral);box-shadow:0 0 8px var(--coral)}
.h-val{color:var(--text-0);font-weight:700}
/* ================ LAYOUT ================ */
.page{
display:grid;
grid-template-columns:1fr;
max-width:1680px;
margin:0 auto;
padding:28px;
gap:24px;
}
/* PAGE HEAD */
.page-head{
display:flex;align-items:center;justify-content:space-between;
padding-bottom:8px;
}
.page-title-wrap{display:flex;align-items:center;gap:18px}
.page-icon{
width:52px;height:52px;
border-radius:12px;
background:linear-gradient(135deg,rgba(99,102,241,.2),rgba(6,182,212,.15));
border:1px solid rgba(99,102,241,.3);
display:flex;align-items:center;justify-content:center;
font-size:24px;
}
.page-title{font-size:26px;font-weight:800;letter-spacing:-.02em;color:var(--text-0)}
.page-sub{font-size:13px;color:var(--text-2);margin-top:3px;font-weight:500}
.page-actions{display:flex;gap:10px}
.btn{
padding:10px 16px;border-radius:10px;
background:var(--bg-2);border:1px solid var(--border);color:var(--text-1);
font-size:12px;font-weight:600;letter-spacing:.04em;
cursor:pointer;transition:var(--trans);
display:flex;align-items:center;gap:7px;
font-family:var(--font-sans);
}
.btn:hover{background:var(--bg-3);border-color:var(--border-hover);color:var(--text-0)}
.btn-primary{background:linear-gradient(135deg,var(--accent),var(--violet));border:none;color:#fff}
.btn-primary:hover{opacity:.9;transform:translateY(-1px)}
/* ================ STATS GRID ================ */
.stats-grid{
display:grid;
grid-template-columns:repeat(auto-fit,minmax(200px,1fr));
gap:18px;
}
.stat-card{
background:linear-gradient(135deg,var(--bg-card),var(--bg-1));
border:1px solid var(--border);
border-radius:var(--radius);
padding:22px 24px;
position:relative;overflow:hidden;
transition:var(--trans);
}
.stat-card:hover{border-color:var(--border-hover);transform:translateY(-2px);box-shadow:var(--shadow-lg)}
.stat-card::before{
content:"";position:absolute;top:0;left:0;right:0;height:3px;
background:linear-gradient(90deg,var(--accent),var(--violet));
opacity:.5;
}
.stat-card.mint::before{background:linear-gradient(90deg,var(--mint),var(--success))}
.stat-card.gold::before{background:linear-gradient(90deg,var(--gold),var(--warning))}
.stat-card.cyan::before{background:linear-gradient(90deg,var(--cyan),var(--info))}
.stat-card.coral::before{background:linear-gradient(90deg,var(--coral),var(--danger))}
.stat-label{font-size:10px;letter-spacing:.15em;text-transform:uppercase;color:var(--text-3);font-weight:700;margin-bottom:10px}
.stat-value{font-size:34px;font-weight:900;letter-spacing:-.03em;color:var(--text-0);line-height:1;font-family:var(--font-mono)}
.stat-sub{font-size:11px;color:var(--text-2);margin-top:7px;font-weight:500}
.stat-delta{
display:inline-flex;align-items:center;gap:4px;
padding:2px 8px;border-radius:100px;
background:rgba(16,185,129,.1);color:var(--mint);
font-size:10px;font-weight:700;
margin-left:8px;
}
/* ================ SECTION ================ */
.section{
background:var(--bg-card);border:1px solid var(--border);
border-radius:var(--radius);overflow:hidden;
}
.section-head{
padding:18px 24px;border-bottom:1px solid var(--border);
display:flex;align-items:center;justify-content:space-between;
background:linear-gradient(180deg,rgba(99,102,241,.04),transparent);
}
.section-title{font-size:14px;font-weight:700;letter-spacing:.02em;color:var(--text-0);display:flex;align-items:center;gap:10px}
.section-title::before{content:"";width:3px;height:18px;background:var(--accent);border-radius:2px}
.section-meta{font-size:11px;color:var(--text-3);font-family:var(--font-mono)}
.section-body{padding:20px 24px}
/* ================ TWO-COL ================ */
.two-col{display:grid;grid-template-columns:1.6fr 1fr;gap:24px}
@media (max-width:1200px){.two-col{grid-template-columns:1fr}}
/* ================ TIMELINE ================ */
.timeline{position:relative;padding-left:30px}
.timeline::before{
content:"";position:absolute;left:10px;top:8px;bottom:8px;width:2px;
background:linear-gradient(180deg,var(--accent),var(--violet),transparent);
}
.tl-item{
position:relative;padding:14px 0 16px 18px;
border-bottom:1px dashed var(--border);
}
.tl-item:last-child{border-bottom:none}
.tl-item::before{
content:"";position:absolute;left:-25px;top:22px;
width:11px;height:11px;border-radius:50%;
background:var(--accent);
box-shadow:0 0 0 3px var(--bg-card),0 0 0 4px var(--accent);
}
.tl-item.milestone::before{background:var(--gold);box-shadow:0 0 0 3px var(--bg-card),0 0 0 4px var(--gold),0 0 14px var(--gold)}
.tl-item.feat::before{background:var(--mint);box-shadow:0 0 0 3px var(--bg-card),0 0 0 4px var(--mint)}
.tl-item.fix::before{background:var(--coral);box-shadow:0 0 0 3px var(--bg-card),0 0 0 4px var(--coral)}
.tl-item.sync::before{background:var(--text-3);box-shadow:0 0 0 3px var(--bg-card),0 0 0 4px var(--text-3)}
.tl-head{display:flex;align-items:center;gap:10px;margin-bottom:5px;flex-wrap:wrap}
.tl-sha{font-family:var(--font-mono);font-size:10px;color:var(--text-3);font-weight:600;background:var(--bg-2);padding:2px 7px;border-radius:4px}
.tl-ts{font-size:10px;color:var(--text-3);font-family:var(--font-mono)}
.tl-tag{font-size:9px;font-weight:700;padding:2px 7px;border-radius:100px;text-transform:uppercase;letter-spacing:.08em}
.tl-tag.phase{background:rgba(99,102,241,.15);color:var(--accent-hover);border:1px solid rgba(99,102,241,.3)}
.tl-tag.wave{background:rgba(6,182,212,.15);color:var(--cyan);border:1px solid rgba(6,182,212,.3)}
.tl-tag.doctrine{background:rgba(246,213,114,.15);color:var(--gold);border:1px solid rgba(246,213,114,.3)}
.tl-subject{font-size:12.5px;color:var(--text-1);line-height:1.5;word-break:break-word}
/* ================ CHART DONUT ================ */
.chart-wrap{padding:16px 0;display:flex;flex-direction:column;align-items:center}
.donut-row{display:grid;grid-template-columns:repeat(3,1fr);gap:16px;width:100%;margin-top:10px}
.donut-card{background:var(--bg-2);border:1px solid var(--border);border-radius:var(--radius-sm);padding:14px;text-align:center}
.donut-val{font-size:28px;font-weight:900;color:var(--text-0);font-family:var(--font-mono);line-height:1}
.donut-label{font-size:10px;letter-spacing:.1em;text-transform:uppercase;color:var(--text-3);margin-top:6px;font-weight:700}
/* ================ BAR CHART ================ */
.barchart{display:flex;align-items:flex-end;gap:6px;height:180px;padding:16px 0 8px;border-bottom:1px dashed var(--border);margin-bottom:10px}
.bar-col{flex:1;display:flex;flex-direction:column;align-items:center;gap:6px}
.bar{
width:100%;background:linear-gradient(180deg,var(--accent),var(--violet));
border-radius:4px 4px 0 0;min-height:4px;
transition:all .3s ease;cursor:pointer;
position:relative;
}
.bar:hover{opacity:.8;transform:scaleY(1.05)}
.bar-val{font-size:10px;color:var(--text-2);font-family:var(--font-mono);font-weight:700;position:absolute;top:-18px;left:50%;transform:translateX(-50%)}
.bar-label{font-size:10px;color:var(--text-3);font-family:var(--font-mono)}
/* ================ PROGRESS ================ */
.progress-wrap{margin:16px 0}
.progress-head{display:flex;justify-content:space-between;margin-bottom:8px}
.progress-name{font-size:12px;font-weight:600;color:var(--text-1)}
.progress-val{font-size:12px;font-family:var(--font-mono);color:var(--text-0);font-weight:700}
.progress-track{height:10px;background:var(--bg-2);border-radius:100px;overflow:hidden;border:1px solid var(--border)}
.progress-fill{
height:100%;
background:linear-gradient(90deg,var(--mint),var(--cyan),var(--accent));
border-radius:100px;
transition:width 1s cubic-bezier(.4,0,.2,1);
box-shadow:0 0 20px rgba(92,219,149,.4);
}
/* ================ TAGS GRID ================ */
.tags-grid{display:flex;flex-wrap:wrap;gap:8px;padding:4px 0}
.tag-chip{
padding:6px 12px;border-radius:100px;
font-size:11px;font-weight:600;font-family:var(--font-mono);
background:var(--bg-2);border:1px solid var(--border);color:var(--text-1);
display:flex;align-items:center;gap:6px;
transition:var(--trans);cursor:default;
}
.tag-chip:hover{border-color:var(--border-hover);transform:translateY(-1px)}
.tag-chip.phase{background:rgba(99,102,241,.08);border-color:rgba(99,102,241,.25);color:var(--accent-hover)}
.tag-chip.wave{background:rgba(6,182,212,.08);border-color:rgba(6,182,212,.25);color:var(--cyan)}
.tag-chip.doctrine{background:rgba(246,213,114,.08);border-color:rgba(246,213,114,.25);color:var(--gold)}
.tag-chip .count{background:rgba(255,255,255,.08);padding:1px 6px;border-radius:4px;font-size:9px;color:var(--text-0);font-weight:800}
/* ================ LEGEND ================ */
.legend{display:flex;gap:16px;margin:12px 0;font-size:11px;flex-wrap:wrap}
.leg-item{display:flex;align-items:center;gap:6px;color:var(--text-2)}
.leg-dot{width:10px;height:10px;border-radius:50%}
/* ================ FOOTER ================ */
footer{
text-align:center;padding:24px;
color:var(--text-3);font-size:11px;font-family:var(--font-mono);
border-top:1px solid var(--border);margin-top:24px;
}
footer a{color:var(--accent-hover);text-decoration:none}
/* ================ ANIMATIONS ================ */
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.4}}
.pulse{animation:pulse 2s ease-in-out infinite}
@keyframes slideUp{from{opacity:0;transform:translateY(20px)}to{opacity:1;transform:none}}
.stat-card,.section{animation:slideUp .5s ease-out backwards}
.stat-card:nth-child(1){animation-delay:.05s}
.stat-card:nth-child(2){animation-delay:.1s}
.stat-card:nth-child(3){animation-delay:.15s}
.stat-card:nth-child(4){animation-delay:.2s}
.stat-card:nth-child(5){animation-delay:.25s}
.stat-card:nth-child(6){animation-delay:.3s}
@media (max-width:720px){
.page{padding:14px}
.page-head{flex-direction:column;align-items:flex-start;gap:12px}
.health-bar{display:none}
.stats-grid{grid-template-columns:repeat(2,1fr);gap:10px}
.stat-card{padding:14px}
.stat-value{font-size:24px}
}
</style>
</head>
<body>
<header class="topbar">
<div class="brand">
<div class="brand-logo">W</div>
<div class="brand-text">
<span class="brand-title">WEVAL Technology Platform</span>
<span class="brand-sub">Release Train · Live</span>
</div>
</div>
<div class="header-crumbs">
<a href="/weval-technology-platform.html">WTP</a>
<span></span>
<a href="/wevia-cockpit.html">Cockpit</a>
<span></span>
<span style="color:var(--text-0);font-weight:600">Release Train</span>
</div>
<div class="health-bar" id="health-bar">
<div class="h-item"><span class="h-dot ok pulse"></span><span>LIVE</span></div>
<div class="h-item">S204 · <span class="h-val" id="h-load"></span></div>
<div class="h-item">Intents · <span class="h-val" id="h-intents"></span></div>
<div class="h-item">Coverage · <span class="h-val" id="h-coverage"></span></div>
<div class="h-item">Commits 24h · <span class="h-val" id="h-commits"></span></div>
</div>
</header>
<div class="page">
<!-- PAGE HEAD -->
<div class="page-head">
<div class="page-title-wrap">
<div class="page-icon">🚂</div>
<div>
<h1 class="page-title">Release Train Dashboard</h1>
<p class="page-sub">Multi-Claude reconciliation · Phases · Waves · Doctrines · Intents · Coverage UX</p>
</div>
</div>
<div class="page-actions">
<button class="btn" onclick="loadData()">
<span id="refresh-icon"></span> Refresh
</button>
<a href="/wevia-cockpit.html" class="btn btn-primary">
⚡ Cockpit
</a>
</div>
</div>
<!-- KPI STATS -->
<div class="stats-grid" id="stats-grid">
<div class="stat-card">
<div class="stat-label">📦 Commits 24h</div>
<div class="stat-value" id="kpi-commits"></div>
<div class="stat-sub">auto-sync + features + fixes</div>
</div>
<div class="stat-card mint">
<div class="stat-label">🎯 Milestones</div>
<div class="stat-value" id="kpi-milestones"></div>
<div class="stat-sub">phases + waves significatives</div>
</div>
<div class="stat-card cyan">
<div class="stat-label">🤖 Intents</div>
<div class="stat-value" id="kpi-intents"></div>
<div class="stat-sub">dernière progression</div>
</div>
<div class="stat-card gold">
<div class="stat-label">📐 Pages UX</div>
<div class="stat-value" id="kpi-coverage"></div>
<div class="stat-sub" id="kpi-coverage-sub"></div>
</div>
<div class="stat-card">
<div class="stat-label">🏗 Phases</div>
<div class="stat-value" id="kpi-phases"></div>
<div class="stat-sub">uniques 24h</div>
</div>
<div class="stat-card coral">
<div class="stat-label">📜 Doctrines</div>
<div class="stat-value" id="kpi-doctrines"></div>
<div class="stat-sub">numérotées</div>
</div>
</div>
<!-- COVERAGE PROGRESS -->
<div class="section">
<div class="section-head">
<div class="section-title">Coverage UX Doctrine 60</div>
<div class="section-meta" id="cov-meta"></div>
</div>
<div class="section-body">
<div class="progress-wrap">
<div class="progress-head">
<span class="progress-name">Pages enrichies (cascade async)</span>
<span class="progress-val" id="progress-val">0%</span>
</div>
<div class="progress-track">
<div class="progress-fill" id="progress-fill" style="width:0%"></div>
</div>
</div>
</div>
</div>
<!-- 2-COL: TIMELINE + DISTRIBUTION -->
<div class="two-col">
<!-- TIMELINE MILESTONES -->
<div class="section">
<div class="section-head">
<div class="section-title">Timeline Milestones</div>
<div class="legend">
<div class="leg-item"><span class="leg-dot" style="background:var(--gold)"></span>Milestone</div>
<div class="leg-item"><span class="leg-dot" style="background:var(--mint)"></span>Feature</div>
<div class="leg-item"><span class="leg-dot" style="background:var(--coral)"></span>Fix</div>
<div class="leg-item"><span class="leg-dot" style="background:var(--text-3)"></span>Auto-sync</div>
</div>
</div>
<div class="section-body">
<div class="timeline" id="timeline">
<div style="color:var(--text-3);text-align:center;padding:40px;font-family:var(--font-mono);font-size:12px">Chargement timeline…</div>
</div>
</div>
</div>
<!-- RIGHT COL -->
<div style="display:flex;flex-direction:column;gap:24px">
<!-- DONUT DISTRIBUTION -->
<div class="section">
<div class="section-head">
<div class="section-title">Répartition Commits</div>
</div>
<div class="section-body">
<div class="donut-row">
<div class="donut-card">
<div class="donut-val" id="d-feat"></div>
<div class="donut-label" style="color:var(--mint)">Features</div>
</div>
<div class="donut-card">
<div class="donut-val" id="d-fix"></div>
<div class="donut-label" style="color:var(--coral)">Fixes</div>
</div>
<div class="donut-card">
<div class="donut-val" id="d-sync"></div>
<div class="donut-label" style="color:var(--text-3)">Auto-sync</div>
</div>
</div>
</div>
</div>
<!-- HOURLY BARCHART -->
<div class="section">
<div class="section-head">
<div class="section-title">Distribution Horaire</div>
<div class="section-meta">24h · CET</div>
</div>
<div class="section-body">
<div class="barchart" id="barchart">
<div style="color:var(--text-3);font-size:11px;font-family:var(--font-mono);margin:auto">Chargement…</div>
</div>
</div>
</div>
<!-- PHASES TAGS -->
<div class="section">
<div class="section-head">
<div class="section-title">Phases · Waves · Doctrines</div>
</div>
<div class="section-body">
<div style="font-size:10px;letter-spacing:.15em;text-transform:uppercase;color:var(--text-3);font-weight:700;margin-bottom:10px">PHASES</div>
<div class="tags-grid" id="tags-phases"></div>
<div style="font-size:10px;letter-spacing:.15em;text-transform:uppercase;color:var(--text-3);font-weight:700;margin:16px 0 10px">WAVES</div>
<div class="tags-grid" id="tags-waves"></div>
<div style="font-size:10px;letter-spacing:.15em;text-transform:uppercase;color:var(--text-3);font-weight:700;margin:16px 0 10px">DOCTRINES</div>
<div class="tags-grid" id="tags-doctrines"></div>
</div>
</div>
</div>
</div>
<footer>
Release Train · WEVAL Technology Platform · Multi-Claude Convergence · Live Data · <span id="gen-at"></span>
· <a href="/api/release-train-data.json" target="_blank">raw JSON</a>
</footer>
</div>
<script>
// ============= Data Loading =============
async function loadData() {
const icon = document.getElementById("refresh-icon");
icon.style.animation = "pulse 1s infinite";
try {
const res = await fetch("/api/release-train-data.json?t=" + Date.now(), {cache:"no-store"});
const d = await res.json();
render(d);
// Health bar
document.getElementById("h-intents").textContent = d.stats.last_intent_count || "—";
document.getElementById("h-coverage").textContent = d.stats.last_coverage ? d.stats.last_coverage.pct + "%" : "—";
document.getElementById("h-commits").textContent = d.stats.total_commits_24h;
fetchLoad();
document.getElementById("gen-at").textContent = new Date(d.generated_at).toLocaleString("fr-FR");
} catch(e) {
console.error("Load fail", e);
} finally {
icon.style.animation = "";
}
}
async function fetchLoad() {
try {
const res = await fetch("/api/infra-dashboard-api.php?t=" + Date.now(), {cache:"no-store", signal:AbortSignal.timeout(5000)});
const d = await res.json();
const load = d.load_1m || d.load || null;
document.getElementById("h-load").textContent = load ? load.toFixed(1) : "—";
} catch(e) {
document.getElementById("h-load").textContent = "—";
}
}
function render(d) {
// KPIs
document.getElementById("kpi-commits").textContent = d.stats.total_commits_24h;
document.getElementById("kpi-milestones").textContent = d.stats.milestones_24h;
document.getElementById("kpi-intents").textContent = d.stats.last_intent_count;
if (d.stats.last_coverage) {
document.getElementById("kpi-coverage").textContent = d.stats.last_coverage.pct + "%";
document.getElementById("kpi-coverage-sub").textContent = `${d.stats.last_coverage.num}/${d.stats.last_coverage.total}`;
document.getElementById("cov-meta").textContent = `${d.stats.last_coverage.num} / ${d.stats.last_coverage.total} pages`;
document.getElementById("progress-val").textContent = d.stats.last_coverage.pct + "%";
setTimeout(() => {
document.getElementById("progress-fill").style.width = d.stats.last_coverage.pct + "%";
}, 200);
}
document.getElementById("kpi-phases").textContent = d.stats.unique_phases;
document.getElementById("kpi-doctrines").textContent = d.stats.unique_doctrines;
// Donut
document.getElementById("d-feat").textContent = d.stats.features;
document.getElementById("d-fix").textContent = d.stats.fixes;
document.getElementById("d-sync").textContent = d.stats.auto_sync;
// Timeline milestones
const tl = document.getElementById("timeline");
tl.innerHTML = "";
(d.milestone_commits || []).slice(0, 25).forEach(c => {
const cls = c.milestone ? "milestone" : (c.type === "fix" ? "fix" : (c.type === "auto-sync" ? "sync" : "feat"));
const tags = [];
if (c.phase) tags.push(`<span class="tl-tag phase">phase ${c.phase}</span>`);
if (c.wave) tags.push(`<span class="tl-tag wave">wave ${c.wave}</span>`);
if (c.doctrine) tags.push(`<span class="tl-tag doctrine">D${c.doctrine}</span>`);
const dt = new Date(c.ts);
const tsStr = dt.toLocaleTimeString("fr-FR",{hour:"2-digit",minute:"2-digit"});
const subjTrim = c.subject.length > 180 ? c.subject.slice(0,180) + "…" : c.subject;
tl.insertAdjacentHTML("beforeend",
`<div class="tl-item ${cls}">
<div class="tl-head">
<span class="tl-sha">${c.sha}</span>
<span class="tl-ts">${tsStr}</span>
${tags.join("")}
</div>
<div class="tl-subject">${escapeHtml(subjTrim)}</div>
</div>`
);
});
// Barchart hourly
const bc = document.getElementById("barchart");
bc.innerHTML = "";
const hh = d.hourly_distribution || {};
const max = Math.max(1, ...Object.values(hh));
const hours = Object.keys(hh).sort();
hours.forEach(h => {
const v = hh[h];
const pct = (v / max) * 100;
bc.insertAdjacentHTML("beforeend",
`<div class="bar-col" title="${h}h: ${v} commits">
<div class="bar" style="height:${pct}%"><span class="bar-val">${v}</span></div>
<div class="bar-label">${h}h</div>
</div>`);
});
// Tags
const renderTags = (id, data, cls) => {
const el = document.getElementById(id);
el.innerHTML = "";
Object.entries(data).forEach(([k, v]) => {
el.insertAdjacentHTML("beforeend",
`<div class="tag-chip ${cls}">${cls === "phase" ? "phase " : (cls === "wave" ? "wave " : "D")}${k}<span class="count">${v}</span></div>`);
});
};
renderTags("tags-phases", d.phases, "phase");
renderTags("tags-waves", d.waves, "wave");
renderTags("tags-doctrines", d.doctrines, "doctrine");
}
function escapeHtml(s) {
return s.replace(/[<>&"]/g, c => ({'<':'&lt;','>':'&gt;','&':'&amp;','"':'&quot;'}[c]));
}
// Initial load + auto-refresh every 60s
loadData();
setInterval(loadData, 60000);
</script>
</body>
</html>