From 7e9661424c2c423a1681463c7e45dddee35fd5ff Mon Sep 17 00:00:00 2001 From: Takashi Matsuyama Date: Wed, 7 Oct 2026 21:35:14 +0900 Subject: [PATCH 1/5] basou: chart the period and effort on the board page of basou view The period and effort section of the board page now shows six counts (elapsed, active, Claude, Codex, per day, output tokens), the commits since the start, and three charts built as SVG nodes: the milestones on a line from the start, the active time by day with Claude's time and the part of Codex's not at the same time stacked, and the running total of the active time with the milestones as dashed lines. A day's figures show on hover, and a table gives the same by week, from Monday. The page data carries the days, the running total and the weeks, from the record's daily rows. The page's colours now come from variables, chosen for light and dark, and a chart keeps a readable width on a narrow screen, scrolling in its own frame. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 4 +- packages/cli/src/commands/view.test.ts | 156 ++++++++++- packages/cli/src/lib/board-ui.ts | 349 +++++++++++++++++++++---- packages/core/src/board/index.ts | 2 + packages/core/src/board/page.test.ts | 74 +++++- packages/core/src/board/page.ts | 100 +++++++ packages/core/src/lib/view-strings.ts | 65 ++++- 7 files changed, 684 insertions(+), 66 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bf7c6f28..dd7a2fa0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -158,7 +158,9 @@ 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 Codex's alone stacked, and the + running total, with a table by week), 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..2291aae4 100644 --- a/packages/cli/src/commands/view.test.ts +++ b/packages/cli/src/commands/view.test.ts @@ -1199,6 +1199,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 +1220,26 @@ describe("basou view: the board page", () => { "shown", "stateName", "section", + "svg", + "hourStep", + "monthTicks", + "plotWidth", + "dayX", + "hours", + "yAxis", + "xAxis", + "dayIndex", + "sideLabel", + "timelineChart", + "dailyChart", + "cumulativeChart", + "weeksTable", ]; - const source = [...helpers, ...names].map(lift).join("\n"); + const source = [ + "var SVGNS = 'http://www.w3.org/2000/svg';", + "var CHART = { w: 760, left: 44, right: 10 };", + ...[...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 +1316,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 +1340,123 @@ 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, Codex's alone 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, + codex_only: 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" }], + active_days: 2, + 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, and its label. + expect(timeline.find((n) => n.attrs.class === "dot")).toHaveLength(1); + expect(timeline.text()).toContain("Started"); + // Bars only where there was time: Claude on both days, Codex alone 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("codex")).toHaveLength(1); + const [claude2, codex2] = [bars("claude")[1] as Node, bars("codex")[0] as Node]; + expect(Number(codex2.attrs.y) + Number(codex2.attrs.height)).toBeLessThan( + Number(claude2.attrs.y), + ); + expect(Number(codex2.attrs.height)).toBeCloseTo(Number(claude2.attrs.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, Codex alone 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"]); + }); + + it("labels the months under a chart without crowding, and keeps a label in the right part inside", () => { + const document = { + createElementNS: (_ns: string, tag: string) => new Node(tag), + }; + const fns = new Function( + "document", + [ + "var SVGNS = 'x';", + "var CHART = { w: 760, left: 44, right: 10 };", + ...["svg", "monthTicks", "plotWidth", "dayX", "xAxis", "sideLabel"].map(lift), + "return { xAxis: xAxis, sideLabel: sideLabel };", + ].join("\n"), + )(document) as { + xAxis: (days: { date: string }[], base: number) => Node[]; + sideLabel: (x: number, y: number, text: string, middle: boolean) => Node; + }; + 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", + ]); + expect(fns.sideLabel(100, 10, "a", false).attrs["text-anchor"]).toBe("start"); + expect(fns.sideLabel(700, 10, "a", false).attrs["text-anchor"]).toBe("end"); + expect(fns.sideLabel(100, 10, "a", true).attrs["text-anchor"]).toBe("middle"); + expect(fns.sideLabel(700, 10, "a", true).attrs["text-anchor"]).toBe("end"); + }); + 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 +1499,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..ec717ec7 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,239 @@ 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; + } + // A label beside a mark: to the left of it in the right part of a chart, so it is not cut off. + function sideLabel(x, y, text, middle) { + var right = x > CHART.w * 0.7; + return svg('text', { + class: 'ms-label', x: middle ? x : right ? x - 4 : x + 4, y: y, + 'text-anchor': middle && !right ? 'middle' : right ? 'end' : 'start', text: 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 = 86; + var mid = 46; + 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 placed = 0; + milestones.forEach(function (m) { + var i = dayIndex(days, m.date); + if (i === -1) return; + var x = dayX(i, days.length); + var above = placed % 2 === 0; + placed++; + nodes.push(svg('circle', { class: 'dot', cx: x, cy: mid, r: 5 }, [ + svg('title', { text: m.date + ' ' + m.label + ' ' + m.ref }) + ])); + nodes.push(sideLabel(x, above ? mid - 12 : mid + 22, m.label, true)); + }); + 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 xh = hours(d.codex_only) * scale; + var gap = ch > 0 && xh > 0 && band > 4 ? 2 : 0; + if (ch > 0) nodes.push(svg('rect', { class: 'claude', x: x, y: base - ch, width: width, height: ch })); + if (xh > 0) nodes.push(svg('rect', { class: 'codex', x: x, y: base - ch - gap - xh, width: width, height: xh })); + 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), codex: hm(d.codex_only), 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.codexAlone]) + ]); + 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 placed = 0; + milestones.forEach(function (m) { + var i = dayIndex(days, m.date); + if (i === -1) return; + var x = dayX(i, days.length); + nodes.push(svg('line', { class: 'ms', x1: x, x2: x, y1: top, y2: base })); + nodes.push(sideLabel(x, top + 10 + (placed % 3) * 13, m.label, false)); + placed++; + }); + 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) { + 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: 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.elapsed_days ? null : union / e.elapsed_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 - ]); + }))); + if (e.weeks && e.weeks.length > 0) children.push(weeksTable(e.weeks)); + 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..dd32f8a1 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,6 +361,52 @@ describe("boardPage", () => { { repo: "../basou", count: 34 }, ], milestones: [{ date: "2026-05-04", label: "Started", ref: "abc1234" }], + active_days: 2, + daily: [ + { + date: "2026-10-04", + union: 60_000, + claude: 60_000, + codex_only: 0, + cumulative: 60_000, + commits: 3, + }, + { + date: "2026-10-05", + union: 180_000, + claude: 120_000, + codex_only: 60_000, + cumulative: 240_000, + commits: 3, + }, + { + date: "2026-10-06", + union: null, + claude: null, + codex_only: null, + cumulative: null, + commits: null, + }, + { date: "2026-10-07", union: 0, claude: 0, codex_only: 0, cumulative: null, commits: 0 }, + ], + weeks: [ + { + week: "2026-09-28", + union: 60_000, + claude: 60_000, + codex: null, + active_days: 1, + commits: 3, + }, + { + week: "2026-10-05", + union: 180_000, + claude: 120_000, + codex: 90_000, + active_days: 1, + commits: 3, + }, + ], }); expect(b.turns).toEqual([{ text: "Decide X", source: "decision_X" }]); expect(b.footnotes).toEqual({ diff --git a/packages/core/src/board/page.ts b/packages/core/src/board/page.ts index 80b384a6..52d0446a 100644 --- a/packages/core/src/board/page.ts +++ b/packages/core/src/board/page.ts @@ -59,6 +59,15 @@ const pageRecordSchema = z.looseObject({ output_tokens: count, sessions_without_tokens: count, commits: z.record(text, count).nullable(), + daily: z + .array( + z.looseObject({ + date: text, + 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 +154,26 @@ 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; + codex_only: number | null; + cumulative: number | null; + commits: number | null; +}; + +/** One week of the effort, from its Monday. */ +export type BoardPageWeek = { + week: string; + union: number | null; + claude: number | null; + codex: number | null; + active_days: number; + commits: number | null; +}; + /** What the board page draws from one record: the eight sections, in order. */ export type BoardPageBody = { heading: { @@ -164,6 +193,16 @@ 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. */ + active_days: number | null; + /** + * Each day from the start, in milliseconds: the active time, Claude's, the + * part of Codex's not at the same time as 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 +451,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 +559,63 @@ 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 of what was measured, null when nothing of it was. +function sumOf(values: readonly (number | null)[]): number | null { + const measured = values.filter((v): v is number => v !== null); + return measured.length === 0 ? null : measured.reduce((a, b) => a + b, 0); +} + +// 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; + daily: BoardPageDay[] | null; + weeks: BoardPageWeek[] | null; +} { + if (rows === null) return { active_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, + codex_only: 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: days.filter((r) => (r.active_ms.union ?? 0) > 0).length, + commits: sumOf(days.flatMap((r) => Object.values(r.commits))), + })); + return { + active_days: rows.filter((r) => (r.active_ms.union ?? 0) > 0).length, + daily, + weeks, + }; +} diff --git a/packages/core/src/lib/view-strings.ts b/packages/core/src/lib/view-strings.ts index febae7a2..3f3a67ce 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; + codexAlone: 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", + codexAlone: "Codex (not at the same time as Claude)", + dayDetail: "{date}: active {active} (Claude {claude}, Codex alone {codex}), 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: "日ごとの実働", + codexAlone: "Codex(Claude と重ならない分)", + dayDetail: "{date}: 実働 {active}(Claude {claude} / Codex のみ {codex})· commit {commits}", + cumulativeTitle: "実働の累積", + cumulativeDetail: "{date}: 累計 {total}", + weeksTitle: "週ごと(月曜から)", + week: "週", + weekOf: "{date} 週", + activeDays: "動いた日", + commitColumn: "commit", + noDays: "描く日がない。", }, states: { done: "到達", From 3463d6db78262359addae1f0a9bfe5038ead95a3 Mon Sep 17 00:00:00 2001 From: Takashi Matsuyama Date: Wed, 7 Oct 2026 21:38:14 +0900 Subject: [PATCH 2/5] test(board): hold the page to a record with no days measured Co-Authored-By: Claude Opus 5.5 --- packages/core/src/board/page.test.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/core/src/board/page.test.ts b/packages/core/src/board/page.test.ts index dd32f8a1..c0fc89c5 100644 --- a/packages/core/src/board/page.test.ts +++ b/packages/core/src/board/page.test.ts @@ -409,6 +409,14 @@ describe("boardPage", () => { ], }); expect(b.turns).toEqual([{ text: "Decide X", source: "decision_X" }]); + // 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, daily: null, weeks: null }); + await rm(join(records, `${B}.json`)); expect(b.footnotes).toEqual({ notes: ["Measured with care."], axis: { From 0f5e66beae711afde7cd1a043c77a6e416af5092 Mon Sep 17 00:00:00 2001 From: Takashi Matsuyama Date: Wed, 7 Oct 2026 22:02:19 +0900 Subject: [PATCH 3/5] fix(board): name the rest of the active time as it is, and total only what was measured The part of a day's active time above Claude's is everything not Claude's (Codex's, and work by hand or in a terminal), so it is now named that way; a record's days cannot tell Codex's alone apart. A day or a week part of which was not measured no longer shows a partial total: its commits and days worked are not measured. The per-day figure divides by the days from the start to today, the days the active time covers. With no Codex session at all, the week table's Codex column says so. A day that is not a date of the calendar makes the record not one the page draws, said in the page's language. Milestone labels are kept inside a chart and off each other, and a stack's top stands at the day's total. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 5 +- packages/cli/src/commands/view.test.ts | 127 ++++++++++++++++++++----- packages/cli/src/lib/board-ui.ts | 78 ++++++++++----- packages/core/src/board/page.test.ts | 54 ++++++++--- packages/core/src/board/page.ts | 55 +++++++---- packages/core/src/lib/view-strings.ts | 10 +- 6 files changed, 243 insertions(+), 86 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dd7a2fa0..eee75da1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -159,8 +159,9 @@ All notable changes to **basou** are recorded here. The project follows 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 (six counts, the commits, and charts of the milestones, the - active time by day with Claude's and Codex's alone stacked, and the - running total, with a table by week), the reach matrix with the cells that moved since the previous + 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 2291aae4..843eb9ad 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 { @@ -1229,15 +1235,16 @@ describe("basou view: the board page", () => { "yAxis", "xAxis", "dayIndex", - "sideLabel", + "labelWidth", + "placeLabels", "timelineChart", "dailyChart", "cumulativeChart", "weeksTable", ]; const source = [ - "var SVGNS = 'http://www.w3.org/2000/svg';", - "var CHART = { w: 760, left: 44, right: 10 };", + 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(", ")} };`)( @@ -1340,7 +1347,7 @@ 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, Codex's alone above it, the running total and the weeks", () => { + 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, @@ -1351,7 +1358,7 @@ describe("basou view: the board page", () => { date, union, claude, - codex_only: union === null || claude === null ? null : union - claude, + not_claude: union === null || claude === null ? null : union - claude, cumulative, commits: 1, }); @@ -1365,8 +1372,13 @@ describe("basou view: the board page", () => { output_tokens: 1000, sessions_without_tokens: 0, commits: [{ repo: ".", count: 3 }], - milestones: [{ date: "2026-05-01", label: "Started", ref: "abc" }], + 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), @@ -1400,20 +1412,22 @@ describe("basou view: the board page", () => { 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, and its label. + // 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"); - // Bars only where there was time: Claude on both days, Codex alone on the second, above Claude. + 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("codex")).toHaveLength(1); - const [claude2, codex2] = [bars("claude")[1] as Node, bars("codex")[0] as Node]; - expect(Number(codex2.attrs.y) + Number(codex2.attrs.height)).toBeLessThan( - Number(claude2.attrs.y), - ); - expect(Number(codex2.attrs.height)).toBeCloseTo(Number(claude2.attrs.height), 5); + 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, Codex alone 2h 00m), commits 1", + "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; @@ -1421,23 +1435,63 @@ describe("basou view: the board page", () => { 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 keeps a label in the right part inside", () => { + 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", [ - "var SVGNS = 'x';", - "var CHART = { w: 760, left: 44, right: 10 };", - ...["svg", "monthTicks", "plotWidth", "dayX", "xAxis", "sideLabel"].map(lift), - "return { xAxis: xAxis, sideLabel: sideLabel };", + liftVar("SVGNS"), + liftVar("CHART"), + ...["svg", "monthTicks", "plotWidth", "dayX", "xAxis", "labelWidth", "placeLabels"].map( + lift, + ), + "return { xAxis: xAxis, placeLabels: placeLabels, CHART: CHART };", ].join("\n"), )(document) as { xAxis: (days: { date: string }[], base: number) => Node[]; - sideLabel: (x: number, y: number, text: string, middle: boolean) => Node; + placeLabels: ( + items: { x: number; text: string }[], + rows: number, + centred: boolean, + ) => { x: number; row: number; text: string }[]; + CHART: { w: number; left: number; right: number }; }; const days = Array.from({ length: 160 }, (_, i) => ({ date: new Date(Date.UTC(2026, 3, 28 + i)).toISOString().slice(0, 10), @@ -1451,10 +1505,33 @@ describe("basou view: the board page", () => { "9/1", "10/1", ]); - expect(fns.sideLabel(100, 10, "a", false).attrs["text-anchor"]).toBe("start"); - expect(fns.sideLabel(700, 10, "a", false).attrs["text-anchor"]).toBe("end"); - expect(fns.sideLabel(100, 10, "a", true).attrs["text-anchor"]).toBe("middle"); - expect(fns.sideLabel(700, 10, "a", true).attrs["text-anchor"]).toBe("end"); + 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", () => { diff --git a/packages/cli/src/lib/board-ui.ts b/packages/cli/src/lib/board-ui.ts index ec717ec7..070ceb07 100644 --- a/packages/cli/src/lib/board-ui.ts +++ b/packages/cli/src/lib/board-ui.ts @@ -94,7 +94,7 @@ export const BOARD_HTML = ` svg.chart .grid { stroke: var(--grid); stroke-width: 1; } svg.chart .axis { stroke: var(--border); stroke-width: 1; } svg.chart .claude { fill: var(--s1); } - svg.chart .codex { fill: var(--s2); } + svg.chart .other { fill: var(--s2); } svg.chart .line { fill: none; stroke: var(--s1); stroke-width: 2; } svg.chart .ms { stroke: var(--fg-2); stroke-width: 1; stroke-dasharray: 3 3; } svg.chart .dot { fill: var(--s1); stroke: var(--bg); stroke-width: 2; } @@ -364,12 +364,32 @@ export const BOARD_HTML = ` }); return out; } - // A label beside a mark: to the left of it in the right part of a chart, so it is not cut off. - function sideLabel(x, y, text, middle) { - var right = x > CHART.w * 0.7; - return svg('text', { - class: 'ms-label', x: middle ? x : right ? x - 4 : x + 4, y: y, - 'text-anchor': middle && !right ? 'middle' : right ? 'end' : 'start', text: text + // 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) { @@ -379,8 +399,8 @@ export const BOARD_HTML = ` // Milestones on a line from the first day to the last. function timelineChart(days, milestones) { - var h = 86; - var mid = 46; + 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); @@ -389,17 +409,20 @@ export const BOARD_HTML = ` nodes.push(svg('circle', { class: 'start', cx: dayX(0, days.length), cy: mid, r: 5 }, [ svg('title', { text: days[0].date }) ])); - var placed = 0; + var marks = []; milestones.forEach(function (m) { var i = dayIndex(days, m.date); if (i === -1) return; var x = dayX(i, days.length); - var above = placed % 2 === 0; - placed++; + 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 }) ])); - nodes.push(sideLabel(x, above ? mid - 12 : mid + 22, m.label, true)); + }); + // 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 }), @@ -423,20 +446,21 @@ export const BOARD_HTML = ` days.forEach(function (d, i) { var x = CHART.left + i * band + (band - width) / 2; var ch = hours(d.claude) * scale; - var xh = hours(d.codex_only) * scale; - var gap = ch > 0 && xh > 0 && band > 4 ? 2 : 0; + 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 (xh > 0) nodes.push(svg('rect', { class: 'codex', x: x, y: base - ch - gap - xh, width: width, height: xh })); + 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), codex: hm(d.codex_only), commits: num(d.commits) + 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.codexAlone]) + el('span', null, [el('span', { class: 'swatch s2' }), S.effort.notClaude]) ]); return el('figure', null, [ el('figcaption', { text: S.effort.dailyTitle }), @@ -456,14 +480,16 @@ export const BOARD_HTML = ` 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 placed = 0; + 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 })); - nodes.push(sideLabel(x, top + 10 + (placed % 3) * 13, m.label, false)); - placed++; + }); + 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++) { @@ -484,7 +510,7 @@ export const BOARD_HTML = ` ]); } - function weeksTable(weeks) { + 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 }), @@ -493,7 +519,7 @@ export const BOARD_HTML = ` 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: hm(w.codex) }), + 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) }) ]); }); @@ -512,7 +538,7 @@ export const BOARD_HTML = ` ]); }; var union = e.active_ms.union; - var perDay = union === null || !e.elapsed_days ? null : union / e.elapsed_days; + 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' }, [ @@ -541,7 +567,9 @@ export const BOARD_HTML = ` : 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 })]); }))); - if (e.weeks && e.weeks.length > 0) children.push(weeksTable(e.weeks)); + // 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); } diff --git a/packages/core/src/board/page.test.ts b/packages/core/src/board/page.test.ts index c0fc89c5..f26a0a7f 100644 --- a/packages/core/src/board/page.test.ts +++ b/packages/core/src/board/page.test.ts @@ -361,13 +361,15 @@ describe("boardPage", () => { { repo: "../basou", count: 34 }, ], milestones: [{ date: "2026-05-04", label: "Started", ref: "abc1234" }], - active_days: 2, + // 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, - codex_only: 0, + not_claude: 0, cumulative: 60_000, commits: 3, }, @@ -375,7 +377,7 @@ describe("boardPage", () => { date: "2026-10-05", union: 180_000, claude: 120_000, - codex_only: 60_000, + not_claude: 60_000, cumulative: 240_000, commits: 3, }, @@ -383,11 +385,11 @@ describe("boardPage", () => { date: "2026-10-06", union: null, claude: null, - codex_only: null, + not_claude: null, cumulative: null, commits: null, }, - { date: "2026-10-07", union: 0, claude: 0, codex_only: 0, cumulative: null, commits: 0 }, + { date: "2026-10-07", union: 0, claude: 0, not_claude: 0, cumulative: null, commits: 0 }, ], weeks: [ { @@ -398,24 +400,54 @@ describe("boardPage", () => { active_days: 1, commits: 3, }, + // A week with a day not measured is not known as a whole. { week: "2026-10-05", - union: 180_000, - claude: 120_000, - codex: 90_000, - active_days: 1, - commits: 3, + 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 }, + }, + ] 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 }); + // 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, daily: null, weeks: null }); + 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."], diff --git a/packages/core/src/board/page.ts b/packages/core/src/board/page.ts index 52d0446a..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"; @@ -62,7 +63,7 @@ const pageRecordSchema = z.looseObject({ daily: z .array( z.looseObject({ - date: text, + date: z.string().refine(isCalendarDate), active_ms: z.looseObject({ union: count, claude: count, codex: count }), commits: z.record(text, count), }), @@ -159,18 +160,24 @@ export type BoardPageDay = { date: string; union: number | null; claude: number | null; - codex_only: 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. */ +/** 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; + active_days: number | null; commits: number | null; }; @@ -193,12 +200,17 @@ 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. */ + /** + * 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 of Codex's not at the same time as Claude's, and the running total - * of the active time (null from a day whose time was not measured on). + * 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. */ @@ -566,10 +578,20 @@ type MeasuredDay = { commits: Readonly>; }; -// The sum of what was measured, null when nothing of it was. +// The sum, null when any of it was not measured: a part is not the whole. function sumOf(values: readonly (number | null)[]): number | null { - const measured = values.filter((v): v is number => v !== null); - return measured.length === 0 ? null : measured.reduce((a, b) => a + b, 0); + 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. @@ -583,10 +605,11 @@ function mondayOf(date: string): string { 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, daily: null, weeks: 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; @@ -595,7 +618,7 @@ function daysOf(rows: readonly MeasuredDay[] | null): { date: row.date, union, claude, - codex_only: union === null || claude === null ? null : Math.max(0, union - claude), + not_claude: union === null || claude === null ? null : Math.max(0, union - claude), cumulative: total, commits: sumOf(Object.values(row.commits)), }; @@ -610,12 +633,8 @@ function daysOf(rows: readonly MeasuredDay[] | null): { 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: days.filter((r) => (r.active_ms.union ?? 0) > 0).length, + active_days: activeDaysOf(days), commits: sumOf(days.flatMap((r) => Object.values(r.commits))), })); - return { - active_days: rows.filter((r) => (r.active_ms.union ?? 0) > 0).length, - daily, - weeks, - }; + 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 3f3a67ce..27d3a62d 100644 --- a/packages/core/src/lib/view-strings.ts +++ b/packages/core/src/lib/view-strings.ts @@ -897,7 +897,7 @@ export type BoardPageStrings = { noMilestones: string; notMeasured: string; dailyTitle: string; - codexAlone: string; + notClaude: string; dayDetail: string; cumulativeTitle: string; cumulativeDetail: string; @@ -1023,8 +1023,8 @@ const BOARD_PAGE_EN: BoardPageStrings = { noMilestones: "No milestones declared.", notMeasured: "not measured", dailyTitle: "Active time by day", - codexAlone: "Codex (not at the same time as Claude)", - dayDetail: "{date}: active {active} (Claude {claude}, Codex alone {codex}), commits {commits}", + 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", @@ -1154,8 +1154,8 @@ const BOARD_PAGE_JA: BoardPageStrings = { noMilestones: "節目の宣言はない。", notMeasured: "測れなかった", dailyTitle: "日ごとの実働", - codexAlone: "Codex(Claude と重ならない分)", - dayDetail: "{date}: 実働 {active}(Claude {claude} / Codex のみ {codex})· commit {commits}", + notClaude: "Claude 以外(Codex・人手・端末など。Claude と重ならない分)", + dayDetail: "{date}: 実働 {active}(Claude {claude} / Claude 以外 {other})· commit {commits}", cumulativeTitle: "実働の累積", cumulativeDetail: "{date}: 累計 {total}", weeksTitle: "週ごと(月曜から)", From 383d9d4ddf45c540c15e48791e6efe0085de9a48 Mon Sep 17 00:00:00 2001 From: Takashi Matsuyama Date: Wed, 7 Oct 2026 22:04:59 +0900 Subject: [PATCH 4/5] test(board): hold the width a wide character is reckoned at Co-Authored-By: Claude Opus 5.5 --- packages/cli/src/commands/view.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/commands/view.test.ts b/packages/cli/src/commands/view.test.ts index 843eb9ad..0c475e09 100644 --- a/packages/cli/src/commands/view.test.ts +++ b/packages/cli/src/commands/view.test.ts @@ -1482,7 +1482,7 @@ describe("basou view: the board page", () => { ...["svg", "monthTicks", "plotWidth", "dayX", "xAxis", "labelWidth", "placeLabels"].map( lift, ), - "return { xAxis: xAxis, placeLabels: placeLabels, CHART: CHART };", + "return { xAxis: xAxis, placeLabels: placeLabels, labelWidth: labelWidth, CHART: CHART };", ].join("\n"), )(document) as { xAxis: (days: { date: string }[], base: number) => Node[]; @@ -1491,8 +1491,12 @@ describe("basou view: the board page", () => { 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), })); From 7d5b8881aa9d04f8122f178aa3a5b3e05b63cfbb Mon Sep 17 00:00:00 2001 From: Takashi Matsuyama Date: Wed, 7 Oct 2026 22:07:11 +0900 Subject: [PATCH 5/5] test(board): hold a day with no time apart from a day worked Co-Authored-By: Claude Opus 5.5 --- packages/core/src/board/page.test.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/core/src/board/page.test.ts b/packages/core/src/board/page.test.ts index f26a0a7f..a1f623f8 100644 --- a/packages/core/src/board/page.test.ts +++ b/packages/core/src/board/page.test.ts @@ -421,12 +421,20 @@ describe("boardPage", () => { 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";