From 5399fcec20996acb6f28ebbe3920942fd7feddc2 Mon Sep 17 00:00:00 2001 From: tegwick Date: Mon, 30 Mar 2026 00:48:22 +0200 Subject: [PATCH] fix(dashboard): merge token + event charts into single dual-axis chart on Progress page Replaces the two separate charts with one combined area+line chart. Events use the left y-axis (steelblue); tokens use a normalized scale with a right y-axis (amber) that formats values as k/M. When no token data exists yet the right axis is omitted and a legend note explains. Hover tooltips show actual values for both series. Co-Authored-By: Claude Sonnet 4.6 --- state-hub/dashboard/src/progress.md | 72 ++++++++++++++--------------- 1 file changed, 34 insertions(+), 38 deletions(-) diff --git a/state-hub/dashboard/src/progress.md b/state-hub/dashboard/src/progress.md index 6237803..9e93eae 100644 --- a/state-hub/dashboard/src/progress.md +++ b/state-hub/dashboard/src/progress.md @@ -57,59 +57,55 @@ if (_h1) { _h1.style.position = "relative"; withDocHelp(_h1, "/docs/progress-log import * as Plot from "npm:@observablehq/plot"; const cutoff = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); + const byDay = Object.entries( data .filter(e => new Date(e.created_at) >= cutoff) - .reduce((acc, e) => { - const day = e.created_at.slice(0, 10); - acc[day] = (acc[day] ?? 0) + 1; - return acc; - }, {}) + .reduce((acc, e) => { const d = e.created_at.slice(0, 10); acc[d] = (acc[d] ?? 0) + 1; return acc; }, {}) ).sort().map(([day, count]) => ({day, count})); +const tokensByDay = Object.entries( + tokenEvents + .filter(e => new Date(e.created_at) >= cutoff) + .reduce((acc, e) => { const d = e.created_at.slice(0, 10); acc[d] = (acc[d] ?? 0) + (e.tokens_in || 0) + (e.tokens_out || 0); return acc; }, {}) +).sort().map(([day, tokens]) => ({day, tokens})); + +// Scale tokens onto the events axis for dual-axis display +const maxEvents = Math.max(1, ...byDay.map(d => d.count)); +const maxTokens = Math.max(1, ...tokensByDay.map(d => d.tokens)); +const ratio = maxTokens / maxEvents; +const fmtTokens = v => { const t = v * ratio; return t >= 1e6 ? (t/1e6).toFixed(1)+"M" : t >= 1e3 ? Math.round(t/1e3)+"k" : String(Math.round(t)); }; + display(byDay.length === 0 ? html`

No events in the last 30 days.

` : Plot.plot({ - title: "Progress events per day (30-day window)", + title: "Progress events & tokens per day (30-day window)", x: {label: "Date", tickRotate: -30}, - y: {label: "Events", grid: true}, + y: {label: "← Events", grid: true}, + marginBottom: 60, + marginRight: tokensByDay.length > 0 ? 70 : 20, + width: 750, marks: [ - Plot.areaY(byDay, {x: "day", y: "count", fill: "steelblue", fillOpacity: 0.3}), - Plot.lineY(byDay, {x: "day", y: "count", stroke: "steelblue"}), + Plot.areaY(byDay, {x: "day", y: "count", fill: "steelblue", fillOpacity: 0.25}), + Plot.lineY(byDay, {x: "day", y: "count", stroke: "steelblue", strokeWidth: 1.5}), + Plot.tip(byDay, Plot.pointerX({x: "day", y: "count", + title: d => `${d.day}\n${d.count} event${d.count === 1 ? "" : "s"}`})), + ...(tokensByDay.length > 0 ? [ + Plot.areaY(tokensByDay, {x: "day", y: d => d.tokens / ratio, fill: "#f28e2b", fillOpacity: 0.2}), + Plot.lineY(tokensByDay, {x: "day", y: d => d.tokens / ratio, stroke: "#f28e2b", strokeWidth: 1.5}), + Plot.tip(tokensByDay, Plot.pointerX({x: "day", y: d => d.tokens / ratio, + title: d => `${d.day}\n${d.tokens.toLocaleString()} tokens`})), + Plot.axisY({anchor: "right", label: "Tokens →", tickFormat: fmtTokens}), + ] : []), Plot.ruleY([0]), ], - marginBottom: 60, - width: 750, }) ); -``` -```js -const tokensByDay = Object.entries( - tokenEvents - .filter(e => new Date(e.created_at) >= cutoff) - .reduce((acc, e) => { - const day = e.created_at.slice(0, 10); - acc[day] = (acc[day] ?? 0) + (e.tokens_in || 0) + (e.tokens_out || 0); - return acc; - }, {}) -).sort().map(([day, tokens]) => ({day, tokens})); - -display(tokensByDay.length === 0 - ? html`

No token data in the last 30 days.

` - : Plot.plot({ - title: "Tokens consumed per day (30-day window)", - x: {label: "Date", tickRotate: -30}, - y: {label: "Tokens", grid: true, tickFormat: "~s"}, - marks: [ - Plot.areaY(tokensByDay, {x: "day", y: "tokens", fill: "#f28e2b", fillOpacity: 0.3}), - Plot.lineY(tokensByDay, {x: "day", y: "tokens", stroke: "#f28e2b"}), - Plot.ruleY([0]), - ], - marginBottom: 60, - width: 750, - }) -); +if (byDay.length > 0) display(html`
+ Events (left axis) + ${tokensByDay.length > 0 ? html` Tokens (right axis)` : html`No token data yet`} +
`); ``` ## Event Log