diff --git a/CHANGELOG.md b/CHANGELOG.md index bf7c6f28..eee75da1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -158,7 +158,10 @@ All notable changes to **basou** are recorded here. The project follows summary with six counts (lanes in operation, blocked and unverified cells, open tracks, sessions, the operator's turns) and the observations from outside (one not made shows the previous record's value), the period - and effort, the reach matrix with the cells that moved since the previous + and effort (six counts, the commits, and charts of the milestones, the + active time by day with Claude's and the rest not at the same time + stacked, and the running total, with a table by week; a day or a week + part of which was not measured is not totalled), the reach matrix with the cells that moved since the previous record marked and the stages left behind above it, where each lane is, the ratios, the operator's turns, and footnotes. What the judge reported is marked as such. Its fixed strings are in the language the anchor diff --git a/packages/cli/src/commands/view.test.ts b/packages/cli/src/commands/view.test.ts index 88423fbf..0c475e09 100644 --- a/packages/cli/src/commands/view.test.ts +++ b/packages/cli/src/commands/view.test.ts @@ -1163,6 +1163,12 @@ describe("basou view: the board page", () => { throw new Error(`no end of ${name}`); } const lift = (name: string) => liftFrom(BOARD_HTML, name); + /** Lift a top-level `var` of the board page, as the page declares it. */ + function liftVar(name: string): string { + const start = BOARD_HTML.indexOf(`var ${name} = `); + expect(start).toBeGreaterThan(-1); + return BOARD_HTML.slice(start, BOARD_HTML.indexOf(";\n", start) + 1); + } // Just enough of a DOM for the page's drawing functions. class Node { @@ -1199,6 +1205,7 @@ describe("basou view: the board page", () => { const byId: Record = {}; const document = { createElement: (tag: string) => new Node(tag), + createElementNS: (_ns: string, tag: string) => new Node(tag), createTextNode: (text: string) => Object.assign(new Node("#text"), { textContent: text }), getElementById: (id: string) => { byId[id] ??= new Node("div"); @@ -1219,8 +1226,27 @@ describe("basou view: the board page", () => { "shown", "stateName", "section", + "svg", + "hourStep", + "monthTicks", + "plotWidth", + "dayX", + "hours", + "yAxis", + "xAxis", + "dayIndex", + "labelWidth", + "placeLabels", + "timelineChart", + "dailyChart", + "cumulativeChart", + "weeksTable", ]; - const source = [...helpers, ...names].map(lift).join("\n"); + const source = [ + liftVar("SVGNS"), + liftVar("CHART"), + ...[...helpers, ...names.filter((n) => !helpers.includes(n))].map(lift), + ].join("\n"); const fns = new Function("document", "S", "lang", `${source}; return { ${names.join(", ")} };`)( document, boardPageStrings("en"), @@ -1297,13 +1323,17 @@ describe("basou view: the board page", () => { sessions_without_tokens: null, commits: null, milestones: [], + active_days: null, + daily: null, + weeks: null, }, }) as Node; - const rows = effort.find((n) => n.tag === "tr").map((r) => r.children.map((c) => c.text())); - expect(rows.slice(0, 2)).toEqual([ - ["Start", "2026-04-28"], - ["Elapsed", "not measured"], - ]); + const tiles = effort + .find((n) => n.className === "tile") + .map((t) => t.children.map((c) => c.text())); + expect(tiles[0]).toEqual(["Elapsed", "not measured", "from 2026-04-28"]); + expect(effort.text()).toContain(boardPageStrings("en").effort.noDays); + expect(effort.find((n) => n.tag === "svg")).toHaveLength(0); const foot = call("footnotes", { footnotes: { notes: [], @@ -1317,6 +1347,197 @@ describe("basou view: the board page", () => { expect(foot.text()).not.toContain(boardPageStrings("en").footnotes.noReview); }); + it("draws the days of the effort: Claude's time, the rest above it, the running total and the weeks", () => { + const { call } = drawing(["effort"]); + const day = ( + date: string, + union: number | null, + claude: number | null, + cumulative: number | null, + ) => ({ + date, + union, + claude, + not_claude: union === null || claude === null ? null : union - claude, + cumulative, + commits: 1, + }); + const H = 3_600_000; + const effort = call("effort", { + effort: { + start: "2026-04-30", + time_zone: "UTC", + elapsed_days: 4, + active_ms: { union: 6 * H, claude: 4 * H, codex: 3 * H }, + output_tokens: 1000, + sessions_without_tokens: 0, + commits: [{ repo: ".", count: 3 }], + milestones: [ + { date: "2026-05-01", label: "Started", ref: "abc" }, + // Outside the days drawn: listed, not charted. + { date: "2026-04-01", label: "Before", ref: "x" }, + ], + active_days: 2, + period_days: 4, + daily: [ + day("2026-04-30", 2 * H, 2 * H, 2 * H), + day("2026-05-01", 4 * H, 2 * H, 6 * H), + day("2026-05-02", null, null, null), + day("2026-05-03", 0, 0, null), + ], + weeks: [ + { + week: "2026-04-27", + union: 6 * H, + claude: 4 * H, + codex: 3 * H, + active_days: 2, + commits: 4, + }, + ], + }, + }); + const tiles = effort + .find((n) => n.className === "tile") + .map((t) => t.children.map((c) => c.text())); + expect(tiles.map((t) => t[0])).toEqual([ + "Elapsed", + "Active", + "Claude", + "Codex", + "Per day", + "Output tokens", + ]); + expect(tiles[4]).toEqual(["Per day", "1h 30m", "3h 00m a day worked"]); + const charts = effort.find((n) => n.tag === "svg"); + expect(charts).toHaveLength(3); + const [timeline, daily, total] = charts as [Node, Node, Node]; + // One dot on the line for the milestone within the days, and its label. + expect(timeline.find((n) => n.attrs.class === "dot")).toHaveLength(1); + expect(timeline.text()).toContain("Started"); + expect(timeline.text()).not.toContain("Before"); + expect(effort.text()).toContain("Before"); + // Bars only where there was time: Claude on both days, the rest on the second, above Claude. + const bars = (cls: string) => daily.find((n) => n.tag === "rect" && n.attrs.class === cls); + expect(bars("claude")).toHaveLength(2); + expect(bars("other")).toHaveLength(1); + const [claude2, other2] = [bars("claude")[1] as Node, bars("other")[0] as Node]; + // The top of the stack is the day's total; the gap comes out of the upper bar. + const at = (n: Node, key: string) => Number(n.attrs[key]); + expect(at(other2, "y") + at(other2, "height")).toBeLessThan(at(claude2, "y")); + expect(at(claude2, "y") - at(other2, "y")).toBeCloseTo(at(claude2, "height"), 5); + expect(daily.find((n) => n.attrs.class === "hit").map((r) => r.text())[1]).toBe( + "2026-05-01: active 4h 00m (Claude 2h 00m, not Claude 2h 00m), commits 1", + ); + // The running total stops at the first day not measured. + const line = total.find((n) => n.tag === "path")[0] as Node; + expect(line.attrs.d?.trim().split(" L").length).toBe(2); + expect(total.find((n) => n.attrs.class === "ms")).toHaveLength(1); + const weekRows = effort.find((n) => n.tag === "tr").map((r) => r.children.map((c) => c.text())); + expect(weekRows[1]).toEqual(["week of 2026-04-27", "6h 00m", "4h 00m", "3h 00m", "2", "4"]); + // With no Codex session at all, its column says so rather than not measured. + const solo = call("effort", { + effort: { + start: "2026-04-30", + time_zone: "UTC", + elapsed_days: 0, + active_ms: { union: 2 * H, claude: 2 * H, codex: null }, + output_tokens: 0, + sessions_without_tokens: 0, + commits: [], + milestones: [], + active_days: 1, + period_days: 1, + daily: [day("2026-04-30", 2 * H, 2 * H, 2 * H)], + weeks: [ + { + week: "2026-04-27", + union: 2 * H, + claude: 2 * H, + codex: null, + active_days: 1, + commits: 1, + }, + ], + }, + }); + const soloRows = solo.find((n) => n.tag === "tr").map((r) => r.children.map((c) => c.text())); + expect(soloRows[1]?.[3]).toBe("-"); + // On the first day, the day so far is the day. + const soloTiles = solo + .find((n) => n.className === "tile") + .map((t) => t.children.map((c) => c.text())); + expect(soloTiles[4]).toEqual(["Per day", "2h 00m", "2h 00m a day worked"]); + }); + + it("labels the months under a chart without crowding, and the milestones inside it without overlap", () => { + const document = { + createElementNS: (_ns: string, tag: string) => new Node(tag), + }; + const fns = new Function( + "document", + [ + liftVar("SVGNS"), + liftVar("CHART"), + ...["svg", "monthTicks", "plotWidth", "dayX", "xAxis", "labelWidth", "placeLabels"].map( + lift, + ), + "return { xAxis: xAxis, placeLabels: placeLabels, labelWidth: labelWidth, CHART: CHART };", + ].join("\n"), + )(document) as { + xAxis: (days: { date: string }[], base: number) => Node[]; + placeLabels: ( + items: { x: number; text: string }[], + rows: number, + centred: boolean, + ) => { x: number; row: number; text: string }[]; + labelWidth: (text: string) => number; + CHART: { w: number; left: number; right: number }; + }; + // A wide character is reckoned as wide as two narrow ones, near enough. + expect(fns.labelWidth("ab")).toBeCloseTo(12.4, 5); + expect(fns.labelWidth("\u5b9f\u88c5")).toBeCloseTo(22, 5); + const days = Array.from({ length: 160 }, (_, i) => ({ + date: new Date(Date.UTC(2026, 3, 28 + i)).toISOString().slice(0, 10), + })); + // The start and 5/1, three days on, are too close: 5/1 is left out. + expect(fns.xAxis(days, 100).map((n) => n.textContent)).toEqual([ + "4/28", + "6/1", + "7/1", + "8/1", + "9/1", + "10/1", + ]); + const { w, left, right } = fns.CHART; + const long = "a milestone with a rather long label"; + // At either edge a label is kept inside the chart, centred or beside its mark. + for (const centred of [true, false]) { + const [atRight, atLeft] = fns.placeLabels( + [ + { x: w - right - 2, text: long }, + { x: left + 2, text: long }, + ], + 4, + centred, + ); + expect(atRight?.x).toBeGreaterThanOrEqual(left); + expect((atRight?.x ?? 0) + long.length * 6.2).toBeLessThanOrEqual(w - right + 1e-9); + expect(atLeft?.x).toBeGreaterThanOrEqual(left); + } + // Two marks close together take two rows; one far off goes back to the first. + const placed = fns.placeLabels( + [ + { x: 200, text: long }, + { x: 210, text: long }, + { x: 650, text: "far" }, + ], + 4, + false, + ); + expect(placed.map((p) => p.row)).toEqual([0, 1, 0]); + }); + it("builds prose of text nodes, with only a span between backticks as a code element", () => { const document = { createElement: (tag: string) => new Node(tag), @@ -1359,6 +1580,10 @@ describe("basou view: the board page", () => { // What a value holds is never filled in itself. expect(fill("{a} then {b}", { a: "{b}", b: "x" })).toBe("{b} then x"); expect(codeSpans("x `` y")).toEqual([{ code: false, text: "x `` y" }]); + const hourStep = new Function(`${lift("hourStep")}; return hourStep;`)() as ( + h: number, + ) => number; + expect([1, 5, 6, 26, 384, 2600, 9000].map(hourStep)).toEqual([1, 1, 2, 10, 100, 1000, 2000]); const num = new Function("S", `${lift("num")}; return num;`)(boardPageStrings("en")) as ( n: unknown, ) => string; diff --git a/packages/cli/src/lib/board-ui.ts b/packages/cli/src/lib/board-ui.ts index 7de79bff..070ceb07 100644 --- a/packages/cli/src/lib/board-ui.ts +++ b/packages/cli/src/lib/board-ui.ts @@ -17,52 +17,94 @@ export const BOARD_HTML = ` basou board @@ -268,28 +310,267 @@ export const BOARD_HTML = ` ]); } + var SVGNS = 'http://www.w3.org/2000/svg'; + // An SVG element, built as a node like every other. + function svg(tag, attrs, children) { + var node = document.createElementNS(SVGNS, tag); + Object.keys(attrs || {}).forEach(function (k) { + if (k === 'text') node.textContent = attrs[k]; + else node.setAttribute(k, String(attrs[k])); + }); + (children || []).forEach(function (c) { if (c) node.appendChild(c); }); + return node; + } + // A step of hours for an axis that draws at most five lines. A test lifts this function. + function hourStep(maxHours) { + var steps = [1, 2, 5, 10, 20, 25, 50, 100, 200, 250, 500, 1000]; + for (var i = 0; i < steps.length; i++) { + if (maxHours / steps[i] <= 5) return steps[i]; + } + return 1000 * Math.ceil(maxHours / 5000); + } + // The days a month starts on, and the first day, as labels for an x axis. + function monthTicks(days) { + var out = []; + days.forEach(function (d, i) { + if (i === 0 || d.date.slice(8) === '01') { + out.push({ i: i, label: Number(d.date.slice(5, 7)) + '/' + Number(d.date.slice(8)) }); + } + }); + return out; + } + var CHART = { w: 760, left: 44, right: 10 }; + function plotWidth() { return CHART.w - CHART.left - CHART.right; } + function dayX(i, n) { return CHART.left + (i + 0.5) * plotWidth() / n; } + function hours(ms) { return (ms || 0) / 3600000; } + function yAxis(top, base, max, step, label) { + var nodes = []; + for (var v = 0; v <= max + 1e-9; v += step) { + var y = base - (v / max) * (base - top); + nodes.push(svg('line', { class: v === 0 ? 'axis' : 'grid', x1: CHART.left, x2: CHART.w - CHART.right, y1: y, y2: y })); + nodes.push(svg('text', { x: CHART.left - 6, y: y + 4, 'text-anchor': 'end', text: label(v) })); + } + return nodes; + } + // The month labels under a chart, leaving out one too close to the one before. + function xAxis(days, base) { + var last = -Infinity; + var out = []; + monthTicks(days).forEach(function (t) { + var x = dayX(t.i, days.length); + if (x - last < 40) return; + last = x; + out.push(svg('text', { x: x, y: base + 16, 'text-anchor': 'middle', text: t.label })); + }); + return out; + } + // About how wide a label is drawn: a wide character (CJK) counts as two narrow ones. + function labelWidth(text) { + var w = 0; + for (var i = 0; i < text.length; i++) w += text.charCodeAt(i) > 11903 ? 11 : 6.2; + return w; + } + // Where labels beside marks go: each kept inside the chart, centred on its mark + // or just after it, and in the first of the rows where it does not run into + // another (the last row when none has room). A test lifts this function. + function placeLabels(items, rows, centred) { + var min = CHART.left; + var max = CHART.w - CHART.right; + var taken = []; + for (var r = 0; r < rows; r++) taken.push([]); + return items.map(function (it) { + var w = labelWidth(it.text); + var x0 = centred ? it.x - w / 2 : it.x + 4; + if (x0 + w > max) x0 = centred ? max - w : it.x - 4 - w; + if (x0 < min) x0 = min; + var row = rows - 1; + for (var k = 0; k < rows; k++) { + var free = taken[k].every(function (s) { return x0 + w + 6 <= s[0] || x0 >= s[1] + 6; }); + if (free) { row = k; break; } + } + taken[row].push([x0, x0 + w]); + return { x: x0, row: row, text: it.text }; + }); + } + function dayIndex(days, date) { + for (var i = 0; i < days.length; i++) if (days[i].date === date) return i; + return -1; + } + + // Milestones on a line from the first day to the last. + function timelineChart(days, milestones) { + var h = 100; + var mid = 50; + var nodes = [svg('line', { class: 'axis', x1: CHART.left, x2: CHART.w - CHART.right, y1: mid, y2: mid })]; + monthTicks(days).forEach(function (t) { + var x = dayX(t.i, days.length); + nodes.push(svg('line', { class: 'grid', x1: x, x2: x, y1: mid - 4, y2: mid + 4 })); + }); + nodes.push(svg('circle', { class: 'start', cx: dayX(0, days.length), cy: mid, r: 5 }, [ + svg('title', { text: days[0].date }) + ])); + var marks = []; + milestones.forEach(function (m) { + var i = dayIndex(days, m.date); + if (i === -1) return; + var x = dayX(i, days.length); + marks.push({ x: x, text: m.label }); + nodes.push(svg('circle', { class: 'dot', cx: x, cy: mid, r: 5 }, [ + svg('title', { text: m.date + ' ' + m.label + ' ' + m.ref }) + ])); + }); + // Rows above and below the line, nearest first. + var rowY = [mid - 12, mid + 22, mid - 26, mid + 36]; + placeLabels(marks, rowY.length, true).forEach(function (p) { + nodes.push(svg('text', { class: 'ms-label', x: p.x, y: rowY[p.row], text: p.text })); + }); + return el('figure', null, [ + el('figcaption', { text: S.effort.milestones }), + el('div', { class: 'scroll' }, [svg('svg', { class: 'chart', viewBox: '0 0 ' + CHART.w + ' ' + h, role: 'img', 'aria-label': S.effort.milestones }, nodes)]) + ]); + } + + // Each day's active time: Claude's, then the part of Codex's not at the same time. + function dailyChart(days) { + var h = 200; + var top = 10; + var base = h - 26; + var maxH = 0; + days.forEach(function (d) { maxH = Math.max(maxH, hours(d.union)); }); + var step = hourStep(Math.max(maxH, 1)); + var max = Math.ceil(Math.max(maxH, 1) / step) * step; + var scale = (base - top) / max; + var band = plotWidth() / days.length; + var width = Math.max(1, band - (band > 4 ? 2 : 0)); + var nodes = yAxis(top, base, max, step, function (v) { return v + 'h'; }); + days.forEach(function (d, i) { + var x = CHART.left + i * band + (band - width) / 2; + var ch = hours(d.claude) * scale; + var oh = hours(d.not_claude) * scale; + // The gap between the two comes out of the upper one, so the top is the total. + var gap = ch > 0 && oh > 2 && band > 4 ? 2 : 0; + if (ch > 0) nodes.push(svg('rect', { class: 'claude', x: x, y: base - ch, width: width, height: ch })); + if (oh > 0) nodes.push(svg('rect', { class: 'other', x: x, y: base - ch - oh, width: width, height: oh - gap })); + nodes.push(svg('rect', { class: 'hit', x: CHART.left + i * band, y: top, width: band, height: base - top }, [ + svg('title', { text: fill(S.effort.dayDetail, { + date: d.date, active: hm(d.union), claude: hm(d.claude), other: hm(d.not_claude), commits: num(d.commits) + }) }) + ])); + }); + nodes = nodes.concat(xAxis(days, base)); + var legend = el('div', { class: 'legend' }, [ + el('span', null, [el('span', { class: 'swatch s1' }), S.effort.claude]), + el('span', null, [el('span', { class: 'swatch s2' }), S.effort.notClaude]) + ]); + return el('figure', null, [ + el('figcaption', { text: S.effort.dailyTitle }), + legend, + el('div', { class: 'scroll' }, [svg('svg', { class: 'chart', viewBox: '0 0 ' + CHART.w + ' ' + h, role: 'img', 'aria-label': S.effort.dailyTitle }, nodes)]) + ]); + } + + // The running total of active time, with the milestones as dashed lines. + function cumulativeChart(days, milestones) { + var h = 200; + var top = 10; + var base = h - 26; + var last = 0; + days.forEach(function (d) { if (d.cumulative !== null) last = Math.max(last, hours(d.cumulative)); }); + var step = hourStep(Math.max(last, 1)); + var max = Math.ceil(Math.max(last, 1) / step) * step; + var y = function (ms) { return base - hours(ms) / max * (base - top); }; + var nodes = yAxis(top, base, max, step, function (v) { return v + 'h'; }); + var marks = []; + milestones.forEach(function (m) { + var i = dayIndex(days, m.date); + if (i === -1) return; + var x = dayX(i, days.length); + marks.push({ x: x, text: m.label }); + nodes.push(svg('line', { class: 'ms', x1: x, x2: x, y1: top, y2: base })); + }); + placeLabels(marks, 4, false).forEach(function (p) { + nodes.push(svg('text', { class: 'ms-label', x: p.x, y: top + 10 + p.row * 13, text: p.text })); + }); + var path = ''; + for (var i = 0; i < days.length; i++) { + if (days[i].cumulative === null) break; + path += (i === 0 ? 'M' : 'L') + dayX(i, days.length).toFixed(1) + ' ' + y(days[i].cumulative).toFixed(1) + ' '; + } + if (path !== '') nodes.push(svg('path', { class: 'line', d: path })); + var band = plotWidth() / days.length; + days.forEach(function (d, i) { + nodes.push(svg('rect', { class: 'hit', x: CHART.left + i * band, y: top, width: band, height: base - top }, [ + svg('title', { text: fill(S.effort.cumulativeDetail, { date: d.date, total: hm(d.cumulative) }) }) + ])); + }); + nodes = nodes.concat(xAxis(days, base)); + return el('figure', null, [ + el('figcaption', { text: S.effort.cumulativeTitle }), + el('div', { class: 'scroll' }, [svg('svg', { class: 'chart', viewBox: '0 0 ' + CHART.w + ' ' + h, role: 'img', 'aria-label': S.effort.cumulativeTitle }, nodes)]) + ]); + } + + function weeksTable(weeks, noCodex) { + var head = el('tr', null, [ + el('th', { text: S.effort.week }), el('th', { class: 'n', text: S.effort.active }), + el('th', { class: 'n', text: S.effort.claude }), el('th', { class: 'n', text: S.effort.codex }), + el('th', { class: 'n', text: S.effort.activeDays }), el('th', { class: 'n', text: S.effort.commitColumn }) + ]); + var rows = weeks.map(function (w) { + return el('tr', null, [ + el('td', { text: fill(S.effort.weekOf, { date: w.week }) }), el('td', { class: 'n', text: hm(w.union) }), + el('td', { class: 'n', text: hm(w.claude) }), el('td', { class: 'n', text: noCodex ? '-' : hm(w.codex) }), + el('td', { class: 'n', text: num(w.active_days) }), el('td', { class: 'n', text: num(w.commits) }) + ]); + }); + return el('figure', null, [ + el('figcaption', { text: S.effort.weeksTitle }), + el('div', { class: 'scroll' }, [el('table', { class: 'grid' }, [head].concat(rows))]) + ]); + } + function effort(b) { var e = b.effort; - var rows = []; - var row = function (k, v) { rows.push(el('tr', null, [el('td', { class: 'muted', text: k }), el('td', { text: v })])); }; - row(S.effort.start, e.start + (e.time_zone === null ? '' : ' (' + e.time_zone + ')')); - row(S.effort.elapsedLabel, e.elapsed_days === null ? S.effort.notMeasured : fill(S.effort.elapsed, { days: e.elapsed_days })); - row(S.effort.active, hm(e.active_ms.union)); - row(S.effort.claude, hm(e.active_ms.claude)); - row(S.effort.codex, e.active_ms.codex === null && e.active_ms.union !== null ? S.effort.noCodex : hm(e.active_ms.codex)); - var tokens = num(e.output_tokens); - if (e.sessions_without_tokens) tokens += ' (' + fill(S.effort.withoutTokens, { n: e.sessions_without_tokens }) + ')'; - row(S.effort.outputTokens, tokens); - if (e.commits === null) row(S.effort.commits, S.effort.notMeasured); - else e.commits.forEach(function (c) { row(S.effort.commits + ' ' + c.repo, num(c.count)); }); - var milestones = e.milestones.length === 0 + var tile = function (k, v, sub) { + return el('div', { class: 'tile' }, [ + el('div', { class: 'k', text: k }), el('div', { class: 'v', text: v }), + sub ? el('div', { class: 's', text: sub }) : null + ]); + }; + var union = e.active_ms.union; + var perDay = union === null || !e.period_days ? null : union / e.period_days; + var perWorked = union === null || !e.active_days ? null : union / e.active_days; + var tokens = e.sessions_without_tokens ? fill(S.effort.withoutTokens, { n: e.sessions_without_tokens }) : null; + var tiles = el('div', { class: 'tiles' }, [ + tile(S.effort.elapsed, e.elapsed_days === null ? S.effort.notMeasured : fill(S.effort.days, { days: e.elapsed_days }), + e.time_zone === null ? fill(S.effort.from, { date: e.start }) : fill(S.effort.fromZone, { date: e.start, zone: e.time_zone })), + tile(S.effort.active, hm(union), e.active_days === null ? null : fill(S.effort.daysWorked, { n: e.active_days })), + tile(S.effort.claude, hm(e.active_ms.claude), null), + tile(S.effort.codex, e.active_ms.codex === null && union !== null ? S.effort.noCodex : hm(e.active_ms.codex), null), + tile(S.effort.perDay, hm(perDay), perWorked === null ? null : fill(S.effort.perDayWorked, { time: hm(perWorked) })), + tile(S.effort.outputTokens, num(e.output_tokens), tokens) + ]); + var commits = e.commits === null ? S.effort.notMeasured : e.commits.map(function (c) { + return c.repo + ' ' + num(c.count); + }).join(' / '); + var children = [tiles, el('p', { class: 'muted', text: fill(S.effort.commits, { list: commits }) })]; + var days = e.daily || []; + if (days.length === 0) { + children.push(el('p', { class: 'muted', text: S.effort.noDays })); + } else { + children.push(timelineChart(days, e.milestones)); + children.push(dailyChart(days)); + children.push(cumulativeChart(days, e.milestones)); + } + children.push(e.milestones.length === 0 ? el('p', { class: 'muted', text: S.effort.noMilestones }) : el('ul', null, e.milestones.map(function (m) { return el('li', null, [m.date + ' ', el('b', { text: m.label }), el('span', { class: 'muted', text: ' ' + m.ref })]); - })); - return section(S.sections.effort, false, [ - el('table', null, rows), el('h3', { text: S.effort.milestones }), milestones - ]); + }))); + // With no Codex session at all, its column is not a measurement that failed. + var noCodex = e.active_ms.codex === null && union !== null; + if (e.weeks && e.weeks.length > 0) children.push(weeksTable(e.weeks, noCodex)); + return section(S.sections.effort, false, children); } function matrix(b) { diff --git a/packages/core/src/board/index.ts b/packages/core/src/board/index.ts index 731ee48a..999ecb81 100644 --- a/packages/core/src/board/index.ts +++ b/packages/core/src/board/index.ts @@ -57,8 +57,10 @@ export { boardDigest, measureBoard } from "./measure.js"; export type { BoardPage, BoardPageBody, + BoardPageDay, BoardPageRecordRef, BoardPageUnavailable, + BoardPageWeek, } from "./page.js"; export { boardPage, boardPageUnavailable } from "./page.js"; export type { BoardPortfolio } from "./portfolio.js"; diff --git a/packages/core/src/board/page.test.ts b/packages/core/src/board/page.test.ts index 63bede69..a1f623f8 100644 --- a/packages/core/src/board/page.test.ts +++ b/packages/core/src/board/page.test.ts @@ -81,7 +81,33 @@ function record(over: Record = {}) { output_tokens: 1000, sessions_without_tokens: 1, commits: { ".": 12, "../basou": 34 }, - daily: [], + // Sunday, then Monday to Wednesday; Tuesday's time not measured. + daily: [ + { + date: "2026-10-04", + active_ms: { union: 60_000, claude: 60_000, codex: null }, + output_tokens: 1, + commits: { ".": 1, "../basou": 2 }, + }, + { + date: "2026-10-05", + active_ms: { union: 180_000, claude: 120_000, codex: 90_000 }, + output_tokens: 1, + commits: { ".": 0, "../basou": 3 }, + }, + { + date: "2026-10-06", + active_ms: { union: null, claude: null, codex: null }, + output_tokens: null, + commits: { ".": null, "../basou": null }, + }, + { + date: "2026-10-07", + active_ms: { union: 0, claude: 0, codex: 0 }, + output_tokens: 0, + commits: { ".": 0, "../basou": 0 }, + }, + ], }, components: { found: {}, unacknowledged: [], gone: [], kind_changed: null }, axis: { @@ -335,8 +361,102 @@ describe("boardPage", () => { { repo: "../basou", count: 34 }, ], milestones: [{ date: "2026-05-04", label: "Started", ref: "abc1234" }], + // Tuesday's time was not measured, so the days worked are not known. + active_days: null, + period_days: 4, + daily: [ + { + date: "2026-10-04", + union: 60_000, + claude: 60_000, + not_claude: 0, + cumulative: 60_000, + commits: 3, + }, + { + date: "2026-10-05", + union: 180_000, + claude: 120_000, + not_claude: 60_000, + cumulative: 240_000, + commits: 3, + }, + { + date: "2026-10-06", + union: null, + claude: null, + not_claude: null, + cumulative: null, + commits: null, + }, + { date: "2026-10-07", union: 0, claude: 0, not_claude: 0, cumulative: null, commits: 0 }, + ], + weeks: [ + { + week: "2026-09-28", + union: 60_000, + claude: 60_000, + codex: null, + active_days: 1, + commits: 3, + }, + // A week with a day not measured is not known as a whole. + { + week: "2026-10-05", + union: null, + claude: null, + codex: null, + active_days: null, + commits: null, + }, + ], }); expect(b.turns).toEqual([{ text: "Decide X", source: "decision_X" }]); + // A day one repo's commits of which were not measured has no total. + const partly = record(); + partly.measure.effort.daily = [ + { + date: "2026-10-04", + active_ms: { union: 1, claude: 1, codex: 0 }, + output_tokens: 0, + commits: { ".": 2, "../basou": null }, + }, + // A day with no time is not a day worked. + { + date: "2026-10-05", + active_ms: { union: 0, claude: 0, codex: 0 }, + output_tokens: 0, + commits: { ".": 0, "../basou": 0 }, + }, + ] as unknown as typeof partly.measure.effort.daily; + await place(B, partly); + const part = await boardPage(records); + if (part.status !== "ok") throw new Error(part.why); + expect(part.board.effort.daily?.[0]?.commits).toBeNull(); + expect(part.board.effort.weeks?.[0]).toMatchObject({ commits: null, active_days: 1 }); + expect(part.board.effort.active_days).toBe(1); + // A day that is not a date of the calendar is not in the shape of a record. + const undatedRow = record(); + (undatedRow.measure.effort.daily[0] as { date: string }).date = "x"; + await place(B, undatedRow); + expect(await boardPage(records)).toMatchObject({ + status: "unavailable", + why: "not_a_record", + id: B, + }); + // With no days measured there is nothing by day or by week, not none of them. + const undated = record(); + undated.measure.effort.daily = null as unknown as typeof undated.measure.effort.daily; + await place(B, undated); + const noDays = await boardPage(records); + if (noDays.status !== "ok") throw new Error(noDays.why); + expect(noDays.board.effort).toMatchObject({ + active_days: null, + period_days: null, + daily: null, + weeks: null, + }); + await rm(join(records, `${B}.json`)); expect(b.footnotes).toEqual({ notes: ["Measured with care."], axis: { diff --git a/packages/core/src/board/page.ts b/packages/core/src/board/page.ts index 80b384a6..00df005b 100644 --- a/packages/core/src/board/page.ts +++ b/packages/core/src/board/page.ts @@ -3,6 +3,7 @@ import { join } from "node:path"; import { decodeTime } from "ulid"; import { z } from "zod"; import { findErrorCode } from "../lib/error-codes.js"; +import { isCalendarDate } from "./declaration.js"; import { diffCells } from "./diff.js"; import { readBoardRecordFile, recordIds } from "./previous.js"; @@ -59,6 +60,15 @@ const pageRecordSchema = z.looseObject({ output_tokens: count, sessions_without_tokens: count, commits: z.record(text, count).nullable(), + daily: z + .array( + z.looseObject({ + date: z.string().refine(isCalendarDate), + active_ms: z.looseObject({ union: count, claude: count, codex: count }), + commits: z.record(text, count), + }), + ) + .nullable(), }), axis: z.looseObject({ review_needed: z.boolean().nullable(), @@ -145,6 +155,32 @@ export type BoardPageLane = { flags: { live: boolean; blocked: boolean; unverified: boolean }; }; +/** One day of the effort, as the page draws it. */ +export type BoardPageDay = { + date: string; + union: number | null; + claude: number | null; + /** + * The active time not Claude's: Codex's, and work by hand or in a terminal, + * when it was not at the same time as Claude's. The record's days cannot + * tell Codex's alone apart. + */ + not_claude: number | null; + cumulative: number | null; + /** The commits of every repo that day, null when one of them was not measured. */ + commits: number | null; +}; + +/** One week of the effort, from its Monday: null where a day of it was not measured. */ +export type BoardPageWeek = { + week: string; + union: number | null; + claude: number | null; + codex: number | null; + active_days: number | null; + commits: number | null; +}; + /** What the board page draws from one record: the eight sections, in order. */ export type BoardPageBody = { heading: { @@ -164,6 +200,21 @@ export type BoardPageBody = { sessions_without_tokens: number | null; commits: { repo: string; count: number | null }[] | null; milestones: { date: string; label: string; ref: string }[]; + /** + * The days the active time was more than none on, null when a day's time + * was not measured. + */ + active_days: number | null; + /** The days from the start to today, the one the effort was measured on. */ + period_days: number | null; + /** + * Each day from the start, in milliseconds: the active time, Claude's, the + * part not Claude's, and the running total of the active time (null from a + * day whose time was not measured on). + */ + daily: BoardPageDay[] | null; + /** The days by week, from Monday. */ + weeks: BoardPageWeek[] | null; }; matrix: { stages: { id: string; meaning: string }[]; @@ -412,6 +463,7 @@ function bodyOf(r: PageRecord, previous: Previous): BoardPageBody { ? null : Object.entries(m.effort.commits).map(([repo, n]) => ({ repo, count: n })), milestones: (d.effort.milestones ?? []).map(({ date, label, ref }) => ({ date, label, ref })), + ...daysOf(m.effort.daily), }, matrix: { stages, @@ -519,3 +571,70 @@ function previousValue(previous: Previous, name: string): BoardPagePrevious { const value = previous.observed[name]?.value; return value === null || value === undefined ? { status: "none" } : { status: "value", value }; } + +type MeasuredDay = { + date: string; + active_ms: { union: number | null; claude: number | null; codex: number | null }; + commits: Readonly>; +}; + +// The sum, null when any of it was not measured: a part is not the whole. +function sumOf(values: readonly (number | null)[]): number | null { + let sum = 0; + for (const value of values) { + if (value === null) return null; + sum += value; + } + return sum; +} + +// The days with active time, null when a day's time was not measured. +function activeDaysOf(rows: readonly MeasuredDay[]): number | null { + if (rows.some((r) => r.active_ms.union === null)) return null; + return rows.filter((r) => (r.active_ms.union ?? 0) > 0).length; +} + +// The Monday of the week a calendar date is in, as YYYY-MM-DD. +function mondayOf(date: string): string { + const [y, m, d] = date.split("-").map(Number) as [number, number, number]; + const day = new Date(0); + day.setUTCFullYear(y, m - 1, d); + day.setUTCDate(day.getUTCDate() - ((day.getUTCDay() + 6) % 7)); + return day.toISOString().slice(0, 10); +} + +function daysOf(rows: readonly MeasuredDay[] | null): { + active_days: number | null; + period_days: number | null; + daily: BoardPageDay[] | null; + weeks: BoardPageWeek[] | null; +} { + if (rows === null) return { active_days: null, period_days: null, daily: null, weeks: null }; + let total: number | null = 0; + const daily = rows.map((row) => { + const { union, claude } = row.active_ms; + total = total === null || union === null ? null : total + union; + return { + date: row.date, + union, + claude, + not_claude: union === null || claude === null ? null : Math.max(0, union - claude), + cumulative: total, + commits: sumOf(Object.values(row.commits)), + }; + }); + const byWeek = new Map(); + for (const row of rows) { + const week = mondayOf(row.date); + byWeek.set(week, [...(byWeek.get(week) ?? []), row]); + } + const weeks = [...byWeek].map(([week, days]) => ({ + week, + union: sumOf(days.map((r) => r.active_ms.union)), + claude: sumOf(days.map((r) => r.active_ms.claude)), + codex: sumOf(days.map((r) => r.active_ms.codex)), + active_days: activeDaysOf(days), + commits: sumOf(days.flatMap((r) => Object.values(r.commits))), + })); + return { active_days: activeDaysOf(rows), period_days: rows.length, daily, weeks }; +} diff --git a/packages/core/src/lib/view-strings.ts b/packages/core/src/lib/view-strings.ts index febae7a2..27d3a62d 100644 --- a/packages/core/src/lib/view-strings.ts +++ b/packages/core/src/lib/view-strings.ts @@ -879,19 +879,34 @@ export type BoardPageStrings = { missingPreviousUnreadable: string; }; effort: { - start: string; - elapsedLabel: string; elapsed: string; + days: string; + from: string; + fromZone: string; active: string; + daysWorked: string; claude: string; codex: string; noCodex: string; + perDay: string; + perDayWorked: string; outputTokens: string; withoutTokens: string; commits: string; milestones: string; noMilestones: string; notMeasured: string; + dailyTitle: string; + notClaude: string; + dayDetail: string; + cumulativeTitle: string; + cumulativeDetail: string; + weeksTitle: string; + week: string; + weekOf: string; + activeDays: string; + commitColumn: string; + noDays: string; }; states: { done: string; @@ -990,19 +1005,34 @@ const BOARD_PAGE_EN: BoardPageStrings = { missingPreviousUnreadable: "not observed (the previous record could not be read)", }, effort: { - start: "Start", - elapsedLabel: "Elapsed", - elapsed: "{days} days", + elapsed: "Elapsed", + days: "{days} days", + from: "from {date}", + fromZone: "from {date} ({zone})", active: "Active", + daysWorked: "{n} days worked", claude: "Claude", codex: "Codex", noCodex: "no Codex session", + perDay: "Per day", + perDayWorked: "{time} a day worked", outputTokens: "Output tokens", withoutTokens: "imports that recorded no tokens: {n}", - commits: "Commits", + commits: "Commits since the start: {list}", milestones: "Milestones", noMilestones: "No milestones declared.", notMeasured: "not measured", + dailyTitle: "Active time by day", + notClaude: "Not Claude (Codex, by hand, in a terminal; not at the same time as Claude)", + dayDetail: "{date}: active {active} (Claude {claude}, not Claude {other}), commits {commits}", + cumulativeTitle: "Active time, cumulative", + cumulativeDetail: "{date}: {total} in all", + weeksTitle: "By week, from Monday", + week: "Week", + weekOf: "week of {date}", + activeDays: "Days worked", + commitColumn: "Commits", + noDays: "There are no days to draw.", }, states: { done: "done", @@ -1106,19 +1136,34 @@ const BOARD_PAGE_JA: BoardPageStrings = { missingPreviousUnreadable: "未確認(前回の記録が読めない)", }, effort: { - start: "開始", - elapsedLabel: "経過", - elapsed: "{days} 日", + elapsed: "経過", + days: "{days} 日", + from: "{date} から", + fromZone: "{date} から({zone})", active: "実働", + daysWorked: "{n} 日に分布", claude: "Claude", codex: "Codex", noCodex: "Codex の session なし", + perDay: "1 日あたり", + perDayWorked: "動いた日だけなら {time}", outputTokens: "出力トークン", withoutTokens: "トークンを記録しなかった取り込み {n}", - commits: "commit", + commits: "開始日以降の commit: {list}", milestones: "節目", noMilestones: "節目の宣言はない。", notMeasured: "測れなかった", + dailyTitle: "日ごとの実働", + notClaude: "Claude 以外(Codex・人手・端末など。Claude と重ならない分)", + dayDetail: "{date}: 実働 {active}(Claude {claude} / Claude 以外 {other})· commit {commits}", + cumulativeTitle: "実働の累積", + cumulativeDetail: "{date}: 累計 {total}", + weeksTitle: "週ごと(月曜から)", + week: "週", + weekOf: "{date} 週", + activeDays: "動いた日", + commitColumn: "commit", + noDays: "描く日がない。", }, states: { done: "到達",