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 <noreply@anthropic.com>
This commit is contained in:
@@ -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`<p style="color:gray">No events in the last 30 days.</p>`
|
||||
: 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`<p style="color:gray">No token data in the last 30 days.</p>`
|
||||
: 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`<div style="font-size:0.78rem;display:flex;gap:1.2rem;margin-top:0.4rem;color:var(--theme-foreground-muted)">
|
||||
<span><span style="color:steelblue">▬</span> Events (left axis)</span>
|
||||
${tokensByDay.length > 0 ? html`<span><span style="color:#f28e2b">▬</span> Tokens (right axis)</span>` : html`<span style="font-style:italic">No token data yet</span>`}
|
||||
</div>`);
|
||||
```
|
||||
|
||||
## Event Log
|
||||
|
||||
Reference in New Issue
Block a user