fuzzyworld

fuzzyworld-tools / commit

Usage: line charts, site-wide /usage/ page, Usage in the top nav (Q107 stages 2 and 3)

799905aed0511f8af9f239d6e339d282a7d72f40 · Pavel Safronov · 2026-11-04 09:20:14 -0800 · parents ced0eafa4c73

Claude Code usage: this commit 94.5K out / 11.5M cached · est. $4.03 (API list-price equivalent at 2026-10-22 rates, not what was paid) · session 13 tally 534 in / 195.5K out / 22.1M cached · usage by commit and session

Usage: line charts, site-wide /usage/ page, Usage in the top nav (Q107 stages 2 and 3)

- Per-repo Usage page: five line charts (output tokens, estimated cost, cached
  reads, cost per commit, AI-marked commits), unknown periods break the line and
  are hatched, never zero; header as a fact list; Sessions table cost column.
- /usage/: the same charts across public published repos plus a per-repo table;
  per-repo summaries cached in STATE/<name>.usage.json by HEAD sha; reserved
  top-level names cannot be repos; "Usage" added to the site-wide nav.
- Cost is always labelled an estimated API list-price equivalent, not what was paid.
- Tests: 498 pass (31 skipped); README and PLAN.md updated (Q108 admin-on-live-site backlog item).

FW-Tokens-In: 534
FW-Tokens-Out: 195501
FW-Tokens-Cached: 22105620
FW-Tokens-Method: 2
FW-Model-Tokens: claude-opus-5-5 in=152 out=56234 read=2802135 write5m=223613 write1h=0
FW-Model-Tokens: claude-sonnet-5-5 in=382 out=139267 read=19303485 write5m=412376 write1h=201205
FW-Session: 34cf73f9-fac0-43be-9dff-7c3ded7471ce
FW-Rtk-Saved: 4629597
FW-Rtk-Cmds: 14031

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
 PLAN.md               |  22 +++-
 README.md             |  13 +-
 server/gen.py         | 349 ++++++++++++++++++++++++++++++++++++++++++--------
 server/site/style.css |  57 ++++++++-
 tests/test_gen.py     | 294 ++++++++++++++++++++++++++++++++++++++++--
 5 files changed, 664 insertions(+), 71 deletions(-)

diff --git a/PLAN.md b/PLAN.md
index f5d10d7..7ef68de 100644
--- a/PLAN.md
+++ b/PLAN.md
@@ -215,10 +215,13 @@ he wants; see the tiers in the README, form = T2, email = T1). The form stays in
   estimated cost from `server/prices.json` (list prices, `as_of`; unknown stays unknown); one weekly "output tokens"
   SVG chart on the Usage tab with a hatched strip for commits without data. Old method-1 trailers are left alone
   (they ran about 1.5-2x high).
-  Remaining stages: (2) more per-repo charts: cost per week, cached reads per week, cost per commit (weekly average),
-  AI-marked commits per week (stacked), and an est. cost column in the Sessions table; (3) a site-wide `/usage/`
-  roll-up in `build_index` over public published repos, with a per-repo table; (4) optional: lines added/removed per
-  week and usage per added line.
+  **Q107 stages 2 and 3 (done 2026-11-04):** the Usage tab also charts estimated cost, cached reads, average cost per
+  commit and AI-marked commits (two lines) per week or month, and the Sessions table has an est. cost column; the
+  site-wide `/usage/` page (`build_site_usage`, called from `build_index`, public published repos only) has the same
+  charts plus a per-repo table (unknown costs are hatched, never zero; the page is rebuilt on every `fw publish`
+  from a per-repo summary cached in the state dir by HEAD sha). Reserved top-level names (`usage`, `ci`, `projects`,
+  ...: `SITE_RESERVED` in `server/gen.py`) are never treated as repos by the index or the roll-up.
+  Remaining: (4) optional: lines added/removed per week and usage per added line.
 - File finder and code search (one shipped script plus a prebuilt index per repo)
 - code-server on TrueNAS, for editing in the browser (private, over Tailscale)
 - Provenance stats and transcript publishing (see below)
