# Token Spend

What one person and a stack of agents actually moves through a month, and what it would have cost at API list rates.


<style>
/* Every selector here is scoped under .tokenspend. That is the whole reason this is
   a fragment: it drops into a page that already has its own .card, .sub and
   .headline without either one clobbering the other, and it brings no :root, no
   body rule and no nav. The host page owns the shell. */
/* No colour tokens are defined here on purpose. This component used to set
   --display, --hot and a private --dim to the dark theme's values, which meant
   it stayed dark inside a light page: the host's theme was being overridden by
   the thing it contained. Everything below now reads the host's tokens, so the
   chart follows the site into light or dark with no second palette anywhere. */
.tokenspend{width:min(860px,100%); margin:0 auto; padding:48px 16px 72px;
  --s1:#C4166F; --s2:#B85510; --s3:#8C7A0A; --s4:#2F7A50; --s5:#3E8CC4; --s6:#123F6B;}
/* Dark restores the palette the spectrum was drawn in. Both triggers, the same
   two site.css uses: the OS preference unless the reader explicitly chose
   light, and the explicit toggle. */
@media (prefers-color-scheme: dark){
  :root:not([data-theme="light"]) .tokenspend{--s1:#D50C7A; --s2:#DB6716; --s3:#EEE16B; --s4:#3A9360; --s5:#85BDE8; --s6:#124C83;}
}
:root[data-theme="dark"] .tokenspend{--s1:#D50C7A; --s2:#DB6716; --s3:#EEE16B; --s4:#3A9360; --s5:#85BDE8; --s6:#124C83;}
.tokenspend .eyebrow{
  font-family:"Archivo",system-ui,sans-serif; font-size:var(--t-micro,11px); letter-spacing:.20em;
  text-transform:uppercase; color:var(--meta); margin:0 0 14px;
}
.tokenspend .headline{
  font-family:"Archivo",system-ui,sans-serif; font-weight:700;
  font-size:var(--t-h1,clamp(30px,6vw,52px)); line-height:1;
  letter-spacing:var(--ls-tight,-.015em); margin:0 0 6px;
}
/* .42em, deliberately off the scale: the unit has to track the number it sits
   against, and a fixed rung would leave it stranded when the headline reflows. */
.tokenspend .headline .unit{font-size:.42em; color:var(--display); margin-left:10px}
.tokenspend .sub{margin:0 0 26px; max-width:60ch}

.tokenspend svg{width:100%; height:auto; display:block; overflow:visible}
.tokenspend .lbl{font-family:"Archivo",system-ui,sans-serif; font-size:var(--t-micro,11px);
  letter-spacing:var(--ls-caps,.14em); fill:var(--text); opacity:.62}
.tokenspend .lbl.hot{fill:var(--hot-ink); opacity:1}
.tokenspend .lbl.mean{fill:var(--meta); opacity:1}
.tokenspend .lbl.end{fill:var(--display); opacity:1}
/* hover readout: one point at a time, date over value */
.tokenspend #ts-hit{cursor:crosshair}
.tokenspend #ts-chart:focus{outline:none}
.tokenspend #ts-chart:focus-visible #ts-hbox{stroke:var(--display)}
.tokenspend #ts-hline{stroke:var(--text); stroke-width:1; opacity:.35}
.tokenspend #ts-hdot{fill:var(--display); stroke:var(--ground); stroke-width:2}
.tokenspend #ts-hbox{fill:var(--ground); stroke:var(--rule); stroke-width:1}
.tokenspend .tip{font-family:"Archivo",system-ui,sans-serif; font-size:12px; fill:var(--text)}
.tokenspend .tip .tip-date{opacity:.7; letter-spacing:.04em}
.tokenspend .tip .tip-val{font-weight:700}
.tokenspend .tip .tip-vend{opacity:.85}
/* A day some machine has not reported yet: hatched, so a short day reads as
   unfinished rather than quiet. Same ink as the end dot, in either theme. */
.tokenspend #ts-hatch line{stroke:var(--display); stroke-width:1.6; opacity:.7}
.tokenspend .hatched{fill:url(#ts-hatch)}

/* The six beam bands: they start at full height, then fall and flatten into the
   baseline the line is drawn on. One morph, once, on load. */
.tokenspend .band{transform-origin:50% 0%}
@media (prefers-reduced-motion: no-preference){
  .tokenspend .band{animation:fall 1.05s cubic-bezier(.65,.02,.3,1) forwards}
  .tokenspend .band:nth-child(1){animation-delay:.05s} .tokenspend .band:nth-child(2){animation-delay:.11s}
  .tokenspend .band:nth-child(3){animation-delay:.17s} .tokenspend .band:nth-child(4){animation-delay:.23s}
  .tokenspend .band:nth-child(5){animation-delay:.29s} .tokenspend .band:nth-child(6){animation-delay:.35s}
  @keyframes fall{
    0%   {transform:scaleY(1);    opacity:.85}
    70%  {transform:scaleY(.06);  opacity:.55}
    100% {transform:scaleY(.045); opacity:.30}
  }
  .tokenspend #ts-line{stroke-dasharray:var(--len); stroke-dashoffset:var(--len);
        animation:draw 1.15s cubic-bezier(.4,0,.2,1) .72s forwards}
  @keyframes draw{to{stroke-dashoffset:0}}
  .tokenspend #ts-area{opacity:0; animation:wash .8s ease 1.35s forwards}
  @keyframes wash{to{opacity:.16}}
  .tokenspend .late{opacity:0; animation:rise .6s ease 1.6s forwards}
  @keyframes rise{from{opacity:0; transform:translateY(6px)} to{opacity:1; transform:none}}
}
@media (prefers-reduced-motion: reduce){
  .tokenspend .band{transform:scaleY(.045); opacity:.30}
  .tokenspend #ts-area{opacity:.16}
}

.tokenspend .source{
  font-family:"Archivo",system-ui,sans-serif; font-size:var(--t-micro,11px);
  line-height:1.6; color:var(--meta); margin:22px 0 0; max-width:74ch;
}
.tokenspend .source b{color:var(--meta); font-weight:400}
/* Kai's footer, 2026-09-15: the claim first, then the three records it rests on
   as a list. The list is the point; three sources buried in one sentence read as
   hedging, and each is a different file format doing a different job. */
.tokenspend ul.source{margin:8px 0 0; padding-left:1.1em}
.tokenspend ul.source li{margin:0 0 3px}
.tokenspend hr.src{border:0; border-top:1px solid var(--rule); margin:20px 0 0; max-width:74ch}

/* The table is deliberately NOT spectrum-coloured. Colour carries data in the
   chart; down here it would be decoration. */
.tokenspend table{width:100%; border-collapse:collapse; margin:34px 0 0;
  font-family:"Archivo",system-ui,sans-serif; font-size:var(--t-small,14px)}
.tokenspend caption{text-align:left; font-size:var(--t-micro,11px);
  letter-spacing:var(--ls-caps,.14em); text-transform:uppercase;
  color:var(--meta); padding:0 0 10px}
.tokenspend th{text-align:left; font-weight:500; font-size:var(--t-micro,11px);
  letter-spacing:var(--ls-caps,.14em);
  text-transform:uppercase; color:var(--meta); padding:0 0 8px; border-bottom:1px solid var(--rule)}
.tokenspend td{padding:9px 0; border-bottom:1px solid var(--rule)}
.tokenspend td.num{text-align:right; font-variant-numeric:tabular-nums}
.tokenspend td.lin{color:var(--meta)}
.tokenspend th.num{text-align:right}
.tokenspend tbody tr:last-child td{border-bottom:none}
/* The 13px phone override is gone: it was a seventh size invented to shave a
   pixel, and 14px tabular figures fit 390px without it. */
/* The page h1 is for screen readers and search; the big number is the visual headline. */
.tokenspend .ts-h1{position:absolute; width:1px; height:1px; overflow:hidden; clip:rect(0 0 0 0); white-space:nowrap; margin:0}
</style>

<section class="tokenspend">

  <h1 class="ts-h1">Token spend</h1>

  <p class="eyebrow" id="ts-eyebrow">30 days to 17 September 2026</p>
  <p class="headline"><span id="ts-total">91.3</span><span class="unit">billion tokens</span></p>
  <p class="sub">
    What one person and a stack of agents actually moves through a month, and what it
    would have cost at API list rates: <b id="ts-cost">$66,325</b>.<span id="ts-cache"> Cache reads are 97% of everything read, and the stack reads about 262 tokens for every one it writes. Nothing here remembers anything between sessions, so that ratio is what continuity costs.</span>
  </p>

  <p class="sub ts-now">What on earth am I spending these tokens on? I highlight a selection on <a href="/now/">/now/</a>.</p>

  <svg id="ts-chart" data-live="fallback" tabindex="0" viewBox="0 0 800 210" role="img"
       aria-label="Daily token spend over 30 days, ending 2026-09-17">
    <defs>
      <linearGradient id="beam" x1="0" x2="1" y1="0" y2="0">
        <stop offset="0.00" stop-color="var(--s1)"/>
        <stop offset="0.20" stop-color="var(--s2)"/>
        <stop offset="0.40" stop-color="var(--s3)"/>
        <stop offset="0.60" stop-color="var(--s4)"/>
        <stop offset="0.80" stop-color="var(--s5)"/>
        <stop offset="1.00" stop-color="var(--s6)"/>
      </linearGradient>
      <linearGradient id="wash" x1="0" x2="0" y1="0" y2="1">
        <stop offset="0" stop-color="var(--display)" stop-opacity=".9"/>
        <stop offset="1" stop-color="var(--display)" stop-opacity="0"/>
      </linearGradient>
      <pattern id="ts-hatch" patternUnits="userSpaceOnUse" width="6" height="6" patternTransform="rotate(45)">
        <line x1="0" y1="0" x2="0" y2="6"/>
      </pattern>
    </defs>

    <g id="bands">
      <rect class="band" x="0" y="0" width="134" height="170" fill="var(--s1)"/>
      <rect class="band" x="133" y="0" width="134" height="170" fill="var(--s2)"/>
      <rect class="band" x="266" y="0" width="133" height="170" fill="var(--s3)"/>
      <rect class="band" x="400" y="0" width="133" height="170" fill="var(--s4)"/>
      <rect class="band" x="533" y="0" width="133" height="170" fill="var(--s5)"/>
      <rect class="band" x="666" y="0" width="133" height="170" fill="var(--s6)"/>
    </g>

    <path id="ts-area" fill="url(#wash)" opacity="0"/>
    <g id="ts-hatches" class="late"></g>
    <path id="ts-mean" stroke="var(--meta)" stroke-width="1" stroke-dasharray="3 5" opacity=".75"/>
    <path id="ts-line" fill="none" stroke="url(#beam)" stroke-width="2.6"
          stroke-linecap="round" stroke-linejoin="round"/>
    <circle id="ts-peakdot" r="4" fill="var(--s1)" class="late"/>
    <circle id="ts-enddot"  r="4" fill="var(--display)" class="late"/>
    <text id="ts-peaklbl" class="lbl hot late"></text>
    <text id="ts-meanlbl" class="lbl mean late"></text>
    <text id="ts-endlbl"  class="lbl end late"></text>
    <text id="ts-firstlbl" class="lbl late"></text>
    <g id="ts-hover" visibility="hidden" pointer-events="none">
      <line id="ts-hline" y1="20" y2="168"/>
      <circle id="ts-hdot" r="5"/>
      <rect id="ts-hbox" rx="3"/>
      <text id="ts-htext" class="tip"><tspan id="ts-hdate" class="tip-date"></tspan><tspan id="ts-hval" class="tip-val"></tspan><tspan id="ts-hvend" class="tip-vend"></tspan></text>
    </g>
    <rect id="ts-hit" x="0" y="0" width="800" height="210" fill="transparent"/>
  </svg>

  <table>
    <caption>By lineage, same 30 days</caption>
    <thead><tr><th>Lineage</th><th class="num">Tokens</th><th class="num">At list rates</th></tr></thead>
    <tbody id="ts-lineage">
      <tr><td>Anthropic</td><td class="num">86.77B</td><td class="num">$64,285</td></tr>
      <tr><td>OpenAI</td><td class="num">3.17B</td><td class="num">$1,943</td></tr>
      <tr><td>DeepSeek</td><td class="num">1.35B</td><td class="num">$53.62</td></tr>
      <tr><td>Z.ai</td><td class="num">18M</td><td class="num">$10.24</td></tr>
      <tr><td>Moonshot</td><td class="num">14M</td><td class="num">$28.36</td></tr>
      <tr><td>Google</td><td class="num">12M</td><td class="num">$5.05</td></tr>
      <tr><td>not billable</td><td class="num">8,278</td><td class="num">$0.00</td></tr>
      <tr><td>unpriced</td><td class="num">0</td><td class="num">$0.00</td></tr>
    </tbody>
  </table>

  <table>
    <caption>By model</caption>
    <thead><tr><th>Model</th><th>Lineage</th><th class="num">Tokens</th><th class="num">At list rates</th></tr></thead>
    <tbody id="ts-models">
      <tr><td>claude-opus-5</td><td class="lin">Anthropic</td><td class="num">38.62B</td><td class="num">$29,020</td></tr>
      <tr><td>claude-sonnet-5</td><td class="lin">Anthropic</td><td class="num">18.54B</td><td class="num">$5,351</td></tr>
      <tr><td>claude-fable-5-1</td><td class="lin">Anthropic</td><td class="num">17.33B</td><td class="num">$12,373</td></tr>
      <tr><td>claude-fable-5</td><td class="lin">Anthropic</td><td class="num">11.93B</td><td class="num">$17,208</td></tr>
      <tr><td>gpt-5.6-sol</td><td class="lin">OpenAI</td><td class="num">1.97B</td><td class="num">$1,058</td></tr>
      <tr><td>deepseek-flash</td><td class="lin">DeepSeek</td><td class="num">1.23B</td><td class="num">$8.88</td></tr>
      <tr><td>gpt-6-astra</td><td class="lin">OpenAI</td><td class="num">429M</td><td class="num">$611</td></tr>
      <tr><td>codex-auto-review</td><td class="lin">OpenAI</td><td class="num">401M</td><td class="num">$224</td></tr>
    </tbody>
  </table>

  <p class="source">
    This is what the work <b>would</b> have billed on the API, not money spent.
    Calculated and counted from the local records, input, output, cache writes and
    cache reads, each priced at its own rate:
  </p>
  <ul class="source">
    <li>every assistant turn in the Claude Code transcripts</li>
    <li>every <b>token_count</b> event in the Codex session rollouts</li>
    <li>every metered call the proxy logged</li>
  </ul>
  <p class="source" id="ts-asof">Chart data: 30 days ending 2026-09-17, as built.</p>

<script>
// Generated from kaibot scripts/route/token-spend-chart.js (its tests cover this
// reader). The baked numbers above and SERIES below are the fallback; the page
// re-reads LIVE_URL at load and redraws only if every check passes.
const SERIES = [
  ["2026-08-19",1.593],
  ["2026-08-20",1.557],
  ["2026-08-21",2.593],
  ["2026-08-22",0.040],
  ["2026-08-23",1.658],
  ["2026-08-24",1.653],
  ["2026-08-25",1.346],
  ["2026-08-26",1.762],
  ["2026-08-27",1.754],
  ["2026-08-28",0.771],
  ["2026-08-29",1.811],
  ["2026-08-30",5.433],
  ["2026-08-31",3.031],
  ["2026-09-01",3.482],
  ["2026-09-02",5.538],
  ["2026-09-03",4.414],
  ["2026-09-04",4.579],
  ["2026-09-05",1.466],
  ["2026-09-06",2.691],
  ["2026-09-07",1.628],
  ["2026-09-08",1.197],
  ["2026-09-09",2.834],
  ["2026-09-10",6.082],
  ["2026-09-11",8.284],
  ["2026-09-12",5.981],
  ["2026-09-13",2.362],
  ["2026-09-14",1.069],
  ["2026-09-15",5.475],
  ["2026-09-16",4.323],
  ["2026-09-17",4.927]
];
const LIVE_URL = "https://pub-d02579113fc049e6ad51cc4530cf9157.r2.dev/tokens-series.json";

const W = 800, H = 210, TOP = 26, BOT = 168;   // BOT is where the fallen bands sit
const $ = id => document.getElementById(id);
let CUR = { series: [], pts: [] };   // what draw() last drew
let DAYS = {};                        // date -> per-day record from the live payload, when it has one
let VENDORS = new Set();              // lineage names the page already prints in its table
// A machine had not reported this day yet when the payload was built.
const counting = d => !!(d && d.expected && d.reported < d.expected);
// Day number of a YYYY-MM-DD, read as UTC so a clock change can never shift a point.
const dayNo = s => Date.parse(s + "T00:00:00Z") / 864e5;
const seg = pts => pts.map((p, k) => (k ? "L" : "M") + p[0].toFixed(1) + " " + p[1].toFixed(1)).join(" ");

function draw(series) {
  const vals = series.map(d => d[1]);
  const lo = 0, hi = Math.max(...vals);
  // Points are placed by date, not by position in the list: the series drops a
  // day with no data, and a missing date has to show as a hole in the line,
  // never a bridge across it (design ruling, 2026-10-17). With every day present
  // this is the same x as before, i / (n - 1) of the width.
  const d0 = dayNo(series[0][0]), span = dayNo(series[series.length - 1][0]) - d0;
  const x = s => ((dayNo(s) - d0) / span) * W;
  const y = v => BOT - ((v - lo) / (hi - lo)) * (BOT - TOP);

  const pts = series.map(d => [x(d[0]), y(d[1])]);
  CUR = { series, pts };
  // Runs of consecutive dates, as [first, last] indexes. One run when nothing is missing.
  const runs = [];
  series.forEach((d, i) => {
    if (i && dayNo(d[0]) - dayNo(series[i - 1][0]) === 1) runs[runs.length - 1][1] = i;
    else runs.push([i, i]);
  });
  const run = i => runs.find(r => r[0] <= i && i <= r[1]);
  // The pen lifts at every missing date: one subpath per run. A one-day run is a
  // zero-length subpath, so the line's box (which the beam gradient is stretched
  // over) still spans the full width when that day is first or last, and a dot
  // below makes it visible.
  $("ts-line").setAttribute("d", runs.map(([a, b]) =>
    seg(a === b ? [pts[a], pts[a]] : pts.slice(a, b + 1))).join(" "));
  // The wash is closed down to the baseline per run, so it breaks where the line does.
  const xs = p => +p[0].toFixed(1);
  $("ts-area").setAttribute("d", runs.filter(([a, b]) => b > a).map(([a, b]) =>
    seg(pts.slice(a, b + 1)) + ` L${xs(pts[b])} ${BOT} L${xs(pts[a])} ${BOT} Z`).join(" "));
  // A day with a missing date on both sides: drawn as a dot, styled like the end dot.
  let solos = $("ts-solos");
  if (!solos) {
    solos = document.createElementNS("http://www.w3.org/2000/svg", "g");
    solos.id = "ts-solos"; solos.setAttribute("class", "late");
    $("ts-chart").insertBefore(solos, $("ts-peakdot"));
  }
  solos.replaceChildren(...runs.filter(([a, b]) => a === b).map(([a]) => {
    const c = document.createElementNS("http://www.w3.org/2000/svg", "circle");
    c.setAttribute("r", "4"); c.setAttribute("fill", "var(--display)");
    c.setAttribute("data-date", series[a][0]);
    c.setAttribute("cx", pts[a][0].toFixed(1)); c.setAttribute("cy", pts[a][1].toFixed(1));
    return c;
  }));

  // stroke-dasharray needs the real path length, so the draw animation is exact
  const el = $("ts-line");
  el.style.setProperty("--len", el.getTotalLength());

  // the 7-day mean: the "compared to what", drawn as one thin rule, labelled in place
  const last7 = vals.slice(-7);
  const mean = last7.reduce((a, b) => a + b, 0) / last7.length;
  $("ts-mean").setAttribute("d", `M0 ${y(mean).toFixed(1)} L${W} ${y(mean).toFixed(1)}`);

  // Hatch the slice of area that belongs to a day still counting: from halfway
  // to the day before, up over the point, to halfway to the day after. The
  // slice stops at the point on a side where the next date is missing, the
  // same as at either end, so it never reaches into a gap.
  const hatches = [];
  series.forEach((d, i) => {
    if (!counting(DAYS[d[0]])) return;
    const mid = j => [(pts[i][0] + pts[j][0]) / 2, (pts[i][1] + pts[j][1]) / 2];
    const [a, b] = run(i);
    const l = i > a ? mid(i - 1) : pts[i], r = i < b ? mid(i + 1) : pts[i];
    const e = document.createElementNS("http://www.w3.org/2000/svg", "path");
    e.setAttribute("class", "hatched");
    e.setAttribute("data-date", d[0]);
    e.setAttribute("d", [[l[0], BOT], l, pts[i], r, [r[0], BOT]]
      .map((p, k) => (k ? "L" : "M") + p[0].toFixed(1) + " " + p[1].toFixed(1)).join(" ") + " Z");
    hatches.push(e);
  });
  $("ts-hatches").replaceChildren(...hatches);
  // Like the readout, the hatch is scaled back up on a narrow column, or at
  // 390px its lines thin out to a grey smear.
  if (hatches.length) {
    const k = Math.max(1, W / ($("ts-chart").getBoundingClientRect().width || W));
    $("ts-hatch").setAttribute("width", (6 * k).toFixed(1));
    $("ts-hatch").setAttribute("height", (6 * k).toFixed(1));
    const ln = $("ts-hatch").firstElementChild;
    ln.setAttribute("y2", (6 * k).toFixed(1)); ln.style.strokeWidth = (1.6 * k).toFixed(1);
  }

  // direct labels: peak, last full day, first day, and the mean. No legend, no axis.
  const pi = vals.indexOf(hi);
  place("ts-peakdot", pts[pi]);
  place("ts-enddot", pts[pts.length - 1]);
  label("ts-peaklbl", pts[pi][0], pts[pi][1] - 12, `${hi.toFixed(2)}B  ${series[pi][0].slice(5)}`, "middle");
  // The date, not "today": the series ends on the last COMPLETE day, and the
  // word today would be claiming a number for a day still in progress.
  label("ts-endlbl", W, pts[pts.length - 1][1] - 12, `${vals[vals.length - 1].toFixed(2)}B  ${series[series.length - 1][0].slice(5)}`, "end");
  label("ts-meanlbl", 6, y(mean) - 7, `${mean.toFixed(2)}B  7-day mean`, "start");
  label("ts-firstlbl", 0, BOT + 18, series[0][0], "start");
}

function place(id, p) {
  const c = $(id);
  c.setAttribute("cx", p[0].toFixed(1)); c.setAttribute("cy", p[1].toFixed(1));
}
function label(id, px, py, text, anchor) {
  const t = $(id);
  t.setAttribute("x", px.toFixed(1)); t.setAttribute("y", py.toFixed(1));
  t.setAttribute("text-anchor", anchor); t.textContent = text;
}

draw(SERIES);
$("ts-chart").setAttribute("data-live", "fallback");

// ---- hover readout ---------------------------------------------------------
// Kai asked for it: point at the line, see that day's date and tokens. One point
// at a time, snapped to the nearest day. Mouse, touch and keyboard (focus the
// chart, then left/right arrows) all drive the same readout.
const SHORT = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];
let hi_ = -1;
// That day's biggest vendors by tokens, as shares of the day. Only names that
// are already in the lineage table get through, so a key the payload should
// never carry (a machine, a box) cannot reach the readout even if one appears.
function vendors(d) {
  const bl = d && d.by_lineage;
  if (!bl || typeof bl !== "object") return [];
  const rows = Object.keys(bl)
    .filter(k => VENDORS.has(k) && bl[k] && isNum(bl[k].tokens) && bl[k].tokens > 0)
    .map(k => [k, bl[k].tokens])
    .sort((a, b) => b[1] - a[1]);
  const total = rows.reduce((a, r) => a + r[1], 0);
  return rows.map(([k, t]) => {
    const pct = 100 * t / total;
    // never "100%" while something else is in the day
    const s = pct < 1 ? "<1%" : Math.min(rows.length > 1 ? 99 : 100, Math.round(pct)) + "%";
    return `${k} ${s}`;
  });
}
function vendorLine(v, top) {
  if (!v.length) return "";
  const more = v.length - top;
  return v.slice(0, top).join("  ·  ") + (more > 0 ? `  ·  +${more} more` : "");
}
function show(i) {
  const n = CUR.series.length;
  if (!n) return;
  i = Math.max(0, Math.min(n - 1, i));
  hi_ = i;
  const [date, b] = CUR.series[i], [px, py] = CUR.pts[i];
  const [yy, mm, dd] = date.split("-").map(Number);
  const wd = new Date(Date.UTC(yy, mm - 1, dd)).toLocaleDateString("en-US", { weekday: "short", timeZone: "UTC" });
  const d = DAYS[date];
  let val = `${b.toFixed(2)}B tokens`;
  if (d && typeof d.cost_usd === "number") val += `  \u00b7  ${humanMoney(d.cost_usd)}`;
  if (counting(d)) val += "  \u00b7  still counting";
  $("ts-hdate").textContent = `${wd} ${dd} ${SHORT[mm - 1]} ${yy}`;
  $("ts-hval").textContent = val;
  $("ts-hline").setAttribute("x1", px.toFixed(1)); $("ts-hline").setAttribute("x2", px.toFixed(1));
  place("ts-hdot", [px, py]);
  // The chart scales with its column, so on a phone 12 viewBox units is about 6px.
  // Scale the readout back up so it reads at roughly 12px whatever the width.
  const k = Math.max(1, W / ($("ts-chart").getBoundingClientRect().width || W));
  const t = $("ts-htext"), PAD = 8 * k, LH = 15 * k;
  t.style.fontSize = (12 * k).toFixed(1) + "px";
  $("ts-hdot").setAttribute("r", (5 * Math.min(k, 1.6)).toFixed(1));
  $("ts-hdate").setAttribute("x", 0); $("ts-hdate").setAttribute("dy", 0);
  $("ts-hval").setAttribute("x", 0); $("ts-hval").setAttribute("dy", LH);
  $("ts-hvend").setAttribute("x", 0); $("ts-hvend").setAttribute("dy", LH);
  const g = $("ts-hover"); g.setAttribute("visibility", "visible");
  // Top three, unless that line will not fit the chart's width; then two, then one.
  const v = vendors(d);
  let bb;
  for (let top = Math.min(3, v.length); ; top--) {
    $("ts-hvend").textContent = vendorLine(v, top);
    bb = t.getBBox();
    if (top <= 1 || bb.width + PAD * 2 <= W) break;
  }
  const lines = v.length ? 3 : 2;
  const bw = bb.width + PAD * 2, bh = LH * lines + PAD;
  const bx = Math.max(0, Math.min(W - bw, px - bw / 2));
  const by = py - bh - 12 < -30 ? py + 14 : py - bh - 12;   // above the point, unless that leaves the chart
  $("ts-hbox").setAttribute("x", bx.toFixed(1)); $("ts-hbox").setAttribute("y", by.toFixed(1));
  $("ts-hbox").setAttribute("width", bw.toFixed(1)); $("ts-hbox").setAttribute("height", bh.toFixed(1));
  t.setAttribute("transform", `translate(${(bx + PAD).toFixed(1)} ${(by + PAD + 10 * k).toFixed(1)})`);
}
function hide() { hi_ = -1; $("ts-hover").setAttribute("visibility", "hidden"); }
function nearest(ev) {
  const svg = $("ts-chart"), m = svg.getScreenCTM();
  if (!m) return -1;
  const pt = svg.createSVGPoint(); pt.x = ev.clientX; pt.y = ev.clientY;
  const vx = pt.matrixTransform(m.inverse()).x;
  // The nearest day that has data, by x. Points sit by date, so a pointer over a
  // missing date lands on the closer real day either side of it. A tie goes to
  // the later day, as Math.round did when every day was evenly spaced.
  let best = 0;
  CUR.pts.forEach((p, i) => { if (Math.abs(p[0] - vx) <= Math.abs(CUR.pts[best][0] - vx)) best = i; });
  return best;
}
$("ts-hit").addEventListener("pointermove", ev => show(nearest(ev)));
$("ts-hit").addEventListener("pointerdown", ev => show(nearest(ev)));
$("ts-hit").addEventListener("pointerleave", ev => { if (ev.pointerType === "mouse") hide(); });
$("ts-chart").addEventListener("focus", () => show(hi_ < 0 ? CUR.series.length - 1 : hi_));
$("ts-chart").addEventListener("blur", hide);
$("ts-chart").addEventListener("keydown", ev => {
  if (ev.key === "ArrowLeft") { show(hi_ - 1); ev.preventDefault(); }
  else if (ev.key === "ArrowRight") { show(hi_ + 1); ev.preventDefault(); }
  else if (ev.key === "Escape") { hide(); }
});
$("ts-chart").setAttribute("data-live", "fallback");


// ---- live reader -----------------------------------------------------------

const MONTHS = ["January", "February", "March", "April", "May", "June", "July",
  "August", "September", "October", "November", "December"];

// Same rounding rules as human_tokens / human_money in build-sparkline.py.
function humanTokens(n) {
  if (n >= 1e9) return (n / 1e9).toFixed(2) + "B";
  if (n >= 1e6) return Math.round(n / 1e6) + "M";
  return n.toLocaleString("en-US");
}
function humanMoney(d) {
  return d >= 100
    ? "$" + Math.round(d).toLocaleString("en-US")
    : "$" + d.toFixed(2);
}

const isDay = s => typeof s === "string" && /^\d{4}-\d{2}-\d{2}$/.test(s);
const isNum = n => typeof n === "number" && isFinite(n) && n >= 0;

// Throws on anything the page cannot draw honestly; the caller treats a throw
// as "keep the baked render".
function check(p) {
  if (!p || !Array.isArray(p.series) || p.series.length < 2) throw new Error("series");
  p.series.forEach((d, i) => {
    if (!Array.isArray(d) || !isDay(d[0]) || !isNum(d[1])) throw new Error("row");
    // x comes from the date, so the dates have to be real and strictly rising
    if (!isFinite(dayNo(d[0])) || (i && !(d[0] > p.series[i - 1][0]))) throw new Error("order");
  });
  if (Math.max(...p.series.map(d => d[1])) <= 0) throw new Error("flat");
  if (!isDay((p.generated_at || "").slice(0, 10))) throw new Error("generated_at");
  // A stale object must not regress a page that was built from newer data.
  if (p.series[p.series.length - 1][0] < SERIES[SERIES.length - 1][0]) throw new Error("older");
  if (!isNum(p.total_cost_usd)) throw new Error("total_cost_usd");
  if (!Array.isArray(p.lineage) || !Array.isArray(p.models)) throw new Error("tables");
}

function fillRows(tbodyId, rows, cells) {
  const tb = $(tbodyId);
  const trs = rows.map(r => {
    const tr = document.createElement("tr");
    cells.forEach(([cls, f]) => {
      const td = document.createElement("td");
      if (cls) td.className = cls;
      td.textContent = f(r);
      tr.appendChild(td);
    });
    return tr;
  });
  tb.replaceChildren(...trs);
}

function paint(p) {
  const s = p.series, last = s[s.length - 1][0];
  DAYS = {};
  for (const d of (Array.isArray(p.days) ? p.days : [])) if (d && isDay(d.date)) DAYS[d.date] = d;
  VENDORS = new Set(p.lineage.filter(r => Array.isArray(r) && typeof r[0] === "string").map(r => r[0]));
  const [yy, mm, dd] = last.split("-").map(Number);
  draw(s);
  $("ts-eyebrow").textContent = `${s.length} days to ${dd} ${MONTHS[mm - 1]} ${yy}`;
  $("ts-total").textContent = s.reduce((a, d) => a + d[1], 0).toFixed(1);
  $("ts-cost").textContent = humanMoney(p.total_cost_usd);
  // Same sentence as build-sparkline.py; dropped, not zeroed, when the keys are absent.
  const c = p.cache;
  $("ts-cache").textContent = c && c.input_tokens && c.output_tokens && c.cache_read_tokens
    ? ` Cache reads are ${(100 * c.cache_read_tokens / c.input_tokens).toFixed(0)}% of everything read, ` +
      `and the stack reads about ${Math.round(c.input_tokens / c.output_tokens).toLocaleString("en-US")} ` +
      "tokens for every one it writes. Nothing here remembers anything between sessions, " +
      "so that ratio is what continuity costs."
    : "";
  $("ts-chart").setAttribute("aria-label", `Daily token spend over ${s.length} days, ending ${last}`);
  $("ts-asof").textContent =
    `Chart data: ${s.length} days ending ${last}, as published ${p.generated_at.slice(0, 10)}.` +
    (s.some(d => counting(DAYS[d[0]])) ? " Hatched: still counting." : "");
  fillRows("ts-lineage", p.lineage, [
    ["", r => r[0]], ["num", r => humanTokens(r[1])], ["num", r => humanMoney(r[2])]]);
  fillRows("ts-models", p.models, [
    ["", r => r[0]], ["lin", r => r[1]], ["num", r => humanTokens(r[2])], ["num", r => humanMoney(r[3])]]);
  $("ts-chart").setAttribute("data-live", "live");
}

if (typeof fetch === "function") {
  fetch(LIVE_URL, { mode: "cors", credentials: "omit" })
    .then(r => { if (!r.ok) throw new Error("http " + r.status); return r.json(); })
    .then(p => { check(p); paint(p); })
    .catch(() => { /* keep the baked render; data-live="fallback" is the trace */ });
}
</script>
</section>

