Commit 739a394f6535

Vincent Demeester <vincent@sbr.pm>
2026-04-17 10:50:11
fix: html bar chart rendering and proportional scaling
Bar fills were invisible due to span elements needing display:block for width/height to apply. Also added border to bar tracks for better contrast.
1 parent ee11224
Changed files (1)
tools
usage-metrics
tools/usage-metrics/usage-report
@@ -7,9 +7,10 @@
 usage-report: Generate usage reports from collected metrics.
 
 Reads JSON files from ~/.local/share/usage-metrics/ and produces
-a markdown summary for awareness, pruning, and optimization.
+markdown or HTML reports for awareness, pruning, and optimization.
 """
 import argparse
+import html
 import json
 import os
 import sys
@@ -17,19 +18,15 @@ from collections import Counter
 from datetime import date, timedelta
 from pathlib import Path
 
-
 METRICS_DIR = Path(os.environ.get("XDG_DATA_HOME", Path.home() / ".local/share")) / "usage-metrics"
 
 
 def load_host_data(days: int) -> dict[str, list[dict]]:
-    """Load host metrics for the last N days, grouped by hostname."""
     hosts_dir = METRICS_DIR / "hosts"
     if not hosts_dir.exists():
         return {}
-
     cutoff = date.today() - timedelta(days=days)
     result: dict[str, list[dict]] = {}
-
     for host_dir in hosts_dir.iterdir():
         if not host_dir.is_dir():
             continue
@@ -48,19 +45,15 @@ def load_host_data(days: int) -> dict[str, list[dict]]:
                 result[hostname].append(json.loads(f.read_text()))
             except (json.JSONDecodeError, OSError):
                 continue
-
     return result
 
 
 def load_shared_data(days: int) -> list[dict]:
-    """Load shared metrics for the last N days."""
     shared_dir = METRICS_DIR / "shared"
     if not shared_dir.exists():
         return []
-
     cutoff = date.today() - timedelta(days=days)
     result = []
-
     for f in sorted(shared_dir.iterdir()):
         if not f.name.endswith(".json"):
             continue
@@ -74,12 +67,10 @@ def load_shared_data(days: int) -> list[dict]:
             result.append(json.loads(f.read_text()))
         except (json.JSONDecodeError, OSError):
             continue
-
     return result
 
 
 def aggregate_commands(data_list: list[dict], key: str = "shell") -> Counter:
-    """Aggregate command counts across multiple days."""
     total = Counter()
     for data in data_list:
         section = data.get(key, {})
@@ -90,13 +81,11 @@ def aggregate_commands(data_list: list[dict], key: str = "shell") -> Counter:
 
 
 def aggregate_pi(shared_list: list[dict]) -> dict:
-    """Aggregate pi session data across days."""
     tools = Counter()
     skills = Counter()
     models = Counter()
     providers = Counter()
     total_sessions = 0
-
     for data in shared_list:
         pi = data.get("pi", {})
         total_sessions += pi.get("sessions_count", 0)
@@ -104,15 +93,12 @@ def aggregate_pi(shared_list: list[dict]) -> dict:
         skills.update(pi.get("skills_loaded", {}))
         models.update(pi.get("models_used", {}))
         providers.update(pi.get("providers_used", {}))
-
-    # Compute never-used from aggregate: skills in directory but never loaded
     all_declared = set()
     if shared_list:
         for data in shared_list:
             all_declared.update(data.get("pi", {}).get("skills_never_used", []))
             all_declared.update(data.get("pi", {}).get("skills_loaded", {}).keys())
     never_used = sorted(s for s in all_declared if s not in skills)
-
     return {
         "sessions": total_sessions,
         "tools": tools,
@@ -123,148 +109,404 @@ def aggregate_pi(shared_list: list[dict]) -> dict:
     }
 
 
-def format_counter(counter: Counter, limit: int = 20) -> str:
-    """Format a counter as a readable list."""
+# ── Markdown report ──────────────────────────────────────────────────
+
+def format_counter_md(counter: Counter, limit: int = 20) -> str:
     items = counter.most_common(limit)
     if not items:
         return "  (no data)\n"
+    max_val = items[0][1] if items else 1
     max_name = max(len(name) for name, _ in items)
     lines = []
     for name, count in items:
-        bar = "█" * min(count, 50)
-        lines.append(f"  {name:<{max_name}}  {count:>5}  {bar}")
+        bar_len = max(1, int(50 * count / max_val))
+        bar = "█" * bar_len
+        lines.append(f"  {name:<{max_name}}  {count:>7}  {bar}")
     return "\n".join(lines) + "\n"
 
 
-def generate_report(days: int, format: str = "md") -> str:
-    """Generate the full usage report."""
-    host_data = load_host_data(days)
-    shared_data = load_shared_data(days)
-
-    lines = []
-    lines.append(f"# Usage Report — last {days} days")
-    lines.append(f"Generated: {date.today()}\n")
-
-    # Per-host sections
+def generate_md(days: int, host_data: dict, shared_data: list) -> str:
+    lines = [f"# Usage Report — last {days} days", f"Generated: {date.today()}\n"]
     for hostname, data_list in sorted(host_data.items()):
         if not data_list:
             continue
-
         lines.append(f"## Host: {hostname} ({len(data_list)} days of data)\n")
-
-        # Shell commands
         shell_cmds = aggregate_commands(data_list, "shell")
         total_cmds = sum(shell_cmds.values())
-        lines.append(f"### Shell Commands")
-        lines.append(f"Total: {total_cmds} commands, {len(shell_cmds)} unique\n")
-        lines.append(format_counter(shell_cmds))
-
-        # Process accounting
+        lines.append(f"### Shell Commands\nTotal: {total_cmds} commands, {len(shell_cmds)} unique\n")
+        lines.append(format_counter_md(shell_cmds))
         acct_cmds = aggregate_commands(data_list, "process_accounting")
         if acct_cmds:
-            lines.append(f"### Process Accounting (all exec'd binaries)")
-            lines.append(f"Total: {sum(acct_cmds.values())} executions, {len(acct_cmds)} unique\n")
-            lines.append(format_counter(acct_cmds))
-
-        # Nix packages (from most recent snapshot)
+            lines.append(f"### Process Accounting\nTotal: {sum(acct_cmds.values())} executions, {len(acct_cmds)} unique\n")
+            lines.append(format_counter_md(acct_cmds))
         latest = data_list[-1]
         nix = latest.get("nix_packages", {})
         if isinstance(nix, dict) and "total_bins" in nix:
-            lines.append(f"### Nix Packages")
-            lines.append(f"Installed bins: {nix.get('total_bins', '?')}")
-            lines.append(f"Used (today): {nix.get('used_count', '?')}")
-            lines.append(f"Unused (today): {nix.get('unused_count', '?')}\n")
-
-            # Cross-reference with historical shell usage for better pruning
             all_used = set(shell_cmds.keys()) | set(acct_cmds.keys())
             unused_bins = set(nix.get("unused_bins", [])) - all_used
-            if unused_bins:
-                lines.append(f"**Never used in {days} days** ({len(unused_bins)} bins):")
-                # Show first 30
-                for b in sorted(unused_bins)[:30]:
-                    lines.append(f"  - {b}")
-                if len(unused_bins) > 30:
-                    lines.append(f"  ... and {len(unused_bins) - 30} more")
-                lines.append("")
-
-        # Emacs
+            lines.append(f"### Nix Packages\nInstalled: {nix['total_bins']} | Used: {nix['total_bins'] - len(unused_bins)} | Never used: {len(unused_bins)}\n")
         emacs = latest.get("emacs", {})
         if isinstance(emacs, dict) and "declared_count" in emacs:
-            lines.append(f"### Emacs Packages")
-            lines.append(f"Declared: {emacs.get('declared_count', '?')}")
-            lines.append(f"Loaded features: {emacs.get('loaded_count', '?')}")
             unused_pkgs = emacs.get("unused_packages", [])
-            if unused_pkgs:
-                lines.append(f"Potentially unused ({len(unused_pkgs)}):")
-                for p in sorted(unused_pkgs):
-                    lines.append(f"  - {p}")
-            lines.append("")
-
-        # Custom tools
+            lines.append(f"### Emacs Packages\nDeclared: {emacs['declared_count']} | Unused: {len(unused_pkgs)}\n")
         custom = latest.get("custom_tools", {})
         if isinstance(custom, dict) and "defined" in custom:
-            lines.append(f"### Custom Tools (from pkgs/)")
-            lines.append(f"Defined: {len(custom.get('defined', []))}")
+            used = custom.get("used", [])
+            unused = custom.get("unused", [])
+            lines.append(f"### Custom Tools\nDefined: {len(custom['defined'])} | Used: {len(used)} | Unused: {len(unused)}\n")
+    if shared_data:
+        pi = aggregate_pi(shared_data)
+        lines.append(f"## Pi Agent\nSessions: {pi['sessions']}\n")
+        lines.append("### Tools\n" + format_counter_md(pi["tools"]))
+        lines.append("### Skills\n" + format_counter_md(pi["skills"]))
+        if pi["skills_never_used"]:
+            lines.append(f"### Never Used Skills: {', '.join(pi['skills_never_used'])}\n")
+        lines.append("### Models\n" + format_counter_md(pi["models"]))
+        lines.append("### Providers\n" + format_counter_md(pi["providers"]))
+    return "\n".join(lines)
+
+
+# ── HTML report ──────────────────────────────────────────────────────
+
+HTML_STYLE = """
+:root {
+  --bg: #0d1117; --surface: #161b22; --border: #30363d;
+  --text: #e6edf3; --text-dim: #8b949e; --text-bright: #f0f6fc;
+  --accent: #58a6ff; --green: #3fb950; --red: #f85149;
+  --orange: #d29922; --purple: #bc8cff;
+  --bar-bg: #21262d;
+}
+* { box-sizing: border-box; margin: 0; padding: 0; }
+body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
+  background: var(--bg); color: var(--text); line-height: 1.5; padding: 2rem; max-width: 1200px; margin: 0 auto; }
+h1 { color: var(--text-bright); margin-bottom: 0.5rem; font-size: 1.8rem; }
+h2 { color: var(--accent); margin: 2rem 0 1rem; font-size: 1.4rem; border-bottom: 1px solid var(--border); padding-bottom: 0.5rem; }
+h3 { color: var(--text); margin: 1.5rem 0 0.75rem; font-size: 1.1rem; }
+.subtitle { color: var(--text-dim); margin-bottom: 2rem; }
+.stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1rem; margin: 1rem 0; }
+.stat-card { background: var(--surface); border: 1px solid var(--border); border-radius: 8px; padding: 1rem; }
+.stat-value { font-size: 1.8rem; font-weight: 700; color: var(--text-bright); }
+.stat-label { font-size: 0.85rem; color: var(--text-dim); }
+.stat-value.green { color: var(--green); }
+.stat-value.red { color: var(--red); }
+.stat-value.orange { color: var(--orange); }
+.stat-value.purple { color: var(--purple); }
+.bar-chart { margin: 0.5rem 0; }
+.bar-row { display: flex; align-items: center; margin: 2px 0; font-size: 0.85rem; font-family: 'SF Mono', 'Fira Code', monospace; }
+.bar-name { width: 220px; text-align: right; padding-right: 12px; color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; flex-shrink: 0; }
+.bar-track { flex: 1; height: 20px; background: var(--bar-bg); border-radius: 3px; overflow: hidden; border: 1px solid var(--border); }
+.bar-fill { display: block; height: 100%; min-width: 2px; }
+.bar-fill.blue { background: var(--accent); }
+.bar-fill.green { background: var(--green); }
+.bar-fill.purple { background: var(--purple); }
+.bar-fill.orange { background: var(--orange); }
+.bar-count { width: 70px; text-align: right; padding-left: 8px; color: var(--text-dim); flex-shrink: 0; }
+details { background: var(--surface); border: 1px solid var(--border); border-radius: 8px; margin: 0.75rem 0; }
+details > summary { padding: 0.75rem 1rem; cursor: pointer; font-weight: 600; color: var(--text); user-select: none; }
+details > summary:hover { color: var(--accent); }
+details > .content { padding: 0 1rem 1rem; }
+.tag-list { display: flex; flex-wrap: wrap; gap: 0.4rem; margin: 0.5rem 0; }
+.tag { background: var(--bar-bg); border: 1px solid var(--border); border-radius: 4px; padding: 2px 8px; font-size: 0.8rem; color: var(--text-dim); }
+.tag.unused { border-color: var(--red); color: var(--red); opacity: 0.8; }
+.tag.used { border-color: var(--green); color: var(--green); }
+.tabs { display: flex; gap: 0; border-bottom: 1px solid var(--border); margin: 1.5rem 0 1rem; }
+.tab { padding: 0.5rem 1.2rem; cursor: pointer; color: var(--text-dim); border-bottom: 2px solid transparent; font-weight: 500; }
+.tab:hover { color: var(--text); }
+.tab.active { color: var(--accent); border-bottom-color: var(--accent); }
+.tab-content { display: none; }
+.tab-content.active { display: block; }
+.services-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 0.3rem; margin: 0.5rem 0; }
+.service-item { font-size: 0.8rem; color: var(--text-dim); font-family: monospace; }
+"""
+
+HTML_SCRIPT = """
+function switchTab(group, tabName) {
+  document.querySelectorAll(`[data-tab-group="${group}"] .tab`).forEach(t => t.classList.remove('active'));
+  document.querySelectorAll(`[data-tab-content="${group}"]`).forEach(c => c.classList.remove('active'));
+  document.querySelector(`[data-tab-group="${group}"] [data-tab="${tabName}"]`).classList.add('active');
+  document.querySelector(`[data-tab-content="${group}"][data-tab="${tabName}"]`).classList.add('active');
+}
+"""
+
+
+def h(text: str) -> str:
+    return html.escape(str(text))
+
+
+def html_bar_chart(counter: Counter, limit: int = 20, color: str = "blue", show_all: bool = False) -> str:
+    items = counter.most_common()
+    if not items:
+        return '<div class="bar-chart"><em style="color:var(--text-dim)">No data</em></div>'
+    max_val = items[0][1]
+    top_items = items[:limit]
+    rest_items = items[limit:]
+
+    def render_rows(rows):
+        out = []
+        for name, count in rows:
+            pct = max(0.5, 100 * count / max_val) if max_val else 0
+            out.append(f'''<div class="bar-row">
+  <span class="bar-name" title="{h(name)}">{h(name)}</span>
+  <span class="bar-track"><span class="bar-fill {color}" style="width:{pct:.1f}%"></span></span>
+  <span class="bar-count">{count:,}</span>
+</div>''')
+        return "\n".join(out)
+
+    result = f'<div class="bar-chart">{render_rows(top_items)}</div>'
+
+    if rest_items:
+        result += f'''<details><summary>Show all ({len(items)} total)</summary>
+<div class="content"><div class="bar-chart">{render_rows(items)}</div></div></details>'''
+
+    return result
+
+
+def html_stat_card(value, label: str, color: str = "") -> str:
+    cls = f" {color}" if color else ""
+    return f'<div class="stat-card"><div class="stat-value{cls}">{h(str(value))}</div><div class="stat-label">{h(label)}</div></div>'
+
+
+def html_tag_list(items: list[str], css_class: str = "") -> str:
+    cls = f" {css_class}" if css_class else ""
+    return '<div class="tag-list">' + "".join(f'<span class="tag{cls}">{h(i)}</span>' for i in sorted(items)) + '</div>'
+
+
+def generate_html(days: int, host_data: dict, shared_data: list) -> str:
+    parts = []
+    parts.append(f'''<!DOCTYPE html><html lang="en"><head><meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Usage Report — last {days} days</title>
+<style>{HTML_STYLE}</style></head><body>
+<h1>Usage Report</h1>
+<p class="subtitle">Last {days} days · Generated {date.today()}</p>''')
+
+    # ── Aggregate cross-host view ──
+    all_shell = Counter()
+    all_acct = Counter()
+    all_nix_total = 0
+    all_nix_unused = set()
+    all_custom_defined = set()
+    all_custom_used = set()
+    all_services = set()
+    host_count = 0
+
+    for hostname, data_list in host_data.items():
+        if not data_list:
+            continue
+        host_count += 1
+        all_shell += aggregate_commands(data_list, "shell")
+        all_acct += aggregate_commands(data_list, "process_accounting")
+        latest = data_list[-1]
+        nix = latest.get("nix_packages", {})
+        if isinstance(nix, dict) and "total_bins" in nix:
+            all_nix_total = max(all_nix_total, nix.get("total_bins", 0))
+            unused_set = set(nix.get("unused_bins", [])) - set(all_shell.keys()) - set(all_acct.keys())
+            all_nix_unused |= unused_set
+        custom = latest.get("custom_tools", {})
+        if isinstance(custom, dict):
+            all_custom_defined.update(custom.get("defined", []))
+            all_custom_used.update(custom.get("used", []))
+        services = latest.get("services", {})
+        if isinstance(services, dict):
+            all_services.update(services.get("running", []))
+
+    pi = aggregate_pi(shared_data) if shared_data else None
+
+    # ── Overview cards ──
+    parts.append('<h2>Overview</h2>')
+    parts.append('<div class="stats-grid">')
+    parts.append(html_stat_card(host_count, "Hosts reporting"))
+    parts.append(html_stat_card(f"{sum(all_shell.values()):,}", "Shell commands"))
+    parts.append(html_stat_card(len(all_shell), "Unique commands"))
+    parts.append(html_stat_card(f"{sum(all_acct.values()):,}", "Process executions"))
+    if pi:
+        parts.append(html_stat_card(f"{pi['sessions']:,}", "Pi sessions", "purple"))
+    parts.append(html_stat_card(len(all_custom_used), f"Custom tools used", "green"))
+    parts.append(html_stat_card(len(all_custom_defined - all_custom_used), f"Custom tools unused", "red"))
+    parts.append('</div>')
+
+    # ── Tabs: All Hosts / per-host ──
+    tab_names = ["all"] + sorted(host_data.keys())
+    parts.append('<div class="tabs" data-tab-group="hosts">')
+    parts.append(f'<div class="tab active" data-tab="all" onclick="switchTab(\'hosts\',\'all\')">All Hosts</div>')
+    for hn in sorted(host_data.keys()):
+        parts.append(f'<div class="tab" data-tab="{h(hn)}" onclick="switchTab(\'hosts\',\'{h(hn)}\')">{h(hn)}</div>')
+    parts.append('</div>')
+
+    # ── All Hosts tab ──
+    parts.append('<div class="tab-content active" data-tab-content="hosts" data-tab="all">')
+    parts.append('<h3>Shell Commands (all hosts)</h3>')
+    parts.append(html_bar_chart(all_shell, 25, "blue"))
+    if all_acct:
+        parts.append('<h3>Process Accounting (all hosts)</h3>')
+        parts.append(html_bar_chart(all_acct, 25, "green"))
+
+    # Nix packages
+    all_used_bins = set(all_shell.keys()) | set(all_acct.keys())
+    nix_used_count = all_nix_total - len(all_nix_unused) if all_nix_total else 0
+    parts.append('<h3>Nix Packages</h3>')
+    parts.append('<div class="stats-grid">')
+    parts.append(html_stat_card(all_nix_total, "Installed bins"))
+    parts.append(html_stat_card(nix_used_count, "Used (ever)", "green"))
+    parts.append(html_stat_card(len(all_nix_unused), "Never used", "orange"))
+    parts.append('</div>')
+    if all_nix_unused:
+        parts.append(f'<details><summary>Never used bins ({len(all_nix_unused)})</summary><div class="content">')
+        parts.append(html_tag_list(sorted(all_nix_unused), "unused"))
+        parts.append('</div></details>')
+
+    # Custom tools
+    parts.append('<h3>Custom Tools</h3>')
+    if all_custom_used:
+        parts.append(f'<p style="margin:0.5rem 0"><strong>Used:</strong></p>')
+        parts.append(html_tag_list(sorted(all_custom_used), "used"))
+    unused_custom = all_custom_defined - all_custom_used
+    if unused_custom:
+        parts.append(f'<p style="margin:0.5rem 0"><strong>Unused:</strong></p>')
+        parts.append(html_tag_list(sorted(unused_custom), "unused"))
+
+    # Emacs (from any host with data)
+    for hostname, data_list in sorted(host_data.items()):
+        if not data_list:
+            continue
+        emacs = data_list[-1].get("emacs", {})
+        if isinstance(emacs, dict) and emacs.get("declared_count", 0) > 0:
+            unused_pkgs = emacs.get("unused_packages", [])
+            parts.append('<h3>Emacs Packages</h3>')
+            parts.append('<div class="stats-grid">')
+            parts.append(html_stat_card(emacs["declared_count"], "Declared"))
+            parts.append(html_stat_card(emacs.get("loaded_count", 0), "Loaded features", "green"))
+            parts.append(html_stat_card(len(unused_pkgs), "Potentially unused", "orange"))
+            parts.append('</div>')
+            if unused_pkgs:
+                parts.append(f'<details><summary>Potentially unused packages ({len(unused_pkgs)})</summary><div class="content">')
+                parts.append(html_tag_list(unused_pkgs, "unused"))
+                parts.append('</div></details>')
+            cmd_freq = emacs.get("command_frequency", {})
+            if cmd_freq:
+                parts.append('<details><summary>Emacs command frequency</summary><div class="content">')
+                parts.append(html_bar_chart(Counter(cmd_freq), 30, "purple"))
+                parts.append('</div></details>')
+            break  # Same emacs config across hosts
+
+    # Services
+    parts.append('<h3>System Services</h3>')
+    parts.append(f'<details><summary>{len(all_services)} unique services across all hosts</summary><div class="content">')
+    parts.append('<div class="services-grid">')
+    for s in sorted(all_services):
+        parts.append(f'<div class="service-item">{h(s)}</div>')
+    parts.append('</div></div></details>')
+
+    parts.append('</div>')  # end all-hosts tab
+
+    # ── Per-host tabs ──
+    for hostname, data_list in sorted(host_data.items()):
+        if not data_list:
+            continue
+        parts.append(f'<div class="tab-content" data-tab-content="hosts" data-tab="{h(hostname)}">')
+        parts.append(f'<h3>{h(hostname)} — {len(data_list)} days of data</h3>')
+
+        shell_cmds = aggregate_commands(data_list, "shell")
+        parts.append(f'<h3>Shell Commands ({sum(shell_cmds.values()):,} total, {len(shell_cmds)} unique)</h3>')
+        parts.append(html_bar_chart(shell_cmds, 25, "blue"))
+
+        acct_cmds = aggregate_commands(data_list, "process_accounting")
+        if acct_cmds:
+            parts.append(f'<h3>Process Accounting ({sum(acct_cmds.values()):,} execs, {len(acct_cmds)} unique)</h3>')
+            parts.append(html_bar_chart(acct_cmds, 25, "green"))
+
+        latest = data_list[-1]
+        nix = latest.get("nix_packages", {})
+        if isinstance(nix, dict) and "total_bins" in nix:
+            host_used = set(shell_cmds.keys()) | set(acct_cmds.keys())
+            host_unused = set(nix.get("unused_bins", [])) - host_used
+            parts.append('<h3>Nix Packages</h3>')
+            parts.append('<div class="stats-grid">')
+            parts.append(html_stat_card(nix["total_bins"], "Installed"))
+            parts.append(html_stat_card(nix["total_bins"] - len(host_unused), "Used", "green"))
+            parts.append(html_stat_card(len(host_unused), "Never used", "orange"))
+            parts.append('</div>')
+            if host_unused:
+                parts.append(f'<details><summary>Never used ({len(host_unused)})</summary><div class="content">')
+                parts.append(html_tag_list(sorted(host_unused), "unused"))
+                parts.append('</div></details>')
+
+        custom = latest.get("custom_tools", {})
+        if isinstance(custom, dict) and "defined" in custom:
+            parts.append('<h3>Custom Tools</h3>')
             used = custom.get("used", [])
             unused = custom.get("unused", [])
             if used:
-                lines.append(f"Used: {', '.join(sorted(used))}")
+                parts.append(html_tag_list(sorted(used), "used"))
             if unused:
-                lines.append(f"Unused: {', '.join(sorted(unused))}")
-            lines.append("")
+                parts.append(html_tag_list(sorted(unused), "unused"))
 
-        # Services
         services = latest.get("services", {})
         if isinstance(services, dict) and "running" in services:
-            lines.append(f"### System Services")
-            lines.append(f"Running: {services.get('total', '?')} services")
-            for s in services.get("running", []):
-                lines.append(f"  - {s}")
-            lines.append("")
+            running = services.get("running", [])
+            parts.append(f'<details><summary>Services ({len(running)} running)</summary><div class="content">')
+            parts.append('<div class="services-grid">')
+            for s in running:
+                parts.append(f'<div class="service-item">{h(s)}</div>')
+            parts.append('</div></div></details>')
 
-    # Shared / Pi section
-    if shared_data:
-        pi = aggregate_pi(shared_data)
-        lines.append(f"## Pi Agent (across all hosts)\n")
-        lines.append(f"Total sessions: {pi['sessions']}\n")
+        parts.append('</div>')  # end host tab
 
-        lines.append("### Tools Used")
-        lines.append(format_counter(pi["tools"]))
+    # ── Pi Agent section ──
+    if pi:
+        parts.append('<h2>Pi Agent</h2>')
+        parts.append('<div class="stats-grid">')
+        parts.append(html_stat_card(f"{pi['sessions']:,}", "Sessions"))
+        parts.append(html_stat_card(len(pi['tools']), "Unique tools"))
+        parts.append(html_stat_card(len(pi['skills']), "Skills used"))
+        parts.append(html_stat_card(len(pi['skills_never_used']), "Skills never used", "orange"))
+        parts.append(html_stat_card(len(pi['models']), "Models used"))
+        parts.append(html_stat_card(len(pi['providers']), "Providers"))
+        parts.append('</div>')
 
-        lines.append("### Skills Loaded")
-        lines.append(format_counter(pi["skills"]))
+        parts.append('<h3>Tools</h3>')
+        parts.append(html_bar_chart(pi["tools"], 20, "blue"))
 
+        parts.append('<h3>Skills</h3>')
+        parts.append(html_bar_chart(pi["skills"], 20, "purple"))
         if pi["skills_never_used"]:
-            lines.append(f"### Skills Never Used ({len(pi['skills_never_used'])})")
-            for s in pi["skills_never_used"]:
-                lines.append(f"  - {s}")
-            lines.append("")
+            parts.append(f'<p style="margin:0.5rem 0;color:var(--text-dim)">Never used:</p>')
+            parts.append(html_tag_list(pi["skills_never_used"], "unused"))
 
-        lines.append("### Models Used")
-        lines.append(format_counter(pi["models"]))
+        parts.append('<h3>Models</h3>')
+        parts.append(html_bar_chart(pi["models"], 20, "orange"))
 
-        lines.append("### Providers")
-        lines.append(format_counter(pi["providers"]))
+        parts.append('<h3>Providers</h3>')
+        parts.append(html_bar_chart(pi["providers"], 15, "green"))
 
-    report = "\n".join(lines)
+    parts.append(f'<script>{HTML_SCRIPT}</script></body></html>')
+    return "\n".join(parts)
 
-    if format == "json":
-        # Re-export as structured JSON for LLM consumption
+
+# ── Main ──────────────────────────────────────────────────────────────
+
+def generate_report(days: int, fmt: str) -> str:
+    host_data = load_host_data(days)
+    shared_data = load_shared_data(days)
+    if fmt == "html":
+        return generate_html(days, host_data, shared_data)
+    elif fmt == "json":
         return json.dumps({
             "period_days": days,
             "generated": str(date.today()),
             "hosts": {h: d for h, d in host_data.items()},
             "shared": shared_data,
         }, indent=2, default=str)
-
-    return report
+    else:
+        return generate_md(days, host_data, shared_data)
 
 
 def main():
     parser = argparse.ArgumentParser(description="Generate usage report")
     parser.add_argument("--days", type=int, default=30, help="Number of days to include (default: 30)")
-    parser.add_argument("--format", choices=["md", "json"], default="md", help="Output format")
+    parser.add_argument("--format", choices=["md", "html", "json"], default="md", help="Output format")
     parser.add_argument("--output", "-o", type=str, help="Write to file instead of stdout")
+    parser.add_argument("--open", action="store_true", help="Open HTML report in browser")
     args = parser.parse_args()
 
     report = generate_report(args.days, args.format)
@@ -272,6 +514,13 @@ def main():
     if args.output:
         Path(args.output).write_text(report)
         print(f"Report written to {args.output}", file=sys.stderr)
+    elif args.open and args.format == "html":
+        import subprocess
+        import tempfile
+        tmp = Path(tempfile.mktemp(suffix=".html", prefix="usage-report-"))
+        tmp.write_text(report)
+        print(f"Report written to {tmp}", file=sys.stderr)
+        subprocess.run(["xdg-open", str(tmp)])
     else:
         print(report)