@@ -440,6 +443,17 @@ ssh that Pavel would type, so the panel holds no state and has no authority beyo
   record); a machine-readable `fw comments list`.
 - **Later, each useful alone:** pull requests (after contrib intake), CI and runners, blog build/deploy and watcher
   state (Q100), "make this report a regression fixture" (writes `test/fixtures/` in the app's repo).
+- **Backlog (Q108, Pavel 2026-11-04): the admin page on the live site, working with the local `fw admin`.** A page on
+  code.fuzzyworld.net checks whether the visitor's machine runs `fw admin` (a probe of `http://127.0.0.1:<port>`) and,
+  if so, shows the admin UI there while all data and actions go browser to localhost; the live site never sees
+  reports, addresses or tokens (the hosted page is a static shell). Feasible, with conditions: (1) it needs JavaScript
+  on that one page (allowed by the "local JS is OK" policy, nothing else on the site changes); (2) `fw admin` gains a
+  JSON API with CORS for exactly the live origin, a bearer token (the current cookie login does not work
+  cross-site), strict Origin / Host checks and the Private Network Access preflight header; (3) browsers differ:
+  Chrome and Firefox allow an https page to call 127.0.0.1, newer Chrome asks the user once for "local network
+  access", Safari may block it as mixed content, so test all three and keep the local page (`fw admin` as today) as
+  the fallback; (4) the token reaches the live page through a link `fw admin` prints (token in the URL fragment,
+  which is never sent to a server), kept in `sessionStorage`. Not started.
 
 ## Pull requests
 
diff --git a/README.md b/README.md
index a93eaaf..32cb444 100644
--- a/README.md
+++ b/README.md
@@ -158,6 +158,7 @@ Every repo uses the shared hook via `core.hooksPath=~/fw/tools/server/hooks`. `p
 | `/<name>/commit/<sha>.html` | one commit with its diff (truncated at 300 KB); a page for every commit on every branch, up to 2000 per repo |
 | `/<name>/refs/` | branches and tags |
 | `/<name>/usage/` | Claude Code token usage per commit and per session, read from `Usage:` trailers in commit messages (only for repos that have them; see below) |
+| `/usage/` | (not a repo name) Claude Code usage summed over the public published repos: the same charts as a repo's Usage tab (output tokens, estimated cost, cached reads, cost per commit, AI-marked commits, per week or month) plus a table with one row per repo, written by `build_index` |
 | `/<name>/branch/<branch>/...` | the same tree/blob/history/blame/log pages for another branch; `/` in a branch name is written `~` |
 | `/<name>/ci/`, `/<name>/ci/<sha>.html` | CI pipelines (newest 100) and one pipeline with its job logs |
 | `/<name>/ci/badge.svg` | status of the latest pipeline on the default branch |
@@ -182,8 +183,16 @@ A commit's cost is the step from the previous tally; a new session starts when a
 falls or the method changes. Dollar figures are estimates: `server/prices.json` holds the list prices per model
 (with an `as_of` date) and the page says "estimated API list-price equivalent, not what was paid". The log shows a
 bar of output tokens per commit, highlighted at 2× the median or more; each commit page shows its step, estimated
-cost and the session tally; `/<name>/usage/` ranks commits, totals sessions and charts output tokens per week
-(hatched = commits without usage data, unknown rather than zero). Steps between old-style lines are only known
+cost and the session tally; `/<name>/usage/` ranks commits, totals sessions (with an estimated cost per session,
+"unknown" unless every commit in it has one) and charts, per week (per month for histories over 104 weeks): output
+tokens, estimated cost, cached reads, average cost per commit (the tooltip gives n) and AI-marked commits (two lines: all commits and AI-marked ones; in the token and cost charts hatched = commits without usage data or without a known cost, unknown
+rather than zero; the AI-marked chart has no hatching). "AI-marked"
+means an AI `Co-Authored-By` trailer or a usage trailer; a commit without one is "no AI marker", not "human"
+(see Provenance in PLAN.md). `/usage/` rolls the same up over the public published repos (charts start at the first AI-marked commit; dates before
+2000 or after tomorrow are ignored), with a table with one row per repo; a repo's cost shows "(n of m commits)" when
+only some commits have a known cost. It is rebuilt on every `fw publish`, from a small per-repo summary that
+`build_repo` saves in the state dir (`<name>.usage.json`, keyed by the repo's HEAD), so it does not re-run `git log` for
+every repo. Like `/ci/` and the other site pages, `/usage/` cannot be a repo name (such repos are skipped). Steps between old-style lines are only known
 to within the rounding (about two tenths of K/M/G), so small ones show as ≈0; commits without a trailer show no bar.
 
 **License:** the summary page names the repo's license, read from a root `LICENSE`/`LICENCE`/`COPYING` file
diff --git a/server/gen.py b/server/gen.py
index a314c01..eb2d4c2 100755
--- a/server/gen.py
+++ b/server/gen.py
@@ -159,6 +159,7 @@ HOME_URL = "https://fuzzyworld.net"
 NAV = [("code", "Code", "code", "/"), ("apps", "Apps", "apps", "/"), ("writing", "Writing", "writing", "/"),
        ("projects", "Projects", "code", "/projects/"),
        ("my-ai-use", "Agentic", "code", "/my-ai-use/"), ("ci", "CI", "code", "/ci/"),
+       ("usage", "Usage", "code", "/usage/"),
        ("contribute", "Contribute", "code", "/contribute/")]
 WRITING_URL = "https://writing.fuzzyworld.net"   # a static site built by `fw blog` (blog.py), not by this file
 SITE_URLS = {"code": BASE_URL, "apps": APPS_URL, "writing": WRITING_URL}
@@ -1128,44 +1129,144 @@ def bucket_unit(buckets):
     return "month" if buckets and buckets[0]["label"].startswith("month") else "week"
 
 
-def bar_chart_svg(labels, values, *, fmt, unknown, highlight=None, stacked=None, aria, axis_fmt=fmt_tokens, pattern_id="hatch"):
-    """An inline SVG bar chart without script. labels: one text per bucket, ending in its date (the axis shows
-    the first and last); values: bar heights; fmt(value) -> text for the tooltip; unknown: per bucket
-    (commits lacking data, commits), drawn as a hatched strip under the bar since unknown is not zero;
-    highlight: bucket indexes drawn with the hot colour; stacked: optional per-bucket part of the value
-    drawn in a second colour at the bottom of the bar; axis_fmt formats the top axis label; pattern_id keeps the hatch pattern's id unique when a page has several charts. Colours come from CSS classes only."""
+def line_chart_svg(labels, values, *, fmt, unknown, second=None, legend=None, aria, axis_fmt=fmt_tokens,
+                   pattern_id="hatch", tips=None, hatch_note="hatched = commits without usage data (unknown, not zero)"):
+    """An inline SVG line chart without script. labels: one text per bucket, ending in its date (the axis shows
+    the first and last); values: one number per bucket, or None where the value is unknown: the line breaks
+    there (a gap), it never drops to zero, and a single isolated point is only a dot; fmt(value) -> text for the
+    tooltip; unknown: per bucket (commits lacking data, commits), drawn as a hatched strip under the axis;
+    second: optional second series (drawn in the accent colour, the first then grey); legend: [(text, "muted"|"")]
+    drawn under the chart; axis_fmt formats the top axis label; pattern_id keeps the hatch pattern's id unique when
+    a page has several charts; tips: optional full tooltip text per bucket instead of the default; hatch_note: the
+    line under the axis ("" for none). Every bucket has a transparent hover target with a <title>, every known
+    point a dot with the same <title>. Colours come from CSS classes only."""
+    if not labels:
+        return ""
     left, right, top, base = 44, 716, 12, 140
     n = len(values)
-    vmax = max(values, default=0) or 1
+    known = [v for v in list(values) + list(second or []) if v is not None]
+    vmax = max(known, default=0) or 1
     step = (right - left) / max(n, 1)
-    bw = max(step - 1, 1)
-    parts = []
+    x = lambda i: left + (i + 0.5) * step
+    y = lambda v: base - (base - top) * v / vmax
+    tip_of = []
     for i, v in enumerate(values):
-        x = left + i * step
         bad, total = unknown[i]
-        tip = f"{labels[i]}: {fmt(v)}" + (f", {total - bad} of {total} commits with data" if total else ", no commits")
-        h = (base - top) * v / vmax
-        cls = "bar hot" if highlight and i in highlight else "bar"
-        inner = f"<title>{esc(tip)}</title>"
-        parts.append(f'<rect class="hit" x="{x:.1f}" y="{top}" width="{bw:.1f}" height="{base - top}">{inner}</rect>')
-        parts.append(f'<rect class="{cls}" x="{x:.1f}" y="{base - h:.1f}" width="{bw:.1f}" height="{h:.1f}">{inner}</rect>')
-        if stacked is not None and stacked[i]:
-            h2 = (base - top) * min(stacked[i], v) / vmax
-            parts.append(f'<rect class="bar2" x="{x:.1f}" y="{base - h2:.1f}" width="{bw:.1f}" height="{h2:.1f}">{inner}</rect>')
-        if bad:
-            parts.append(f'<rect class="unk" x="{x:.1f}" y="{base + 4}" width="{bw:.1f}" height="6" fill="url(#{pattern_id})">{inner}</rect>')
+        tip_of.append(tips[i] if tips else (
+            f"{labels[i]}: " + ("no data" if v is None else fmt(v))
+            + (f", {total - bad} of {total} commits with data" if total else ", no commits")))
+    hits = "".join(f'<rect class="hit" x="{left + i * step:.1f}" y="{top}" width="{step:.1f}" height="{base - top}">'
+                   f'<title>{esc(tip_of[i])}</title></rect>' for i in range(n))
+
+    def series(vals, muted):
+        cls = " muted" if muted else ""
+        segments, cur = [], []
+        for i, v in enumerate(vals):
+            if v is None:
+                if cur:
+                    segments.append(cur)
+                cur = []
+            else:
+                cur.append((i, v))
+        if cur:
+            segments.append(cur)
+        out = []
+        for seg in segments:
+            if len(seg) > 1:
+                pts = " L".join(f"{x(i):.1f} {y(v):.1f}" for i, v in seg)
+                out.append(f'<path class="area{cls}" d="M{x(seg[0][0]):.1f} {base} L{pts} L{x(seg[-1][0]):.1f} {base} Z"/>')
+                out.append(f'<path class="line{cls}" d="M{pts}"/>')
+        out += [f'<circle class="dot{cls}" cx="{x(i):.1f}" cy="{y(v):.1f}" r="3"><title>{esc(tip_of[i])}</title></circle>'
+                for seg in segments for i, v in seg]
+        return "".join(out)
+
+    parts = [series(values, second is not None)] + ([series(second, False)] if second is not None else [])
+    bad_strips = "".join(
+        f'<rect class="unk" x="{left + i * step:.1f}" y="{base + 4}" width="{max(step - 1, 1):.1f}" height="6" '
+        f'fill="url(#{pattern_id})"><title>{esc(tip_of[i])}</title></rect>' for i in range(n) if unknown[i][0])
     first, last = labels[0].rsplit(" ", 1)[-1], labels[-1].rsplit(" ", 1)[-1]
+    key = ""
+    for j, (text, cls) in enumerate(legend or []):
+        kx = left + j * 260
+        key += (f'<line class="line {cls}" x1="{kx}" y1="189" x2="{kx + 28}" y2="189"/>'
+                f'<text x="{kx + 34}" y="194" text-anchor="start">{esc(text)}</text>')
     return (f'<svg class="chart" viewBox="0 0 720 200" role="img" aria-label="{esc(aria)}">'
             f'<defs><pattern id="{pattern_id}" width="6" height="6" patternUnits="userSpaceOnUse">'
             '<path class="hatchline" d="M0 6L6 0"/></pattern></defs>'
             f'<line class="axis" x1="{left}" y1="{base}" x2="{right}" y2="{base}"/>'
-            + "".join(parts)
+            + "".join(parts) + bad_strips + hits
             + f'<text x="{left - 4}" y="{base}" text-anchor="end">0</text>'
             f'<text x="{left - 4}" y="{top + 8}" text-anchor="end">{esc(axis_fmt(vmax))}</text>'
             f'<text x="{left}" y="{base + 28}" text-anchor="start">{esc(first)}</text>'
             f'<text x="{right}" y="{base + 28}" text-anchor="end">{esc(last)}</text>'
-            f'<text x="{left}" y="194" text-anchor="start">hatched = commits without usage data (unknown, not zero)</text>'
-            '</svg>')
+            + (f'<text x="{left}" y="194" text-anchor="start">{esc(hatch_note)}</text>' if hatch_note else "")
+            + key + '</svg>')
+
+
+COST_NOTE = "estimated API list-price equivalent, not what was paid"
+
+
+def usage_chart(title, caption, labels, values, *, pattern_id, empty, **kw):
+    """One chart section: heading, one-line caption, and the SVG; or `empty` as a message when no bucket has a
+    known value, so a chart never shows an empty axis. A value of None means unknown (a gap in the line); a known
+    0 is a real value and still gets a chart. The one function every Usage chart goes through."""
+    head = f"<h2>{esc(title)}</h2><p class=\"meta\">{caption}</p>"
+    if not labels or all(v is None for v in values):
+        return head + f'<p class="meta">{esc(empty)}</p>'
+    return head + line_chart_svg(labels, values, pattern_id=pattern_id, **kw)
+
+
+def usage_charts(buckets, prefix):
+    """The Usage chart sections (output tokens, est. cost, cached reads, cost per commit, AI-marked commits) for
+    `buckets` from week_buckets, as HTML. `prefix` keeps the SVG pattern ids unique per page."""
+    unit = bucket_unit(buckets)
+    labels = [b["label"] for b in buckets]
+    nodata = [(b["nodata"], b["commits"]) for b in buckets]
+    nocost = [(b["commits"] - b["with_cost"], b["commits"]) for b in buckets]
+    money = lambda v: f"${v:,.2f}"
+    cost_note = "hatched = commits without a known cost (unknown, not zero)"
+    per_commit = [b["cost"] / b["with_cost"] if b["with_cost"] else None for b in buckets]
+    known = lambda b, key, n: b[key] if b[n] else None      # None = no commit has the data: unknown, not zero
+    out = usage_chart(
+        f"Output tokens per {unit}", f"Output tokens of commits with usage trailers; the main measure of work done.",
+        labels, [known(b, "out", "with_tokens") for b in buckets], pattern_id=prefix + "-out", empty="No output tokens recorded yet.",
+        fmt=lambda v: f"{fmt_tokens(v)} output tokens", unknown=nodata,
+        aria=f"Output tokens per {unit}, from commits with usage trailers; hatched marks commits without usage data")
+    cost = usage_chart(
+        f"Estimated cost per {unit}", f"Sum of known per-commit costs: {COST_NOTE}. Commits without a known cost are "
+        "left out, not counted as zero.", labels, [known(b, "cost", "with_cost") for b in buckets], pattern_id=prefix + "-cost",
+        empty="No commit has a known cost yet (cost unknown, not zero).", fmt=lambda v: f"est. {money(v)} ({COST_NOTE})",
+        tips=[f"{b['label']}: est. {money(b['cost'])} ({COST_NOTE}), {b['with_cost']} of {b['commits']} commits with a known cost"
+              if b["with_cost"] else f"{b['label']}: " + ("cost unknown, 0 commits with a known cost" if b["commits"] else "no commits")
+              for b in buckets],
+        unknown=nocost, axis_fmt=money, hatch_note=cost_note,
+        aria=f"Estimated cost per {unit}, {COST_NOTE}; hatched marks commits whose cost is unknown")
+    cached = usage_chart(
+        f"Cached reads per {unit}", "Tokens read from the prompt cache. They grow with context length and turns, so "
+        "they track session length more than work done.", labels, [known(b, "read", "with_tokens") for b in buckets],
+        pattern_id=prefix + "-read", empty="No cached reads recorded yet.",
+        fmt=lambda v: f"{fmt_tokens(v)} cached reads", unknown=nodata,
+        aria=f"Cached reads per {unit}, from commits with usage trailers; hatched marks commits without usage data")
+    tips = [f"{b['label']}: est. {money(v)} per commit ({COST_NOTE}), n={plural(b['with_cost'], 'commit')} with a known cost"
+            if b["with_cost"] else f"{b['label']}: " + ("cost unknown, n=0 commits with a known cost" if b["commits"] else "no commits")
+            for b, v in zip(buckets, per_commit)]
+    each = usage_chart(
+        f"Cost per commit, average per {unit}", f"Known cost divided by the commits with a known cost that {unit} "
+        f"(n is in the tooltip): {COST_NOTE}. A commit's cost includes dead ends since the previous trailer.",
+        labels, per_commit, pattern_id=prefix + "-each", empty="No commit has a known cost yet (cost unknown, not zero).",
+        fmt=lambda v: money(v), unknown=nocost, axis_fmt=money, hatch_note=cost_note, tips=tips,
+        aria=f"Average estimated cost per commit per {unit}, {COST_NOTE}; hatched marks commits whose cost is unknown")
+    ai_tips = [f"{b['label']}: {plural(b['commits'], 'commit')}, {b['ai']} AI-marked, {b['commits'] - b['ai']} with no AI marker"
+               for b in buckets]
+    ai = usage_chart(
+        f"AI-marked commits per {unit}", "Grey line: all commits. Blue line: commits with an AI marker (an AI "
+        "Co-Authored-By trailer, or a usage trailer). The rest have no AI marker, which does not mean a human "
+        "wrote them: a marker is only there when the tool added it.", labels, [b["commits"] for b in buckets],
+        pattern_id=prefix + "-ai", empty="No commits yet.", fmt=lambda v: f"{v} commits",
+        unknown=[(0, b["commits"]) for b in buckets], second=[b["ai"] for b in buckets],
+        legend=[("All commits", "muted"), ("AI-marked commits", "")], hatch_note="", tips=ai_tips,
+        aria=f"Commits per {unit}: one line for all commits, one for the AI-marked ones; the rest have no AI marker")
+    return out + cost + cached + each + ai
 
 
 _usage_cache = {}
@@ -1239,6 +1340,37 @@ def commits_table(name, commits, commit_set=None, usage=None):
     return f'<table class="log">{rows}</table>'
 
 
+def plural(n, word):
+    return f"{n} {word}" + ("" if n == 1 else "s")
+
+
+def cost_stat(dollars, priced, as_of):
+    """('Estimated cost', html) for usage_stats: the summed estimate over the `priced` commits with a known cost,
+    with the full label in a small note, or "unknown" (never $0) when no commit has one."""
+    if not priced:
+        return "Estimated cost", "unknown, not zero (no commit has per-model trailers)"
+    return "Estimated cost", (f'est. ${dollars:,.2f} over the {plural(priced, "commit")} with a known cost'
+                              f'<small>estimated API list-price equivalent at prices of {esc(as_of)}, not what was paid</small>')
+
+
+def usage_stats(items):
+    """A compact list of facts, one per line: [(label, html value)]. Values are built by the caller (already escaped)."""
+    return '<dl class="stats">' + "".join(f"<div><dt>{esc(k)}</dt><dd>{v}</dd></div>" for k, v in items) + "</dl>"
+
+
+def session_cost(cs):
+    """Summed estimated cost of a session's commits, or None unless every commit has a known cost."""
+    return None if any(c["cost"] is None for c in cs) else sum(c["cost"] for c in cs)
+
+
+def session_cost_cell(cs):
+    cost = session_cost(cs)
+    if cost is None:
+        return '<td class="num" title="at least one commit in this session has no known cost">unknown</td>'
+    return (f'<td class="num" title="estimated API list-price equivalent at {esc(load_prices().get("as_of", "?"))} '
+            f'rates, not what was paid">est. ${cost:,.2f}</td>')
+
+
 def build_usage_page(view, commit_set):
     """/<repo>/usage/: commits by output tokens, and a table of sessions. Removed when no commit has a trailer."""
     name = view.name
@@ -1258,35 +1390,35 @@ def build_usage_page(view, commit_set):
         + (f' – {esc(cs[-1]["row"][3])}' if cs[-1]["row"][3] != cs[0]["row"][3] else "") + "</td>"
         f'<td class="num">{len(cs)}</td><td class="num">{fmt_tokens(sum(c["delta"][1] for c in cs))}</td>'
         f'<td class="num">{fmt_tokens(sum(c["delta"][2] for c in cs))}</td>'
-        f'<td class="num">{100 * sum(c["delta"][1] for c in cs) / total_out:.0f}%</td></tr>'
+        f'<td class="num">{100 * sum(c["delta"][1] for c in cs) / total_out:.0f}%</td>'
+        f'{session_cost_cell(cs)}</tr>'
         for n, cs in sessions.items())
     ranked = sorted(costs.values(), key=lambda c: -c["delta"][1])
     buckets = week_buckets(usage["all"], costs)
-    unit = bucket_unit(buckets)
-    chart = bar_chart_svg(
-        [b["label"] for b in buckets], [b["out"] for b in buckets],
-        fmt=lambda v: f"{fmt_tokens(v)} output tokens", unknown=[(b["nodata"], b["commits"]) for b in buckets],
-        aria=f"Output tokens per {unit}, from commits with usage trailers; hatched marks commits without usage data")
+    charts = usage_charts(buckets, "u")
     as_of = load_prices().get("as_of", "?")
-    cost_line = (f' · est. ${usage["dollars"]:,.2f} over the {usage["priced"]} commits with a known cost '
-                 f'(estimated API list-price equivalent at prices of {esc(as_of)}, not what was paid)'
-                 if usage["priced"] else " · cost unknown (no commit has per-model trailers)")
-    body = (f'<h1>Claude Code usage</h1><p class="meta">From the <code>FW-Tokens-*</code> (or older <code>Usage:</code>) '
-            f'trailers in commit messages · '
-            f'{len(costs)} of {usage["commits"]} commits have one · {len(sessions)} sessions · '
-            f'~{fmt_tokens(total_out)} output tokens, ~{fmt_tokens(total_cached)} cached reads{cost_line}</p>'
-            f'<h2>Output tokens per {unit}</h2>{chart}'
+    stats = usage_stats([
+        ("Commits with usage data", f'{len(costs)} of {usage["commits"]}'),
+        ("Sessions", str(len(sessions))),
+        ("Output tokens", f"~{fmt_tokens(total_out)}"),
+        ("Cached reads", f"~{fmt_tokens(total_cached)}"),
+        cost_stat(usage["dollars"], usage["priced"], as_of)])
+    body = ('<h1>Claude Code usage</h1><p class="meta">Read from the <code>FW-Tokens-*</code> trailers in commit '
+            'messages (or older <code>Usage:</code> lines).</p>'
+            '<p class="meta"><a href="/usage/">Usage across all repositories</a></p>'
+            + stats +
+            f'{charts}'
             f'<h2>Commits by output tokens</h2>'
             f'{commits_table(name, [c["row"] for c in ranked[:30]], commit_set, usage)}'
             f'<h2>Sessions</h2><table class="log"><tr><th>#</th><th>Dates</th><th>Commits</th><th>Output</th>'
-            f'<th>Cached</th><th>Share of output</th></tr>{session_rows}</table>'
+            f'<th>Cached</th><th>Share of output</th><th>Est. cost (not what was paid)</th></tr>{session_rows}</table>'
             '<h2>How this is measured</h2><ul>'
             '<li>A trailer is the running token tally of one Claude Code session. A commit\'s cost is the step '
             'from the previous commit\'s tally. A new session starts, and its first commit gets the whole tally, '
             'at the first trailer, when the counting method changes, when the output or cached total falls, '
             'when any model\'s counter falls, when a model that had tokens disappears, or when per-model '
             'lines appear after commits that had none.</li>'
-            '<li>Bars and rankings use output tokens. Cached reads grow with how long the context is and how many '
+            '<li>The bars and rankings in the commit table use output tokens. Cached reads grow with how long the context is and how many '
             'turns ran, so they track session length more than the work; input tokens are negligible. These are '
             'tokens; dollar figures are estimated API list-price equivalents, not what was paid.</li>'
             '<li>Costs need the per-model trailers (<code>FW-Model-Tokens</code>) and are estimates at list prices '
@@ -1299,7 +1431,7 @@ def build_usage_page(view, commit_set):
             '<li>A commit with no trailer shows no bar, and the work before it is counted in the next commit '
             'that has one. The cost is of everything done since the previous trailer, including dead ends, '
             'not only the code in the commit.</li>'
-            f'<li>Highlighted bars: at least {USAGE_HOT}× the median commit.</li></ul>')
+            f'<li>Highlighted bars in the commit table: at least {USAGE_HOT}× the median commit.</li></ul>')
     write(path, page(f"{name} usage", body, [f'<a href="{url(name)}/">{esc(name)}</a>', "usage"], view=view))
 
 
@@ -1658,6 +1790,8 @@ def build_repo(name):
     if not empty:
         build_refs_page(repo, default_view, views, commit_set)
     build_usage_page(default_view, commit_set)
+    if is_public(repo):
+        usage_summary(name, save=True)       # the site-wide /usage/ page reads this instead of re-running git log
     build_ci_pages(default_view)
     build_summary(repo, default_view, desc, d.get("_files", {}), d.get("_ctx"), d.get("_commits", []), empty, commit_set)
 
@@ -1667,18 +1801,131 @@ def build_repo(name):
     print(f"{name}: {len(views)} branches, {len(commit_shas)} commits")
 
 
-def build_index():
-    build_ci_dashboard()
-    rows = []
+# Top-level paths of the code site that are not repos. A repo with one of these names would be overwritten by
+# (or overwrite) that page, so the roll-up and index skip it. (lib/admin.py RESERVED_REPOS is for `fw issues`: a different list.)
+SITE_RESERVED = {"usage", "ci", "projects", "why-i-left-gitlab", "my-ai-use", "contribute", "why", "principles"}
+
+
+def published_public_repos():
+    """[(name, git dir)] of public repos that already have a published summary page, sorted by name.
+    Reserved top-level names (SITE_RESERVED) are never repos."""
+    found = []
     for entry in sorted(os.listdir(GIT_ROOT)):
-        if not entry.endswith(".git"):
+        if not entry.endswith(".git") or entry[:-4] in SITE_RESERVED:
             continue
         repo = os.path.join(GIT_ROOT, entry)
-        if not is_public(repo):
-            continue
-        name = entry[:-4]
-        if not os.path.exists(os.path.join(SITE, name, "index.html")):
-            continue  # not published yet
+        if is_public(repo) and os.path.exists(os.path.join(SITE, entry[:-4], "index.html")):
+            found.append((entry[:-4], repo))
+    return found
+
+
+def usage_summary_rows(u):
+    """Compact per-commit rows [date, ai, out, read, cost] (out/read None = no usage data, cost None = unknown)
+    from a repo_usage result: all that the site-wide roll-up needs."""
+    rows = []
+    for c in u["all"]:
+        e = u["costs"].get(c["sha"])
+        rows.append([c["date"], int(c["ai"]), e["delta"][1] if e else None, e["delta"][2] if e else None,
+                     e["cost"] if e else None])
+    return rows
+
+
+def usage_summary(name, save=False):
+    """{"head", "key", "rows"} for a repo: read from STATE/<name>.usage.json when its HEAD sha and gen_key() still
+    match, else computed from git (via repo_usage) and, when `save` or the file was missing or stale, written.
+    A missing, corrupt or stale file is simply recomputed."""
+    repo = os.path.join(GIT_ROOT, name + ".git")
+    head = git_text(repo, "rev-parse", "HEAD", check=False).strip()
+    path = os.path.join(STATE, name + ".usage.json")
+    if not save:
+        try:
+            with open(path) as f:
+                cached = json.load(f)
+            if (head and cached["head"] == head and cached["key"] == gen_key()
+                    and all(isinstance(r, list) and len(r) == 5 for r in cached["rows"])):
+                return cached
+        except (OSError, ValueError, KeyError, TypeError):
+            pass
+    summary = {"head": head, "key": gen_key(), "rows": usage_summary_rows(repo_usage(name))}
+    try:
+        os.makedirs(STATE, exist_ok=True)
+        write(path, json.dumps(summary))
+    except OSError:
+        pass
+    return summary
+
+
+def site_usage_totals(names, today=None):
+    """(buckets, rows, start) over the named repos. buckets: week_buckets of every repo's commits together, from
+    `start`, the first commit with an AI marker or usage data (dates before 2000 or after tomorrow are ignored
+    entirely; start is None if there is none). rows: per repo name, commits, with_data, ai, out, dollars, priced,
+    has_page; sorted by output tokens, then name."""
+    today = today or datetime.date.today()
+    lo, hi = datetime.date(2000, 1, 1).isoformat(), (today + datetime.timedelta(days=1)).isoformat()
+    allc, costs, rows = [], {}, []
+    for name in names:
+        data = usage_summary(name)["rows"]
+        with_data = [r for r in data if r[2] is not None]
+        known = [r[4] for r in data if r[4] is not None]
+        for i, (date, ai, out, read, cost) in enumerate(data):
+            if not lo <= date <= hi:
+                continue
+            sha = f"{name}:{i}"
+            allc.append({"sha": sha, "date": date, "ai": bool(ai), "has_tokens": out is not None})
+            if out is not None:
+                costs[sha] = {"delta": [0, out, read], "cost": cost}
+        rows.append({"name": name, "commits": len(data), "with_data": len(with_data), "ai": sum(r[1] for r in data),
+                     "out": sum(r[2] for r in with_data), "dollars": sum(known), "priced": len(known),
+                     "has_page": bool(with_data)})
+    rows.sort(key=lambda r: (-r["out"], r["name"]))
+    start = min((c["date"] for c in allc if c["ai"]), default=None)
+    allc = [c for c in allc if start and c["date"] >= start]
+    return week_buckets(allc, costs), rows, start
+
+
+def build_site_usage():
+    """code.fuzzyworld.net/usage/: the Usage charts summed over public published repos, and a table per repo."""
+    repos = published_public_repos()
+    buckets, rows, start = site_usage_totals([n for n, _ in repos])
+    def cost_cell(r):
+        if not r["priced"]:
+            return '<td class="num" title="no commit in this repo has a known cost">unknown</td>'
+        part = "" if r["priced"] == r["with_data"] else f" ({r['priced']} of {r['with_data']} commits)"
+        return (f'<td class="num" title="estimated API list-price equivalent, not what was paid">'
+                f'est. ${r["dollars"]:,.2f}{part}</td>')
+    table_rows = "".join(
+        '<tr><td>' + (f'<a href="{url(r["name"], "usage")}/">{esc(r["name"])}</a>' if r["has_page"] else esc(r["name"]))
+        + f'</td><td class="num">{r["commits"]}</td><td class="num">{r["with_data"]}</td>'
+        + (f'<td class="num">{100 * r["ai"] / r["commits"]:.0f}%</td>' if r["commits"] else '<td class="num">n/a</td>')
+        + f'<td class="num">{fmt_tokens(r["out"]) if r["with_data"] else "unknown"}</td>{cost_cell(r)}</tr>'
+        for r in rows)
+    if rows:
+        table = ('<h2>By repository</h2><table class="log"><tr><th>Repository</th><th>Commits</th>'
+                 '<th>Commits with usage data</th><th>AI-marked commits</th><th>Output tokens</th>'
+                 f'<th>Est. cost (not what was paid)</th></tr>{table_rows}</table>')
+    else:
+        table = '<p class="meta">No public repository is published yet.</p>'
+    with_data = sum(r["with_data"] for r in rows)
+    stats = usage_stats([
+        ("Repositories", str(len(rows))),
+        ("Commits with usage data", f'{with_data} of {sum(r["commits"] for r in rows)}'),
+        ("Output tokens", f'~{fmt_tokens(sum(r["out"] for r in rows))}' if any(r["with_data"] for r in rows) else "unknown, not zero (no commit has usage data)"),
+        cost_stat(sum(r["dollars"] for r in rows), sum(r["priced"] for r in rows), load_prices().get("as_of", "?"))])
+    body = ('<h1>Claude Code usage, all repositories</h1><p class="meta">Summed over the public repositories, read '
+            'from the <code>FW-Tokens-*</code> trailers in their commit messages (or older <code>Usage:</code> lines). '
+            'Unknown is never counted as zero; each repository\'s own usage page has the per-commit detail.</p>'
+            + stats
+            + (f'<p class="meta">Starts at the first AI-marked commit, {esc(start)}.</p>' if start else "")
+            + usage_charts(buckets, "s") + table)
+    write(os.path.join(SITE, "usage", "index.html"),
+          page("Claude Code usage, all repositories", body, ["usage"], section="usage"))
+    print(f"site usage: {len(rows)} repos, {len(buckets)} buckets")
+
+
+def build_index():
+    build_ci_dashboard()
+    rows = []
+    for name, repo in published_public_repos():
         last = git_text(repo, "log", "-1", "--format=%cs", "HEAD", check=False).strip()
         rows.append((last, name, repo_config(repo, "fuzzyworld.description"), app_url(name)))
     rows.sort(key=lambda r: r[0], reverse=True)
@@ -1691,9 +1938,11 @@ def build_index():
     body = (f"<h1>Pavel's code</h1>"
             f"<p>Browse, or clone with <code>git clone {BASE_URL}/&lt;repo&gt;.git</code>. No account or sign-in needed. "
             f'Small web apps are at <a href="{APPS_URL}/">apps.fuzzyworld.net</a>. <br /> <br />'
+            f'<a href="/usage/">Claude Code usage across all repositories</a><br /> <br />'
             f'<a href="/why-i-left-gitlab/">Why aren\'t those GitHub/GitLab/Codeberg links? (Why I left GitLab)</a></p>'
             f'<ul class="repos">{"".join(items)}</ul>')
     write(os.path.join(SITE, "index.html"), page("Repositories", body))
+    build_site_usage()
     print(f"index: {len(rows)} public repos")
 
 
diff --git a/server/site/style.css b/server/site/style.css
index 0483f72..d7db260 100644
--- a/server/site/style.css
+++ b/server/site/style.css
@@ -375,7 +375,7 @@ td.cost .bar.hot {
   background: var(--site-hunk);
 }
 
-/* Inline SVG bar charts (the usage page): colours only here, so they follow dark mode */
+/* Inline SVG line charts (the usage page): colours only here, so they follow dark mode */
 svg.chart {
   display: block;
   width: 100%;
@@ -383,18 +383,37 @@ svg.chart {
   margin: var(--spacing-md) 0;
 }
 
-.chart .bar {
-  fill: var(--site-muted);
+.chart .line {
+  fill: none;
+  stroke: var(--site-link);
+  stroke-width: 2;
+  stroke-linejoin: round;
+  stroke-linecap: round;
+  vector-effect: non-scaling-stroke;
+}
+
+.chart .line.muted {
+  stroke: var(--site-muted);
+}
+
+.chart .area {
+  fill: var(--site-link);
+  fill-opacity: 0.12;
+  stroke: none;
 }
 
-.chart .bar.hot {
-  fill: var(--site-hunk);
+.chart .area.muted {
+  fill: var(--site-muted);
 }
 
-.chart .bar2 {
+.chart .dot {
   fill: var(--site-link);
 }
 
+.chart .dot.muted {
+  fill: var(--site-muted);
+}
+
 .chart .hit {
   fill: transparent;
 }
@@ -756,3 +775,29 @@ pre.clone::before {
   color: var(--color-accent);
   border-color: var(--color-accent);
 }
+
+/* Facts list on the usage pages: one fact per line */
+dl.stats {
+  margin: var(--spacing-md) 0;
+}
+
+dl.stats div {
+  display: flex;
+  flex-wrap: wrap;
+  gap: var(--spacing-sm);
+  padding: 2px 0;
+}
+
+dl.stats dt {
+  min-width: 14em;
+  color: var(--site-muted);
+}
+
+dl.stats dd {
+  margin: 0;
+}
+
+dl.stats small {
+  display: block;
+  color: var(--site-muted);
+}
diff --git a/tests/test_gen.py b/tests/test_gen.py
index 0d4044c..360e275 100644
--- a/tests/test_gen.py
+++ b/tests/test_gen.py
@@ -1,6 +1,12 @@
 """Tests for the page furniture in server/gen.py. Run: python3 -m unittest discover tests"""
+import datetime
+import json
 import os
+import re
+import shutil
+import subprocess
 import sys
+import tempfile
 import unittest
 from unittest import mock
 
@@ -138,21 +144,63 @@ class Charts(unittest.TestCase):
         self.assertEqual(b[0]["label"], "month of 2020-01")
         self.assertEqual(len(b), 82)
 
-    def test_bar_chart_svg(self):
+    def test_line_chart_svg(self):
         b = self.buckets()
-        svg = gen.bar_chart_svg([x["label"] for x in b], [x["out"] for x in b],
-                                fmt=lambda v: f"{gen.fmt_tokens(v)} output tokens",
-                                unknown=[(x["nodata"], x["commits"]) for x in b], aria="Output per week")
+        svg = gen.line_chart_svg([x["label"] for x in b], [x["out"] if x["with_tokens"] else None for x in b],
+                                 fmt=lambda v: f"{gen.fmt_tokens(v)} output tokens",
+                                 unknown=[(x["nodata"], x["commits"]) for x in b], aria="Output per week")
         self.assertNotIn("<script", svg)
         self.assertIn('viewBox="0 0 720 200"', svg)
         self.assertIn("<title>week of 2026-10-26: 100 output tokens, 1 of 2 commits with data</title>", svg)
-        self.assertIn("<title>week of 2026-11-02: 0 output tokens, no commits</title>", svg)
+        self.assertIn("<title>week of 2026-11-02: no data, no commits</title>", svg)   # empty weeks keep a hover target
         self.assertEqual(svg.count('class="unk"'), 1)               # only the first week has commits without data
+        self.assertEqual(svg.count('class="hit"'), 3)
+        self.assertEqual(svg.count("<circle"), 2)                   # two known points; the gap week has none
         self.assertIn("<pattern", svg)
         self.assertIn("hatched = commits without usage data (unknown, not zero)", svg)
         self.assertIn(">2026-10-26<", svg)
         self.assertIn(">2026-11-09<", svg)
 
+    def chart(self, values, **kw):
+        labels = [f"week of 2026-10-{26 + 7 * i:02d}" if i < 1 else f"week of 2026-11-{(i - 1) * 7 + 2:02d}" for i in range(len(values))]
+        return gen.line_chart_svg(labels, values, fmt=str, unknown=[(0, 1)] * len(values), aria="a", **kw)
+
+    def test_gap_splits_the_line_into_segments(self):
+        svg = self.chart([1, 2, None, 3, 4, None, 5])
+        self.assertEqual(svg.count('<path class="line"'), 2)        # [1,2] and [3,4]; the lone 5 is only a dot
+        self.assertEqual(svg.count('<path class="area"'), 2)
+        self.assertEqual(svg.count("<circle"), 5)
+        self.assertNotIn("NaN", svg)
+
+    def test_all_unknown_has_no_path_or_dot(self):
+        svg = self.chart([None, None])
+        self.assertNotIn('<path class="line"', svg)
+        self.assertNotIn("<circle", svg)
+        self.assertEqual(svg.count('class="hit"'), 2)
+
+    def test_single_point_is_a_dot(self):
+        svg = self.chart([7])
+        self.assertEqual(svg.count("<circle"), 1)
+        self.assertNotIn('<path class="line"', svg)
+
+    def test_known_zero_is_drawn_on_the_axis(self):
+        svg = self.chart([0, 0])
+        self.assertEqual(svg.count('<path class="line"'), 1)
+        self.assertEqual(svg.count("<circle"), 2)
+
+    def test_two_series_and_legend(self):
+        svg = self.chart([3, 4], second=[1, 2], legend=[("All commits", "muted"), ("AI-marked commits", "")], hatch_note="")
+        self.assertIn('<path class="line muted"', svg)
+        self.assertIn('<path class="line"', svg)
+        self.assertIn(">All commits<", svg)
+        self.assertIn(">AI-marked commits<", svg)
+
+    def test_titles_are_escaped(self):
+        svg = gen.line_chart_svg(["week of 2026-10-26"], [1], fmt=str, unknown=[(0, 1)], aria="<i>", tips=["<b>x</b>"])
+        self.assertNotIn("<b>", svg)
+        self.assertNotIn("<i>", svg)
+        self.assertIn("&lt;b&gt;x&lt;/b&gt;", svg)
+
 
 class SessionBreaks(unittest.TestCase):
     def test_vanished_model_starts_a_new_session(self):
@@ -174,10 +222,16 @@ class SessionBreaks(unittest.TestCase):
         self.assertEqual(costs["bbb"]["models"], {"m-big": [20, 250, 2000, 0, 0]})
 
 
-class UsageRendering(unittest.TestCase):
+class UsageFixtures:
+    """setUp and helpers for tests that render usage pages from fake commits (no test_ methods of its own)."""
     def setUp(self):
         self._cache = dict(gen._usage_cache)
         self.addCleanup(lambda: (gen._usage_cache.clear(), gen._usage_cache.update(self._cache)))
+        self.state = tempfile.mkdtemp()
+        self.addCleanup(shutil.rmtree, self.state, True)
+        patcher = mock.patch.object(gen, "STATE", self.state)
+        patcher.start()
+        self.addCleanup(patcher.stop)
 
     def usage(self, commits, name="r"):
         costs, total, allc = gen.compute_costs(commits, PRICES)
@@ -196,6 +250,9 @@ class UsageRendering(unittest.TestCase):
             gen.build_usage_page(view, None)
         return next(iter(written.values()))
 
+
+
+class UsageRendering(UsageFixtures, unittest.TestCase):
     def test_cell_and_panel_label_cost_and_never_show_zero_for_unknown(self):
         u = self.usage([commit("a" * 40, "2026-10-26", trailers(1, 1000, 5, {"m-big": [1, 1000, 5, 0, 0]}, 2)),
                         commit("b" * 40, "2026-10-27", "x\n\nFW-Tokens-In: 2\nFW-Tokens-Out: 2000\nFW-Tokens-Cached: 9\n")])
@@ -246,12 +303,231 @@ class UsageRendering(unittest.TestCase):
         self.assertEqual(gen.bucket_unit(b105), "month")
 
     def test_chart_hover_target_and_unique_pattern(self):
-        svg = gen.bar_chart_svg(["week of 2026-10-26", "week of 2026-11-02"], [0, 5], fmt=str, unknown=[(1, 1), (0, 1)],
-                                aria="a", pattern_id="p2")
+        svg = gen.line_chart_svg(["week of 2026-10-26", "week of 2026-11-02"], [None, 5], fmt=str, unknown=[(1, 1), (0, 1)],
+                                 aria="a", pattern_id="p2")
         self.assertIn('id="p2"', svg)
         self.assertIn("url(#p2)", svg)
         self.assertEqual(svg.count('class="hit"'), 2)
-        self.assertTrue(svg.index('class="hit"') < svg.index('class="bar"'))
+        self.assertEqual(svg.count("<circle"), 1)
+
+class UsageChartsAndSiteRollup(UsageFixtures, unittest.TestCase):
+    BIG = {"m-big": [1, 1000, 5, 0, 0]}
+    OLD = "x\n\nFW-Tokens-In: 2\nFW-Tokens-Out: 4000\nFW-Tokens-Cached: 9\n"      # tokens, no per-model lines: cost unknown
+
+    def captured(self, fn, *args):
+        written, real = {}, gen.write
+        def fake(path, content):
+            (real if path.startswith(self.state) else lambda p, c: written.update({p: c}))(path, content)
+        with mock.patch.object(gen, "write", fake), \
+                mock.patch.object(gen, "load_prices", return_value=PRICES), \
+                mock.patch.object(gen, "ci_pipelines", return_value={}):
+            fn(*args)
+        return written
+
+    def check_page(self, html_, charts=5):
+        ids = re.findall(r'<pattern id="([^"]+)"', html_)
+        self.assertEqual(len(ids), len(set(ids)))                # unique pattern ids
+        self.assertEqual(html_.count('<svg class="chart"'), charts)
+        self.assertNotIn("<script", html_)
+        self.assertIsNone(re.search(r"\$0(\.00?)?(?![.\d])", html_))     # unknown is never drawn as zero dollars
+        if "$" in html_:
+            self.assertIn("not what was paid", html_)
+
+    def repo(self, name, commits):
+        self.usage(commits, name)
+
+    def site(self, names, today=None):
+        with mock.patch.object(gen, "published_public_repos", return_value=[(n, "") for n in names]):
+            written = self.captured(gen.build_site_usage)
+        return next(iter(written.values()))
+
+    def test_repo_page_has_all_charts_and_sessions_cost(self):
+        html_ = self.page([commit("a" * 40, "2026-10-26", trailers(1, 1000, 5, self.BIG, 2) + "Co-Authored-By: Claude <noreply@anthropic.com>\n"),
+                           commit("b" * 40, "2026-10-27", "plain")])
+        for h in ("Output tokens per week", "Estimated cost per week", "Cached reads per week",
+                  "Cost per commit, average per week", "AI-marked commits per week"):
+            self.assertIn(f"<h2>{h}</h2>", html_)
+        self.check_page(html_)
+        self.assertIn("n=1 commit with a known cost", html_)
+        self.assertIn("1 AI-marked, 1 with no AI marker", html_)
+        self.assertIn('<circle class="dot muted"', html_)        # one week: dots only; all commits (grey) ...
+        self.assertIn('<circle class="dot"', html_)              # ... and the AI-marked series
+        self.assertIn(">AI-marked commits<", html_)             # the two-line AI chart's legend
+        self.assertIn("<h2>AI-marked commits per week</h2>", html_)
+        self.assertIn("no AI marker", html_)
+        self.assertIn("Est. cost (not what was paid)", html_)
+        self.assertIn(">est. $0.05</td>", html_)
+        self.assertIn('<a href="/usage/">Usage across all repositories</a>', html_)
+        self.assertIn('<dl class="stats">', html_)
+        self.assertIn("<dt>Commits with usage data</dt><dd>1 of 2</dd>", html_)
+        self.assertIn("est. $0.05 over the 1 commit with a known cost", html_)    # singular
+        self.assertIn("estimated API list-price equivalent at prices of 2026-10-22, not what was paid", html_)
+
+    def test_session_cost_known_only_if_every_commit_has_one(self):
+        costs, _, _ = gen.compute_costs([
+            commit("a" * 40, "2026-10-26", trailers(1, 1000, 5, self.BIG, 2)),
+            commit("b" * 40, "2026-10-27", trailers(2, 2000, 9, {"m-new": [1, 1, 1, 0, 0]}, 2))], PRICES)
+        both = [costs["a" * 40], costs["b" * 40]]
+        self.assertIsNone(gen.session_cost(both))
+        self.assertAlmostEqual(gen.session_cost(both[:1]), 0.050015)
+        with mock.patch.object(gen, "load_prices", return_value=PRICES):
+            self.assertIn(">unknown</td>", gen.session_cost_cell(both))
+            self.assertNotIn("$", gen.session_cost_cell(both))
+            self.assertIn("not what was paid", gen.session_cost_cell(both[:1]))
+
+    def test_page_without_known_costs_says_unknown_not_zero(self):
+        html_ = self.page([commit("a" * 40, "2026-10-26", self.OLD)])
+        self.assertIn("No commit has a known cost yet", html_)
+        self.assertNotIn("$", html_)                              # no dollar figure at all, in particular no $0
+        self.assertNotIn("<script", html_)
+        self.check_page(html_, charts=3)                          # tokens, cached reads, AI-marked; the two cost charts say so
+        self.assertIn('<td class="num" title="at least one commit in this session has no known cost">unknown</td>', html_)
+
+    def test_known_zero_is_a_value_unknown_is_not(self):
+        args = dict(pattern_id="z", empty="nothing", fmt=str, unknown=[(0, 1)], aria="a")
+        self.assertIn("<svg", gen.usage_chart("T", "c", ["week of 2026-10-26"], [0], **args))
+        self.assertNotIn("<svg", gen.usage_chart("T", "c", ["week of 2026-10-26"], [None], **args))
+        self.assertIn("nothing", gen.usage_chart("T", "c", ["week of 2026-10-26"], [None], **args))
+
+    def test_empty_buckets_give_messages_not_axes(self):
+        out = gen.usage_charts([], "e")
+        self.assertNotIn("<svg", out)
+        self.assertIn("No commits yet.", out)
+        self.assertEqual(gen.line_chart_svg([], [], fmt=str, unknown=[], aria="a"), "")
+
+    def test_site_rollup_aggregates_two_repos(self):
+        self.repo("one", [commit("a" * 40, "2026-10-26", trailers(1, 1000, 5, self.BIG, 2)),
+                          commit("b" * 40, "2026-10-27", "plain")])
+        self.repo("two", [commit("a" * 40, "2026-10-28", self.OLD),
+                          commit("c" * 40, "2026-11-03", "x\n\nCo-Authored-By: Claude <noreply@anthropic.com>")])
+        self.repo("none", [commit("d" * 40, "2026-10-29", "plain")])
+        buckets, rows, start = gen.site_usage_totals(["one", "two", "none"], today=datetime.date(2026, 11, 4))
+        self.assertEqual(start, "2026-10-26")
+        self.assertEqual([r["name"] for r in rows], ["two", "one", "none"])      # by output tokens
+        self.assertEqual(buckets[0]["commits"], 4)       # all four commits of the first week; "a"*40 exists in both repos
+        self.assertEqual(buckets[0]["out"], 5000)
+        self.assertEqual(buckets[0]["with_cost"], 1)
+        self.assertEqual(buckets[1]["ai"], 1)
+        html_ = self.site(["one", "two", "none"])
+        self.check_page(html_)
+        self.assertIn("Starts at the first AI-marked commit, 2026-10-26.", html_)
+        cost = 'title="estimated API list-price equivalent, not what was paid"'
+        self.assertIn('<tr><td><a href="/two/usage/">two</a></td><td class="num">2</td><td class="num">1</td>'
+                      '<td class="num">100%</td><td class="num">4.0K</td>'
+                      '<td class="num" title="no commit in this repo has a known cost">unknown</td></tr>', html_)
+        self.assertIn('<tr><td><a href="/one/usage/">one</a></td><td class="num">2</td><td class="num">1</td>'
+                      f'<td class="num">50%</td><td class="num">1.0K</td><td class="num" {cost}>est. $0.05</td></tr>', html_)
+        self.assertIn('<tr><td>none</td><td class="num">1</td><td class="num">0</td><td class="num">0%</td>'
+                      '<td class="num">unknown</td><td class="num" title="no commit in this repo has a known cost">unknown</td></tr>', html_)
+
+    def test_site_cost_cell_shows_coverage_when_partly_known(self):
+        self.repo("p", [commit("a" * 40, "2026-10-26", trailers(1, 1000, 5, self.BIG, 2)),
+                        commit("b" * 40, "2026-10-27", trailers(2, 2000, 9, {"m-new": [1, 1, 1, 0, 0]}, 2))])
+        self.assertIn("est. $0.05 (1 of 2 commits)</td>", self.site(["p"]))
+
+    def test_site_range_starts_at_first_ai_commit_and_ignores_bogus_dates(self):
+        self.repo("a", [commit("a" * 40, "1970-01-01", "x\n\nCo-Authored-By: Claude <noreply@anthropic.com>"),
+                        commit("b" * 40, "2031-01-01", "x\n\nCo-Authored-By: Claude <noreply@anthropic.com>"),
+                        commit("c" * 40, "2020-01-06", "plain"),
+                        commit("d" * 40, "2026-10-26", "x\n\nCo-Authored-By: Claude <noreply@anthropic.com>")])
+        buckets, rows, start = gen.site_usage_totals(["a"], today=datetime.date(2026, 11, 4))
+        self.assertEqual(start, "2026-10-26")
+        self.assertEqual(len(buckets), 1)
+        self.assertEqual(rows[0]["commits"], 4)           # the table still counts every commit
+        self.repo("b", [commit("a" * 40, "1970-01-01", "x\n\nCo-Authored-By: Claude <noreply@anthropic.com>"),
+                        commit("c" * 40, "2026-10-26", "plain")])
+        buckets, _, start = gen.site_usage_totals(["b"], today=datetime.date(2026, 11, 4))
+        self.assertEqual((buckets, start), ([], None))
+        self.assertNotIn("Starts at", self.site(["b"]))
+
+    def test_site_page_buckets_by_month_for_long_histories(self):
+        self.repo("old", [commit("a" * 40, "2020-01-06", "x\n\nCo-Authored-By: Claude <noreply@anthropic.com>")])
+        self.repo("new", [commit("a" * 40, "2026-10-26", trailers(1, 1000, 5, self.BIG, 2))])
+        html_ = self.site(["old", "new"])
+        self.assertIn("<h2>Output tokens per month</h2>", html_)
+        self.assertNotIn("per week", html_)
+
+    def test_site_page_escapes_repo_names(self):
+        self.repo("<b>x</b>", [commit("a" * 40, "2026-10-26", trailers(1, 1000, 5, self.BIG, 2))])
+        html_ = self.site(["<b>x</b>"])
+        self.assertNotIn("<b>x</b>", html_)
+        self.assertIn("&lt;b&gt;x&lt;/b&gt;", html_)
+
+    def test_stats_plural_and_unknown(self):
+        self.assertEqual(gen.plural(1, "commit"), "1 commit")
+        self.assertEqual(gen.plural(2, "commit"), "2 commits")
+        self.assertIn("2 commits with a known cost", gen.cost_stat(1.5, 2, "d")[1])
+        label, text = gen.cost_stat(0.0, 0, "d")
+        self.assertIn("unknown, not zero", text)
+        self.assertNotIn("$", text)
+
+    def test_site_page_header_and_nav(self):
+        self.repo("one", [commit("a" * 40, "2026-10-26", trailers(1, 1000, 5, self.BIG, 2))])
+        html_ = self.site(["one"])
+        self.assertIn("<dt>Repositories</dt><dd>1</dd>", html_)
+        self.assertIn('<a href="/usage/" aria-current="page">Usage</a>', html_)
+        self.assertNotIn('<a href="/" aria-current="page"', html_)
+        self.assertIn(["Usage", "https://code.fuzzyworld.net/usage/"], gen.site_nav())
+
+    def test_site_rollup_with_no_repos(self):
+        html_ = self.site([])
+        self.assertIn("No public repository is published yet.", html_)
+        self.assertNotIn("<svg", html_)
+        self.assertNotIn("<script", html_)
+
+    def test_summary_cache_hit_head_change_missing_and_corrupt(self):
+        self.repo("c", [commit("a" * 40, "2026-10-26", trailers(1, 1000, 5, self.BIG, 2))])
+        path = os.path.join(self.state, "c.usage.json")
+        head = ["h1"]
+        with mock.patch.object(gen, "git_text", lambda *a, **k: head[0]), \
+                mock.patch.object(gen, "repo_usage", wraps=gen.repo_usage) as ru:
+            first = gen.usage_summary("c")
+            self.assertEqual((ru.call_count, os.path.exists(path)), (1, True))
+            self.assertEqual(first["rows"][0][:3], ["2026-10-26", 1, 1000])
+            gen.usage_summary("c")
+            self.assertEqual(ru.call_count, 1)                   # hit: no git work
+            head[0] = "h2"
+            gen.usage_summary("c")
+            self.assertEqual(ru.call_count, 2)                   # HEAD changed: recomputed
+            os.remove(path)
+            gen.usage_summary("c")
+            self.assertEqual(ru.call_count, 3)                   # missing file
+            with open(path, "w") as f:
+                f.write("{not json")
+            self.assertEqual(gen.usage_summary("c")["rows"], first["rows"])
+            self.assertEqual(ru.call_count, 4)                   # corrupt file
+            with open(path, "w") as f:
+                json.dump({"head": "h2", "key": gen.gen_key(), "rows": [[1]]}, f)
+            gen.usage_summary("c")
+            self.assertEqual(ru.call_count, 5)                   # malformed rows
+            gen.usage_summary("c")
+            self.assertEqual(ru.call_count, 5)                   # and it was rewritten properly
+
+    def test_published_public_repos(self):
+        root, site = tempfile.mkdtemp(), tempfile.mkdtemp()
+        self.addCleanup(shutil.rmtree, root, True)
+        self.addCleanup(shutil.rmtree, site, True)
+        for name, vis, published in [("ok", "public", True), ("priv", "private", True), ("unpub", "public", False),
+                                     ("cap", "Public", True), ("spaced", "public ", True), ("usage", "public", True),
+                                     ("ci", "public", True)]:
+            subprocess.run(["git", "init", "-q", "--bare", os.path.join(root, name + ".git")], check=True)
+            subprocess.run(["git", "-C", os.path.join(root, name + ".git"), "config", "fuzzyworld.visibility", vis], check=True)
+            if published:
+                os.makedirs(os.path.join(site, name))
+                open(os.path.join(site, name, "index.html"), "w").close()
+        with mock.patch.object(gen, "GIT_ROOT", root), mock.patch.object(gen, "SITE", site):
+            self.assertEqual([n for n, _ in gen.published_public_repos()], ["ok", "spaced"])
+            # "public " (trailing space) is still public: repo_config strips whitespace, as before this change.
+            # "Public" (case) is private, as are unpublished repos, stale SITE pages of private repos, and reserved names.
+
+    def test_index_links_to_site_usage(self):
+        written = {}
+        with mock.patch.object(gen, "write", lambda p, c: written.update({p: c})), \
+                mock.patch.object(gen, "build_ci_dashboard"), mock.patch.object(gen, "published_public_repos", return_value=[]), \
+                mock.patch.object(gen, "build_site_usage") as bsu:
+            gen.build_index()
+        self.assertIn('<a href="/usage/">', next(iter(written.values())))
+        bsu.assert_called_once()
 
 
 if __name__ == "__main__":