fuzzyworld-tools / commit
Usage: line charts, site-wide /usage/ page, Usage in the top nav (Q107 stages 2 and 3)
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}/<repo>.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("<b>x</b>", 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("<b>x</b>", 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__":