// The task board: one self-contained HTML page built from the band's CSV ledgers. // No server and no network: inline CSS, inline SVG and a few lines of script. import { LOGO_PATHS, LOGO_VIEWBOX } from './logo' export const duration = (minutes: number) => minutes < 60 ? `${minutes}m` : `${Math.floor(minutes / 60)}h ${minutes % 60}m` // Agent time reads better with seconds while it is short. export const elapsed = (ms: number) => { const s = Math.round(ms / 1000) if (s < 60) return `${s}s` if (s < 3600) return `${Math.floor(s / 60)}m ${s % 60}s` return duration(Math.round(s / 60)) } export const fmtTokens = (n: number) => n < 1000 ? `${n}` : n < 1e6 ? `${+(n / 1e3).toFixed(1)}k` : `${+(n / 1e6).toFixed(1)}M` export type Task = { started: string project: string name: string cost: number turns: number agentMinutes: number tokens: number reuse: number | null tests: string level: string model: string } // RFC 4180 rows: quoted fields may hold commas, doubled quotes and line breaks. export function parseCsv(text: string): string[][] { const rows: string[][] = [] let row: string[] = [] let field = '' let quoted = false for (let i = 0; i < text.length; i++) { const c = text[i] if (quoted) { if (c === '"' && text[i + 1] === '"') { field += '"' i++ } else if (c === '"') quoted = false else field += c } else if (c === '"') quoted = true else if (c === ',') { row.push(field) field = '' } else if (c === '\n' || c === '\r') { if (c === '\r' && text[i + 1] === '\n') i++ row.push(field) if (row.some(f => f !== '')) rows.push(row) row = [] field = '' } else field += c } row.push(field) if (row.some(f => f !== '')) rows.push(row) return rows } export function tasksFrom(text: string): Task[] { const [head, ...rows] = parseCsv(text) if (!head?.includes('key')) return [] const at = (row: string[], col: string) => row[head.indexOf(col)] ?? '' const num = (row: string[], col: string) => Number(at(row, col)) || 0 return rows.map(row => ({ started: at(row, 'started'), project: at(row, 'project'), name: at(row, 'name'), cost: num(row, 'cost_usd'), turns: num(row, 'turns'), agentMinutes: num(row, 'agent_minutes'), tokens: num(row, 'tokens_total'), reuse: at(row, 'reuse_pct') === '' ? null : num(row, 'reuse_pct'), tests: at(row, 'tests'), level: at(row, 'level'), model: at(row, 'model'), })) } const esc = (s: string) => s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"') const day = (ms: number) => { const d = new Date(ms) return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}` } // Project colours, in sidebar order; the share card carries a gradient for each. const PALETTE = ['#5fe08a', '#7c8cff', '#2fd4c0', '#ff8a3d', '#ff6fb5', '#ffd166', '#4cc3ff', '#b26bff'] const LIGHT = 'color-scheme:light;--bg:#f5f5f3;--panel:#fff;--card:#fff;--line:#ebeae6;--line2:#dedcd6;--text:#1d1c1a;--muted:#77756f;--faint:#a9a7a0;--track:#f2f1ed;--segOn:#fff;--tip:#1d1c1a;--tipText:#fff;--ok:#2f7d3b;--okbg:#e8f5e9;--bad:#b4401b;--badbg:#fcebe4;--warn:#946200;--warnbg:#fbf2d7;--nonebg:#f2f1ed;--empty:#eeede9;--tint:.12;--glow:.45;--shadow:0 1px 2px rgba(25,25,15,.05)' const MONO_CSS = '"JetBrains Mono",ui-monospace,SFMono-Regular,Menlo,monospace' // Dark is the default; light (chosen, or the system's when "system" is chosen) keeps the same layout. const CSS = `:root{color-scheme:dark;--bg:#060606;--panel:#0c0c0c;--card:linear-gradient(180deg,#0f0f0f,#090909);--line:#1c1c1b;--line2:#262624;--text:#f3f2ee;--muted:#8d8b84;--faint:#5e5c55;--track:#141413;--segOn:#1f1f1d;--tip:#050505;--tipText:#f3f2ee;--ok:#5fe08a;--okbg:#122016;--bad:#ff6b5b;--badbg:#2a1210;--warn:#f1c96b;--warnbg:#2a2412;--nonebg:#161615;--empty:#151514;--tint:.2;--glow:1;--shadow:inset 0 1px 0 rgba(255,255,255,.035),0 20px 50px rgba(0,0,0,.35)} :root[data-theme=light]{${LIGHT}} @media (prefers-color-scheme:light){:root[data-theme=system]{${LIGHT}}} *{box-sizing:border-box}html,body{margin:0}body{background:var(--bg);color:var(--text);font:14px/1.5 Inter,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;-webkit-font-smoothing:antialiased} .num{font-family:${MONO_CSS};font-variant-numeric:tabular-nums;letter-spacing:-.03em} .sprite{position:absolute;width:0;height:0;overflow:hidden} .app{display:grid;grid-template-columns:264px minmax(0,1fr);min-height:100vh} .side{border-right:1px solid var(--line);padding:24px 16px;display:flex;flex-direction:column;gap:2px;position:sticky;top:0;height:100vh} .brand{display:flex;align-items:center;gap:10px;font:600 15px/1 ${MONO_CSS};padding:0 10px 24px} .logo{width:28px;height:32px;flex:none}.logo svg{width:100%;height:100%;display:block} .side-label{font-size:11px;letter-spacing:.08em;text-transform:uppercase;color:var(--faint);padding:6px 10px} .nav-item{display:flex;align-items:center;gap:10px;width:100%;border:0;background:transparent;color:var(--muted);font:inherit;padding:8px 10px;border-radius:10px;cursor:pointer;text-align:left} .nav-item .name{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .nav-item:hover,.nav-item.on{background:var(--track);color:var(--text)} .nav-item .n{font:12px ${MONO_CSS};color:var(--faint)} .dot{width:8px;height:8px;border-radius:50%;flex:none;background:var(--c);box-shadow:0 0 10px var(--c)} .side-foot{margin-top:auto;display:flex;align-items:center;gap:10px;font-size:12px;color:var(--faint);padding:12px 2px 0 10px;border-top:1px solid var(--line)}.foot-text{flex:1;min-width:0}.foot-sep{width:1px;align-self:stretch;margin:4px 0;background:var(--line)} .theme{display:grid;place-items:center;width:34px;height:34px;flex:none;border:0;border-radius:9px;background:transparent;color:var(--muted);cursor:pointer}.theme:hover{background:var(--track);color:var(--text)}.theme svg{display:none}.theme[data-choice=system] .i-system,.theme[data-choice=light] .i-light,.theme[data-choice=dark] .i-dark{display:block} main{padding:26px 32px 56px;min-width:0;max-width:1400px;width:100%;margin:0 auto} .top{display:flex;justify-content:space-between;align-items:flex-end;gap:16px;margin-bottom:22px;flex-wrap:wrap} .crumb{color:var(--faint);font-size:13px}.crumb b{color:var(--muted);font-weight:500} h1{margin:2px 0 0;font-size:26px;font-weight:650;letter-spacing:-.02em}.top p{margin:2px 0 0;color:var(--muted)} .actions{display:flex;gap:10px;align-items:center} .seg{display:inline-flex;background:var(--track);border:1px solid var(--line);border-radius:12px;padding:3px;flex:none} .seg button{border:0;background:transparent;color:var(--muted);font:inherit;font-size:13px;padding:6px 12px;border-radius:9px;cursor:pointer} .seg button.on{background:var(--segOn);color:var(--text);box-shadow:inset 0 1px 0 rgba(255,255,255,.06),0 0 0 1px var(--line2)} .btn{border:1px solid var(--line2);background:var(--track);color:var(--text);font:inherit;font-size:13px;padding:8px 14px;border-radius:11px;cursor:pointer}.btn:hover{border-color:var(--faint)} .card{background:var(--card);border:1px solid var(--line);border-radius:22px;box-shadow:var(--shadow)} .kpis{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:14px;margin-bottom:14px} .kpi{padding:18px 20px 0;position:relative;overflow:hidden} .kpi::after{content:"";position:absolute;left:0;right:0;bottom:0;height:75%;background:radial-gradient(120% 100% at 50% 135%,var(--k),transparent 70%);opacity:var(--tint);pointer-events:none} .kpi-label{display:flex;align-items:center;gap:8px;color:var(--muted);font-size:13px} .kpi-label::before{content:"";width:7px;height:7px;border-radius:50%;background:var(--k);box-shadow:0 0 10px var(--k)} .kpi-value{font-size:30px;margin-top:4px;white-space:nowrap} .delta{font:12px ${MONO_CSS};margin-left:8px;vertical-align:6px}.up{color:var(--ok)}.down{color:var(--bad)} .kpi-note{color:var(--faint);font-size:12px} .spark{display:block;width:calc(100% + 40px);height:58px;margin:10px -20px 0;position:relative;z-index:1} .glow-line{opacity:calc(var(--glow) * .8)} .chart-card{padding:22px 24px 18px;margin-bottom:14px} .chart-head{display:flex;justify-content:space-between;align-items:flex-start;gap:12px;flex-wrap:wrap} .label{color:var(--muted);font-size:13px}.big{font-size:40px;line-height:1.15;margin-top:2px} .chart-wrap{position:relative;margin-top:6px} #stream{display:block;width:100%;height:340px;overflow:visible} .axis{fill:var(--faint);font:11px ${MONO_CSS}}.base{stroke:var(--line2)}.empty-chart{fill:var(--faint);font-size:13px} .glow{opacity:calc(var(--glow) * .14)}.top-line{stroke:var(--text);stroke-opacity:.35;fill:none} .hover-line{stroke:var(--text);stroke-opacity:.5} .tip{position:absolute;pointer-events:none;background:var(--tip);color:var(--tipText);border:1px solid var(--line2);border-radius:12px;padding:7px 11px;font-size:12px;transform:translate(-50%,calc(-100% - 12px));white-space:nowrap;box-shadow:0 10px 30px rgba(0,0,0,.35)} .tip-date{opacity:.65}.tip-value{font:600 13px ${MONO_CSS}} .legend{display:flex;gap:8px;flex-wrap:wrap;margin-top:8px} .chip{display:inline-flex;align-items:center;gap:8px;padding:6px 10px;border-radius:10px;background:var(--track);border:1px solid var(--line);color:var(--text);font:inherit;font-size:13px;cursor:pointer} .chip .x{color:var(--faint)}.chip[aria-pressed=false]{opacity:.45} .grid2{display:grid;grid-template-columns:minmax(0,1.4fr) minmax(0,1fr);gap:14px;align-items:start} .list{padding:20px 0 6px} .list-head{display:flex;justify-content:space-between;align-items:center;gap:12px;padding:0 22px 14px} h3{margin:0;font-size:15px;font-weight:600}h3 .n{color:var(--faint);font-weight:400;margin-left:4px} .search{display:flex;align-items:center;gap:8px;background:var(--track);border:1px solid var(--line);border-radius:10px;padding:6px 10px;width:220px;color:var(--faint)} .search input{border:0;outline:0;background:transparent;color:var(--text);font:inherit;font-size:13px;width:100%} .table-wrap{overflow-x:auto} table{width:100%;border-collapse:collapse;font-size:13px} th{text-align:left;font-weight:400;color:var(--faint);font-size:12px;padding:0 10px 10px;white-space:nowrap} th:first-child,td:first-child{padding-left:22px}th:last-child,td:last-child{padding-right:22px} td{padding:11px 10px;border-top:1px solid var(--line);white-space:nowrap;vertical-align:middle} th.r,td.r{text-align:right} td.name{white-space:normal;min-width:140px;width:100%;max-width:0}.task{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.sub{font-size:12px;color:var(--faint)} .bar{height:3px;border-radius:2px;margin-top:6px;background:linear-gradient(90deg,var(--c),transparent)} .pill{display:inline-flex;align-items:center;gap:6px;font-size:12px;padding:3px 9px;border-radius:999px;background:var(--nonebg);color:var(--muted)} .pill i{width:6px;height:6px;border-radius:50%;background:currentColor} .pill.ok{background:var(--okbg);color:var(--ok)}.pill.bad{background:var(--badbg);color:var(--bad)}.pill.warn{background:var(--warnbg);color:var(--warn)} td.empty{text-align:center;color:var(--faint);padding:32px} .heat{padding:20px 22px}.heat .sub{margin:2px 0 14px} #heat{display:block;width:100%;height:auto}.cell-empty{fill:var(--empty)}.glow-cell{opacity:calc(var(--glow) * .45)}.day{fill:var(--faint);font:12px ${MONO_CSS}} .heroes{display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-top:20px}.hero-label{display:flex;align-items:center;gap:7px;color:var(--muted);font-size:12px}.hero-label i{font-style:normal;font-size:11px}.hero-value{font-size:30px;margin-top:4px;white-space:nowrap}.hero-sub{font-size:12px;color:var(--faint);margin-top:2px}.hero-sub b{color:var(--ok);font-weight:500;margin-right:6px} #facts{margin-top:16px}.fact{display:flex;justify-content:space-between;gap:12px;padding:11px 0;border-top:1px solid var(--line);color:var(--muted)}.fact .num{color:var(--text)} footer{color:var(--faint);font-size:12px;margin-top:22px} dialog{border:1px solid var(--line);border-radius:18px;padding:0;background:var(--panel);color:var(--text);box-shadow:0 24px 70px rgba(0,0,0,.4);width:min(840px,92vw)} dialog::backdrop{background:rgba(0,0,0,.55)} .dlg-head{display:flex;justify-content:space-between;align-items:center;gap:12px;padding:14px 18px;border-bottom:1px solid var(--line)} .dlg-actions{display:flex;gap:8px}.dlg-body{padding:18px}.dlg-body svg{width:100%;height:auto;display:block;border-radius:12px} @media (max-width:1360px){.kpis{grid-template-columns:repeat(2,minmax(0,1fr))}} @media (max-width:1100px){.grid2{grid-template-columns:minmax(0,1fr)}} @media (max-width:860px){.app{grid-template-columns:minmax(0,1fr)}.side{position:static;height:auto;flex-direction:row;flex-wrap:wrap;align-items:center;border-right:0;border-bottom:1px solid var(--line)} .side-label,.foot-text,.foot-sep{display:none}.side-foot{margin:0 0 0 auto;padding:0;border:0}.brand{padding:2px 10px}#projects{display:flex;gap:4px;flex-wrap:wrap}.nav-item{width:auto} main{padding:16px}.search{width:160px}.big{font-size:32px}} @media (max-width:520px){.kpis{grid-template-columns:minmax(0,1fr)}.top .actions{width:100%;justify-content:space-between}.list-head{flex-wrap:wrap}.search{width:100%}}` // The page draws itself from the JSON block: project picker, period, charts and table need no reload. // Names only ever reach the page as text (textContent), never as HTML. const APP = `(function () { var D = JSON.parse(document.getElementById('board-data').textContent) var tasks = D.tasks, now = D.now, DAY = 86400000, NS = 'http://www.w3.org/2000/svg' var state = { project: '', metric: 'time', q: '', period: 30, hidden: {} } var MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'] var PALETTE = ${JSON.stringify(PALETTE)} // Each KPI its own colour pair, apart from the project colours: the line runs from one to the other. var KPI = [['sessions', '#4cc3ff', '#7c8cff'], ['time', '#2fd4c0', '#5fe08a'], ['cost', '#ffd166', '#ff8a3d'], ['tokens', '#ff6fb5', '#b26bff']] function byId(id) { return document.getElementById(id) } function h(tag, cls, text) { var e = document.createElement(tag); if (cls) e.className = cls; if (text != null) e.textContent = text; return e } function svg(tag, attrs, parent) { var e = document.createElementNS(NS, tag); for (var k in attrs) e.setAttribute(k, attrs[k]); if (parent) parent.append(e); return e } function two(n) { return (n < 10 ? '0' : '') + n } function dayKey(ms) { var d = new Date(ms); return d.getFullYear() + '-' + two(d.getMonth() + 1) + '-' + two(d.getDate()) } // claude-opus-5-5 reads as Opus 5.5; a trailing date in the id is dropped. function modelName(m) { var p = m.replace(/^claude-/, '').split('-'); return p[0] ? p[0][0].toUpperCase() + p[0].slice(1) + (p.length > 1 ? '\\u00a0' + p.slice(1).filter(function (x) { return x.length < 3 }).join('.') : '') : '' } function shortDate(key) { return MONTHS[+key.slice(5, 7) - 1] + ' ' + +key.slice(8, 10) } function dur(min) { return min < 60 ? min + 'm' : Math.floor(min / 60) + 'h ' + (min % 60) + 'm' } function time(ms) { var s = Math.round(ms / 1000); return s < 60 ? s + 's' : s < 3600 ? Math.floor(s / 60) + 'm ' + (s % 60) + 's' : dur(Math.round(s / 60)) } function tok(n) { return n < 1000 ? String(Math.round(n)) : n < 1e6 ? +(n / 1e3).toFixed(1) + 'k' : +(n / 1e6).toFixed(1) + 'M' } function money(n) { return '$' + n.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) } function plural(n, word) { return n + ' ' + word + (n === 1 ? '' : 's') } function add(a, b) { return a + b } var METRICS = { sessions: { label: 'Sessions', pick: function () { return 1 }, fmt: String }, time: { label: 'Agent time', pick: function (t) { return t.agentMinutes * 60000 }, fmt: time }, cost: { label: 'Cost', pick: function (t) { return t.cost }, fmt: money }, tokens: { label: 'Tokens', pick: function (t) { return t.tokens }, fmt: tok } } function total(list, pick) { return list.map(pick).reduce(add, 0) } function latest(name) { return tasks.filter(function (t) { return t.project === name }).reduce(function (m, t) { return t.started > m ? t.started : m }, '') } var names = Array.from(new Set(tasks.map(function (t) { return t.project }))).sort(function (a, b) { return latest(b).localeCompare(latest(a)) }) function colorOf(name) { return PALETTE[names.indexOf(name) % PALETTE.length] } function scope() { return state.project ? tasks.filter(function (t) { return t.project === state.project }) : tasks } function first(list) { return list.reduce(function (m, t) { return !m || t.started < m ? t.started : m }, '') } // Days the chosen period covers; "All" reaches back to the first session on screen. function span() { if (state.period) return state.period var f = first(scope()) return f ? Math.max(7, Math.round((now - new Date(f.slice(0, 10) + 'T00:00').getTime()) / DAY) + 1) : 30 } function days(n) { var out = []; for (var i = n - 1; i >= 0; i--) out.push(dayKey(now - i * DAY)); return out } function inDays(list, keys) { return list.filter(function (t) { return keys.indexOf(t.started.slice(0, 10)) >= 0 }) } function perDay(list, keys, pick) { return keys.map(function (k) { return total(list.filter(function (t) { return t.started.slice(0, 10) === k }), pick) }) } function avg(values, r) { return values.map(function (_, i) { var a = values.slice(Math.max(0, i - r), i + r + 1); return a.reduce(add, 0) / a.length }) } // Monotone cubic through the points (as d3's curveMonotoneX): flat at peaks and valleys, never past a point. function smooth(p) { var n = p.length, m = [], d = 'M' + p[0][0].toFixed(1) + ',' + p[0][1].toFixed(1) function slope(i) { return (p[i + 1][1] - p[i][1]) / (p[i + 1][0] - p[i][0]) } if (n < 3) return n < 2 ? d : d + 'L' + p[1][0].toFixed(1) + ',' + p[1][1].toFixed(1) for (var i = 1; i < n - 1; i++) { var s0 = slope(i - 1), s1 = slope(i) m[i] = (Math.sign(s0) + Math.sign(s1)) * Math.min(Math.abs(s0), Math.abs(s1), Math.abs(s0 + s1) / 4) || 0 } m[0] = (3 * slope(0) - m[1]) / 2; m[n - 1] = (3 * slope(n - 2) - m[n - 2]) / 2 for (i = 0; i < n - 1; i++) { var dx = (p[i + 1][0] - p[i][0]) / 3 d += 'C' + (p[i][0] + dx).toFixed(1) + ',' + (p[i][1] + m[i] * dx).toFixed(1) + ' ' + (p[i + 1][0] - dx).toFixed(1) + ',' + (p[i + 1][1] - m[i + 1] * dx).toFixed(1) + ' ' + p[i + 1][0].toFixed(1) + ',' + p[i + 1][1].toFixed(1) } return d } function gradient(parent, id, x2, y2, stops) { var g = svg('linearGradient', { id: id, gradientUnits: 'userSpaceOnUse', x1: 0, y1: 0, x2: x2, y2: y2 }, parent) stops.forEach(function (s) { svg('stop', { offset: s[0], 'stop-color': s[1], 'stop-opacity': s[2] }, g) }) } function blur(parent, id, sd) { svg('feGaussianBlur', { stdDeviation: sd }, svg('filter', { id: id, x: '-20%', y: '-50%', width: '140%', height: '200%' }, parent)) } function sidebar() { var nav = byId('projects'); nav.textContent = '' ;[['', 'All projects', tasks.length]].concat(names.map(function (n) { return [n, n, tasks.filter(function (t) { return t.project === n }).length] })).forEach(function (p) { var b = h('button', 'nav-item' + (state.project === p[0] ? ' on' : '')); b.type = 'button'; b.title = p[1] if (p[0]) { var d = h('span', 'dot'); d.style.setProperty('--c', colorOf(p[0])); b.append(d) } b.append(h('span', 'name', p[1]), h('span', 'n', String(p[2]))) b.addEventListener('click', function () { state.project = p[0]; state.hidden = {}; render() }) nav.append(b) }) } function header() { var list = scope(), name = state.project || 'All projects', f = first(list) byId('crumb').textContent = name byId('title').textContent = name byId('subtitle').textContent = plural(list.length, 'session') + (f ? ' since ' + shortDate(f.slice(0, 10)) + ', ' + f.slice(0, 4) : '') } function sparkline(values, i, k) { var W = 300, H = 58, s = svg('svg', { viewBox: '0 0 300 58', preserveAspectRatio: 'none', 'class': 'spark', 'aria-hidden': 'true' }) var vals = values.length > 14 ? avg(values, 2) : values, max = Math.max.apply(null, vals.concat([0])) if (max <= 0) return s var defs = svg('defs', {}, s) gradient(defs, 'kl' + i, W, 0, [[0, k[1], 1], [1, k[2], 1]]) gradient(defs, 'kf' + i, 0, H, [[0, k[2], .38], [1, k[1], 0]]) blur(defs, 'kg' + i, 5) var line = smooth(vals.map(function (v, j) { return [j * W / Math.max(1, vals.length - 1), H - 6 - v / max * (H - 16)] })) svg('path', { d: line + 'L' + W + ',' + H + 'L0,' + H + 'Z', fill: 'url(#kf' + i + ')' }, s) svg('path', { d: line, fill: 'none', stroke: 'url(#kl' + i + ')', 'stroke-width': 2, filter: 'url(#kg' + i + ')', 'class': 'glow-line', 'vector-effect': 'non-scaling-stroke' }, s) svg('path', { d: line, fill: 'none', stroke: 'url(#kl' + i + ')', 'stroke-width': 1.8, 'vector-effect': 'non-scaling-stroke' }, s) return s } function kpis() { var list = scope(), n = span(), keys = days(n), wrap = byId('kpis'); wrap.textContent = '' var cur = inDays(list, keys), prev = state.period ? inDays(list, days(2 * n).slice(0, n)) : [] KPI.forEach(function (k, i) { var M = METRICS[k[0]], v = total(cur, M.pick), p = total(prev, M.pick) var card = h('div', 'card kpi'), value = h('div', 'kpi-value num', M.fmt(v)); card.style.setProperty('--k', k[2]) if (p > 0) { var pct = Math.round((v - p) / p * 100); value.append(h('span', 'delta ' + (pct >= 0 ? 'up' : 'down'), (pct >= 0 ? '↑' : '↓') + Math.abs(pct) + '%')) } var note = !state.period ? 'all time' : p > 0 ? 'vs previous ' + n + ' days' : 'last ' + n + ' days' card.append(h('div', 'kpi-label', M.label), value, h('div', 'kpi-note', note), sparkline(perDay(list, keys, M.pick), i, k)) wrap.append(card) }) } function chart() { var list = scope(), n = span(), keys = days(n), M = METRICS[state.metric], box = byId('stream'), tip = byId('tip'); tip.hidden = true var W = Math.max(320, Math.round(box.getBoundingClientRect().width) || 900), H = 340, top = 66, base = 282 box.setAttribute('viewBox', '0 0 ' + W + ' ' + H); box.textContent = '' var defs = svg('defs', {}, box); blur(defs, 'sglow', 10) var shown = names.filter(function (p) { return (!state.project || p === state.project) && list.some(function (t) { return t.project === p }) }) var projects = shown.filter(function (p) { return !state.hidden[p] }) var cur = inDays(list, keys).filter(function (t) { return !state.hidden[t.project] }) byId('chart-sub').textContent = M.label + (state.period ? ', last ' + n + ' days' : ', all time') byId('chart-total').textContent = M.fmt(total(cur, M.pick)) legend(shown) var x = function (i) { return n > 1 ? i * W / (n - 1) : W / 2 } svg('line', { x1: 0, x2: W, y1: base + 22, y2: base + 22, 'class': 'base' }, box) for (var w = 0, step = Math.max(1, Math.ceil(n / 6)); w < n; w += step) svg('text', { x: x(w), y: H - 6, 'class': 'axis', 'text-anchor': w ? 'middle' : 'start' }, box).textContent = shortDate(keys[w]) var raw = projects.map(function (p) { return perDay(cur.filter(function (t) { return t.project === p }), keys, M.pick) }) // Real day values: the curve passes through them, nothing is spread to neighbouring days. var layers = raw var totals = keys.map(function (_, i) { return layers.reduce(function (a, r) { return a + r[i] }, 0) }) var max = Math.max.apply(null, totals.concat([0])) if (max <= 0) { svg('text', { x: W / 2, y: (top + base) / 2, 'text-anchor': 'middle', 'class': 'empty-chart' }, box).textContent = 'No sessions in this period'; return } var y = function (v) { return base - v / max * (base - top) }, acc = keys.map(function () { return 0 }), shapes = [] layers.forEach(function (r, li) { var lower = acc, upper = acc.map(function (a, i) { return a + r[i] }), c = colorOf(projects[li]); acc = upper gradient(defs, 'sf' + li, 0, base, [[0, c, .95], [1, c, .55]]) var up = smooth(upper.map(function (v, i) { return [x(i), y(v)] })), low = smooth(lower.map(function (v, i) { return [x(i), y(v)] }).reverse()) shapes.push({ d: up + 'L' + low.slice(1) + 'Z', fill: 'url(#sf' + li + ')', top: up }) }) var last = shapes[shapes.length - 1] svg('path', { d: last.top + 'L' + W + ',' + base + 'L0,' + base + 'Z', fill: '#5fe08a', filter: 'url(#sglow)', 'class': 'glow' }, box) shapes.forEach(function (s) { svg('path', { d: s.d, fill: s.fill }, box) }) svg('path', { d: last.top, 'class': 'top-line', 'stroke-width': 1.2 }, box) // One dot per session on the axis, at its hour of the day. cur.forEach(function (t) { var k = keys.indexOf(t.started.slice(0, 10)), hour = (+t.started.slice(11, 13) || 12) / 24 svg('circle', { cx: Math.min(W - 4, Math.max(4, x(k) + (hour - .5) * W / n)), cy: base + 22, r: 3.2, fill: colorOf(t.project) }, box) }) var hover = svg('line', { y1: top - 12, y2: base, 'class': 'hover-line', visibility: 'hidden' }, box) box.onmousemove = function (e) { var r = box.getBoundingClientRect(), i = Math.max(0, Math.min(n - 1, Math.round((e.clientX - r.left) / r.width * (n - 1)))) hover.setAttribute('x1', x(i)); hover.setAttribute('x2', x(i)); hover.setAttribute('visibility', 'visible') tip.textContent = ''; tip.append(h('div', 'tip-date', shortDate(keys[i])), h('div', 'tip-value', M.fmt(raw.reduce(function (a, rr) { return a + rr[i] }, 0)))) tip.style.left = x(i) / W * r.width + 'px'; tip.style.top = y(totals[i]) / H * r.height + 'px'; tip.hidden = false } box.onmouseleave = function () { hover.setAttribute('visibility', 'hidden'); tip.hidden = true } } function legend(shown) { var box = byId('legend'); box.textContent = '' if (shown.length < 2) return shown.forEach(function (p) { var b = h('button', 'chip'), d = h('span', 'dot'); b.type = 'button'; b.setAttribute('aria-pressed', String(!state.hidden[p])); d.style.setProperty('--c', colorOf(p)) b.append(d, document.createTextNode(p), h('span', 'x', state.hidden[p] ? '+' : '×')) b.addEventListener('click', function () { state.hidden[p] = !state.hidden[p]; chart(); card() }) box.append(b) }) } function pill(text) { var kind = /passed/.test(text) ? 'ok' : /failed/.test(text) ? 'bad' : /stale/.test(text) ? 'warn' : '' var p = h('span', 'pill ' + kind); p.append(h('i'), document.createTextNode(text || 'not run')); return p } function table() { var body = byId('rows'), q = state.q.trim().toLowerCase(); body.textContent = '' var list = inDays(scope(), days(span())).filter(function (t) { return !q || (t.name + ' ' + t.project + ' ' + modelName(t.model)).toLowerCase().indexOf(q) >= 0 }) .sort(function (a, b) { return b.started.localeCompare(a.started) }) byId('count').textContent = String(list.length) if (!list.length) { var tr = h('tr'), td = h('td', 'empty', q ? 'No session matches.' : tasks.length ? 'No sessions in this period.' : 'No sessions yet. The band records a session after its first turn.') td.colSpan = 6; tr.append(td); body.append(tr); return } var max = Math.max.apply(null, list.map(function (t) { return t.agentMinutes })) || 1 list.forEach(function (t) { var tr = h('tr'), name = h('td', 'name'), task = h('div', 'task', t.name || 'Untitled session'), bar = h('div', 'bar'), tests = h('td') task.title = t.name; bar.style.setProperty('--c', colorOf(t.project)); bar.style.width = Math.max(4, t.agentMinutes / max * 100) + '%' name.append(task, h('div', 'sub', (state.project ? '' : t.project + ' · ') + shortDate(t.started.slice(0, 10)) + ', ' + t.started.slice(11) + (t.model ? ' · ' + modelName(t.model) : '')), bar) // An imported session has no test record: a dash, not "not run". tests.append(t.tests || t.level !== 'import' ? pill(t.tests) : h('span', 'sub', '–')) tr.append(name, h('td', 'r num', time(t.agentMinutes * 60000)), h('td', 'r num', money(t.cost)), h('td', 'r num', tok(t.tokens)), h('td', 'r num', String(t.turns)), tests) body.append(tr) }) } // Thermal ramp: a quiet indigo for a little agent time, through red to yellow for the most. var HEAT = [[0, [58, 48, 120]], [.4, [150, 45, 112]], [.65, [235, 72, 58]], [.85, [255, 140, 60]], [1, [255, 214, 102]]] function heatColor(a) { for (var i = 1; i < HEAT.length; i++) if (a <= HEAT[i][0]) { var lo = HEAT[i - 1], hi = HEAT[i], t = (a - lo[0]) / (hi[0] - lo[0]) return 'rgb(' + lo[1].map(function (c, j) { return Math.round(c + (hi[1][j] - c) * t) }).join(',') + ')' } return 'rgb(255,214,102)' } function hex(cx, cy, r) { var d = '' for (var k = 0; k < 6; k++) { var a = Math.PI / 3 * k - Math.PI / 2; d += (k ? 'L' : 'M') + (cx + r * Math.cos(a)).toFixed(1) + ',' + (cy + r * Math.sin(a)).toFixed(1) } return d + 'Z' } // The last 12 weeks as a honeycomb: a column per week, a row per weekday, the colour is the day's agent time. function heat() { var s = byId('heat'), list = scope(), keys = days(84), vals = perDay(list, keys, METRICS.time.pick), max = Math.max.apply(null, vals.concat([0])) var r = 17, w = Math.sqrt(3) * r, left = 44, top = r + 2, firstDow = new Date(now - 83 * DAY).getDay(), cols = Math.ceil((84 + firstDow) / 7); s.textContent = '' blur(svg('defs', {}, s), 'hglow', 6) var cells = keys.map(function (k, i) { var pos = i + firstDow, row = pos % 7 return { k: k, v: vals[i], a: max ? vals[i] / max : 0, cx: left + Math.floor(pos / 7) * w + (row % 2 ? w / 2 : 0), cy: top + row * r * 1.5 } }) cells.forEach(function (c) { if (c.a > .6) svg('path', { d: hex(c.cx, c.cy, r), fill: heatColor(c.a), filter: 'url(#hglow)', 'class': 'glow-cell' }, s) }) cells.forEach(function (c) { var p = svg('path', c.v ? { d: hex(c.cx, c.cy, r - 2), fill: heatColor(c.a) } : { d: hex(c.cx, c.cy, r - 2), 'class': 'cell-empty' }, s) svg('title', {}, p).textContent = shortDate(c.k) + ': ' + (c.v ? time(c.v) : 'no session') }) ;['Mon', 'Wed', 'Fri'].forEach(function (d, i) { svg('text', { x: 0, y: top + (1 + 2 * i) * r * 1.5 + 4, 'class': 'day' }, s).textContent = d }) // Legend: the ramp from less to more, under the right edge. var right = left + cols * w + w / 2, ly = top + 6 * r * 1.5 + r + 20 var end = right - 40 svg('text', { x: end - 4 * 15 - 14, y: ly + 4, 'text-anchor': 'end', 'class': 'day' }, s).textContent = 'Less' ;[.1, .3, .55, .8, 1].forEach(function (a, i) { svg('path', { d: hex(end - (4 - i) * 15, ly, 6.5), fill: heatColor(a) }, s) }) svg('text', { x: right, y: ly + 4, 'text-anchor': 'end', 'class': 'day' }, s).textContent = 'More' s.setAttribute('viewBox', '0 0 ' + Math.ceil(right + 2) + ' ' + Math.ceil(ly + 10)) // Two heroes from the same twelve weeks: on how many days agents worked, and the run of days up to today. var active = vals.filter(function (v) { return v > 0 }).length, run = 0, longest = 0, streak = 0 vals.forEach(function (v) { run = v > 0 ? run + 1 : 0; longest = Math.max(longest, run) }) for (var j = vals[83] > 0 ? 83 : 82; j >= 0 && vals[j] > 0; j--) streak++ byId('h-active').textContent = String(active) var share = byId('h-active-sub'); share.textContent = ''; share.append(h('b', '', Math.round(active / 84 * 100) + '%'), document.createTextNode('of the last 84 days')) byId('h-streak').textContent = plural(streak, 'day') byId('h-streak-sub').textContent = 'longest ' + plural(longest, 'day') var cur = inDays(list, days(span())), byDay = {}, best = null cur.forEach(function (t) { var k = t.started.slice(0, 10); byDay[k] = (byDay[k] || 0) + t.agentMinutes; if (!best || t.agentMinutes > best.agentMinutes) best = t }) var busiest = Object.keys(byDay).sort(function (a, b) { return byDay[b] - byDay[a] })[0], facts = byId('facts'); facts.textContent = '' ;[['Busiest day', busiest ? shortDate(busiest) + ' · ' + time(byDay[busiest] * 60000) : '–'], ['Longest session', best ? time(best.agentMinutes * 60000) : '–'], ['Average per session', cur.length ? time(total(cur, METRICS.time.pick) / cur.length) : '–'], ['Average cost per session', cur.length ? money(total(cur, METRICS.cost.pick) / cur.length) : '–']].forEach(function (f) { var r = h('div', 'fact'); r.append(h('span', '', f[0]), h('span', 'num', f[1])); facts.append(r) }) } // The share card: the numbers and the stream of what is on screen. function card() { var list = scope(), n = span(), keys = days(n), cur = inDays(list, keys).filter(function (t) { return !state.hidden[t.project] }) var projects = names.filter(function (p) { return cur.some(function (t) { return t.project === p }) }) byId('c-scope').textContent = (state.project || plural(projects.length, 'project')) + ' · ' + (state.period ? 'last ' + n + ' days' : 'all time') byId('c-time').textContent = time(total(cur, METRICS.time.pick)) byId('c-cost').textContent = money(total(cur, METRICS.cost.pick)) byId('c-tokens').textContent = tok(total(cur, METRICS.tokens.pick)) byId('c-sessions').textContent = String(cur.length) var g = byId('c-stream'), W = 1200, base = 600, topY = 380; g.textContent = '' var layers = projects.map(function (p) { return perDay(cur.filter(function (t) { return t.project === p }), keys, METRICS.time.pick) }) var totals = keys.map(function (_, i) { return layers.reduce(function (a, r) { return a + r[i] }, 0) }), max = Math.max.apply(null, totals.concat([0])) if (max <= 0) return var x = function (i) { return n > 1 ? i * W / (n - 1) : W / 2 }, y = function (v) { return base - v / max * (base - topY) }, acc = keys.map(function () { return 0 }), shapes = [] layers.forEach(function (r, li) { var lower = acc, upper = acc.map(function (a, i) { return a + r[i] }); acc = upper var up = smooth(upper.map(function (v, i) { return [x(i), y(v)] })), low = smooth(lower.map(function (v, i) { return [x(i), y(v)] }).reverse()) shapes.push({ d: up + 'L' + low.slice(1) + 'Z', fill: 'url(#cf' + PALETTE.indexOf(colorOf(projects[li])) + ')', top: up }) }) svg('path', { d: shapes[shapes.length - 1].top + 'L' + W + ',' + base + 'L0,' + base + 'Z', fill: '#5fe08a', opacity: .18, filter: 'url(#cglow)' }, g) shapes.forEach(function (s) { svg('path', { d: s.d, fill: s.fill }, g) }) } function render() { sidebar(); header(); kpis(); chart(); table(); heat(); card() } function download(blob, name) { var a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = name; a.click() } // The saved card carries its own copy of the logo, so it stands alone. function cardText() { var c = byId('card').cloneNode(true); c.querySelector('defs').append(byId('pt-logo').cloneNode(true)); return new XMLSerializer().serializeToString(c) } function saveSvg() { download(new Blob([cardText()], { type: 'image/svg+xml' }), 'ponytail-card.svg') } function savePng() { var img = new Image() img.onload = function () { try { var c = document.createElement('canvas'); c.width = 1200; c.height = 630; c.getContext('2d').drawImage(img, 0, 0); c.toBlob(function (b) { b ? download(b, 'ponytail-card.png') : saveSvg() }) } catch (e) { saveSvg() } } img.onerror = saveSvg img.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(cardText()) } byId('q').addEventListener('input', function (e) { state.q = e.target.value; table() }) document.querySelectorAll('[data-metric]').forEach(function (b) { b.addEventListener('click', function () { state.metric = b.getAttribute('data-metric'); document.querySelectorAll('[data-metric]').forEach(function (x) { x.classList.toggle('on', x === b) }); chart() }) }) document.querySelectorAll('[data-period]').forEach(function (b) { b.addEventListener('click', function () { state.period = +b.getAttribute('data-period'); document.querySelectorAll('[data-period]').forEach(function (x) { x.classList.toggle('on', x === b) }); render() }) }) var resizing; window.addEventListener('resize', function () { clearTimeout(resizing); resizing = setTimeout(chart, 120) }) byId('share').addEventListener('click', function () { byId('share-dialog').showModal() }) byId('share-close').addEventListener('click', function () { byId('share-dialog').close() }) byId('save-png').addEventListener('click', savePng) byId('save-svg').addEventListener('click', saveSvg) var THEMES = ['dark', 'light', 'system'] function applyTheme(choice) { document.documentElement.setAttribute('data-theme', choice) var b = byId('theme'), next = THEMES[(THEMES.indexOf(choice) + 1) % THEMES.length] b.setAttribute('data-choice', choice) b.setAttribute('aria-label', 'Theme: ' + choice + ', switch to ' + next) b.title = 'Theme: ' + choice } var saved = 'dark' try { saved = localStorage.getItem('ponytail-board-theme') || 'dark' } catch (e) {} applyTheme(THEMES.indexOf(saved) >= 0 ? saved : 'dark') byId('theme').addEventListener('click', function () { var next = THEMES[(THEMES.indexOf(byId('theme').getAttribute('data-choice')) + 1) % THEMES.length] applyTheme(next) try { localStorage.setItem('ponytail-board-theme', next) } catch (e) {} }) render() })()` // The share card stays dark in both themes: an image for X or LinkedIn, not part of the page's look. // The page fills its numbers and stream; the logo symbol joins it when the card is saved. const SANS = "Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif" const MONO = "'JetBrains Mono', ui-monospace, Menlo, monospace" const cardStat = (y: number, label: string, id: string) => `${label}` const CARD = ` ${PALETTE.map((c, i) => ``).join('')} ponytail Agent time ${cardStat(172, 'Cost', 'c-cost')}${cardStat(228, 'Tokens', 'c-tokens')}${cardStat(284, 'Sessions', 'c-sessions')} measured in Claude Codeponytail.dev` // JSON inside a script block: "<" is escaped so no name can close the block. const json = (value: unknown) => JSON.stringify(value).replace(/${LOGO_PATHS}`)}` const SEARCH_ICON = '' export function boardHtml(tasks: Task[], now: number): string { const seg = (attr: string, items: [string, string][], on: string) => items.map(([value, label]) => ``).join('') return `ponytail board
Board /

${seg('period', [['7', '7d'], ['30', '30d'], ['90', '90d'], ['0', 'All']], '30')}
${seg('metric', [['time', 'Agent time'], ['cost', 'Cost'], ['tokens', 'Tokens']], 'time')}

Sessions

SessionAgent timeCostTokensTurnsTests

Activity

Agent time per day, last 12 weeks
✦Active days
◆Streak
Share card
${CARD}
` }