Skip to content

Commit 7aceb14

Browse files
Alexey Panfilovclaude
andcommitted
feat(adminapi): split usage chart into stacked calls + cost panels
Replace the single overlay chart with two separate SVGs stacked vertically. Calls on top, cost on bottom, same X-axis layout so day columns line up exactly — you can drop a mental plumb line from a calls bar straight onto the cost bar for the same day. Each panel now shows proper axes: * Y-axis line on the left with 3 tick labels (0, mid, max). Calls show as integers; cost uses priceFmt ($0.0001 precision when needed). Horizontal dashed guides at each tick help read values off the bars. * Small left-of-axis label tags ("calls" / "cost") identify each panel without a legend. * Shared X-axis labels render only under the bottom panel so the stack reads as a single figure. Bar rendering keeps the adaptive width from the previous fix. Tooltips now show both metrics on hover (calls + cost, newline-separated) so hovering either bar tells the full story of that day. Rects animate to full opacity on hover for quicker eye-tracking. The old line-overlay approach was compact but confusing — when both metrics peaked on the same day the two renderings looked interchangeable. Stacked panels with independent Y-axes make "many cheap calls" vs "few expensive calls" visually unambiguous and give concrete numeric reference points at the three tick lines. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent df7b842 commit 7aceb14

3 files changed

Lines changed: 124 additions & 93 deletions

File tree

internal/adminapi/templates/partials_layout.html

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,9 +39,9 @@
3939
.usage-card__sub { font-size: 0.8em; color: var(--text-secondary, rgba(0,0,0,0.6)); margin-top: 0.25rem; }
4040
.usage-chart-wrap { margin-bottom: 1.5rem; padding: 0.75rem; background: rgba(0,0,0,0.03); border-radius: var(--radius, 8px); max-width: 640px; }
4141
.usage-chart-title { font-size: 0.85em; margin-bottom: 0.5rem; color: var(--text-secondary, rgba(0,0,0,0.7)); }
42-
.usage-chart { width: 100%; height: auto; display: block; max-height: 160px; }
43-
.usage-chart-legend { font-size: 0.85em; display: flex; gap: 1.5rem; margin-top: 0.5rem; color: var(--text-secondary, rgba(0,0,0,0.7)); }
44-
.usage-chart-legend__swatch { display: inline-block; width: 10px; height: 10px; margin-right: 0.25rem; border-radius: 2px; vertical-align: middle; }
42+
.usage-chart { width: 100%; height: auto; display: block; max-height: 140px; }
43+
.usage-chart + .usage-chart { margin-top: 0.25rem; }
44+
.usage-chart rect:hover { opacity: 1 !important; }
4545
.usage-section-h { font-size: 0.95em; margin-top: 1.5rem; margin-bottom: 0.5rem; font-weight: 600; }
4646
.usage-turns td { vertical-align: top; }
4747
.usage-turn__q { color: var(--text-primary, #000); margin-bottom: 0.25rem; font-size: 0.9em; }

internal/adminapi/templates/usage.html

Lines changed: 37 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -56,40 +56,53 @@
5656
</div>
5757
</div>
5858

59-
<!-- Daily chart (last 30 days regardless of period toggle) -->
59+
<!-- Daily charts (last 30 days regardless of period toggle) -->
6060
{{if gt .DailyChart.Days 0}}
61+
{{$c := .DailyChart}}
6162
<div class="usage-chart-wrap">
6263
<div class="usage-chart-title">
63-
Daily calls & cost — {{.DailyChart.Days}} day(s) with activity in the last 30.
64-
<span style="color:var(--text-secondary);">Peak: {{.DailyChart.MaxCalls}} calls · {{priceFmt .DailyChart.MaxCost}}.</span>
64+
Daily activity — {{$c.Days}} day(s) with usage in the last 30.
6565
</div>
66-
<svg viewBox="0 0 {{.DailyChart.Width}} {{.DailyChart.Height}}" class="usage-chart" xmlns="http://www.w3.org/2000/svg">
67-
<!-- baseline -->
68-
<line x1="12" y1="{{.DailyChart.PlotBot}}" x2="{{sub .DailyChart.Width 12}}" y2="{{.DailyChart.PlotBot}}" stroke="#ccc" stroke-width="1" />
69-
<!-- calls bars -->
70-
{{range .DailyChart.Bars}}
71-
<rect x="{{.X}}" y="{{.CallsY}}" width="{{.W}}" height="{{.CallsH}}" fill="#3b82f6" opacity="0.75" rx="1">
72-
<title>{{.DayLabel}}: {{.Calls}} calls · {{priceFmt .CostUSD}}</title>
66+
67+
<!-- Calls chart (top) -->
68+
<svg viewBox="0 0 {{$c.Width}} {{$c.Height}}" class="usage-chart" xmlns="http://www.w3.org/2000/svg">
69+
<!-- y-axis line -->
70+
<line x1="{{$c.PlotLeft}}" y1="{{$c.PlotTop}}" x2="{{$c.PlotLeft}}" y2="{{$c.PlotBot}}" stroke="#bbb" />
71+
<!-- y-axis ticks + labels + horizontal guides -->
72+
{{range $c.CallsYAxis}}
73+
<line x1="{{sub $c.PlotLeft 3}}" y1="{{.Y}}" x2="{{$c.PlotLeft}}" y2="{{.Y}}" stroke="#bbb" />
74+
<line x1="{{$c.PlotLeft}}" y1="{{.Y}}" x2="{{$c.PlotRight}}" y2="{{.Y}}" stroke="#eee" stroke-dasharray="2 3" />
75+
<text x="{{sub $c.PlotLeft 6}}" y="{{add .Y 3}}" font-size="9" fill="var(--text-secondary, #666)" text-anchor="end">{{.Label}}</text>
76+
{{end}}
77+
<!-- y-axis label -->
78+
<text x="10" y="{{add $c.PlotTop 4}}" font-size="9" fill="var(--text-secondary, #888)" font-weight="600">calls</text>
79+
<!-- bars -->
80+
{{range $c.CallsBars}}
81+
<rect x="{{.X}}" y="{{.Y}}" width="{{.W}}" height="{{.H}}" fill="#3b82f6" opacity="0.8" rx="1">
82+
<title>{{.DayLabel}}&#10;{{.Calls}} calls · {{priceFmt .CostUSD}}</title>
7383
</rect>
7484
{{end}}
75-
<!-- cost line overlay -->
76-
{{if .DailyChart.CostPath}}
77-
<path d="{{.DailyChart.CostPath}}" fill="none" stroke="#10b981" stroke-width="1.5" />
85+
</svg>
86+
87+
<!-- Cost chart (bottom) — same X-axis layout for day-by-day comparison -->
88+
<svg viewBox="0 0 {{$c.Width}} {{$c.Height}}" class="usage-chart" xmlns="http://www.w3.org/2000/svg">
89+
<line x1="{{$c.PlotLeft}}" y1="{{$c.PlotTop}}" x2="{{$c.PlotLeft}}" y2="{{$c.PlotBot}}" stroke="#bbb" />
90+
{{range $c.CostYAxis}}
91+
<line x1="{{sub $c.PlotLeft 3}}" y1="{{.Y}}" x2="{{$c.PlotLeft}}" y2="{{.Y}}" stroke="#bbb" />
92+
<line x1="{{$c.PlotLeft}}" y1="{{.Y}}" x2="{{$c.PlotRight}}" y2="{{.Y}}" stroke="#eee" stroke-dasharray="2 3" />
93+
<text x="{{sub $c.PlotLeft 6}}" y="{{add .Y 3}}" font-size="9" fill="var(--text-secondary, #666)" text-anchor="end">{{.Label}}</text>
7894
{{end}}
79-
{{range .DailyChart.CostDots}}
80-
<circle cx="{{.X}}" cy="{{.CostY}}" r="2.5" fill="#10b981">
81-
<title>{{.DayLabel}}: {{priceFmt .CostUSD}} · {{.Calls}} calls</title>
82-
</circle>
95+
<text x="10" y="{{add $c.PlotTop 4}}" font-size="9" fill="var(--text-secondary, #888)" font-weight="600">cost</text>
96+
{{range $c.CostBars}}
97+
<rect x="{{.X}}" y="{{.Y}}" width="{{.W}}" height="{{.H}}" fill="#10b981" opacity="0.8" rx="1">
98+
<title>{{.DayLabel}}&#10;{{priceFmt .CostUSD}} · {{.Calls}} calls</title>
99+
</rect>
83100
{{end}}
84-
<!-- x-axis labels (sparse) -->
85-
{{range .DailyChart.XAxisTags}}
86-
<text x="{{.X}}" y="{{sub $.DailyChart.Height 6}}" font-size="9" fill="var(--text-secondary, #666)" text-anchor="middle">{{.Label}}</text>
101+
<!-- Shared X-axis labels: only on the bottom chart so the stack reads as one figure. -->
102+
{{range $c.XAxisTags}}
103+
<text x="{{.X}}" y="{{sub $c.Height 4}}" font-size="9" fill="var(--text-secondary, #666)" text-anchor="middle">{{.Label}}</text>
87104
{{end}}
88105
</svg>
89-
<div class="usage-chart-legend">
90-
<span><span class="usage-chart-legend__swatch" style="background:#3b82f6;opacity:0.75;"></span> calls</span>
91-
<span><span class="usage-chart-legend__swatch" style="background:#10b981;"></span> cost (USD)</span>
92-
</div>
93106
</div>
94107
{{end}}
95108

internal/adminapi/usage.go

Lines changed: 84 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -42,36 +42,45 @@ type expensiveTurnView struct {
4242
Answer string // first line, up to 200 chars
4343
}
4444

45-
// dailyChart is a pre-sized SVG description rendered in the template — keeps
46-
// the template free of math and the HTML free of JS dependencies.
47-
//
48-
// Layout: calls are rendered as solid vertical bars (primary axis, left).
49-
// Cost is a line + point overlay on a secondary axis (right). Two visually
50-
// distinct elements — you can tell at a glance whether a day was "lots of
51-
// cheap calls" or "few expensive calls".
45+
// dailyChart describes two stacked SVG charts (calls on top, cost below)
46+
// that share the same X-axis layout so day columns line up exactly. Each
47+
// chart carries its own Y-axis ticks with labels. Pre-computed here so the
48+
// template stays math-free.
5249
type dailyChart struct {
53-
Width int
54-
Height int
55-
PlotTop int // y coordinate of the top of the plotting area
56-
PlotBot int // y coordinate of the axis line
57-
Bars []dailyBar
58-
CostPath string // SVG path data "M x y L x y L ..." for the cost line
59-
CostDots []dailyBar // reuses bar fields for dot positions (X + CostY)
60-
MaxCalls int
50+
Width int
51+
Height int // per-chart height (each chart renders separately)
52+
PlotTop int
53+
PlotBot int
54+
PlotLeft int // x-coordinate of the Y-axis line
55+
PlotRight int
56+
Days int
57+
// Calls series (bar chart, top)
58+
CallsBars []chartBar
59+
CallsYAxis []yTick
60+
MaxCalls int
61+
// Cost series (bar chart, bottom)
62+
CostBars []chartBar
63+
CostYAxis []yTick
6164
MaxCost float64
62-
Days int
63-
XAxisTags []xAxisTag // sparse labels at first/middle/last day
65+
// Shared X-axis — applied to the cost chart only (bottom of the stack)
66+
XAxisTags []xAxisTag
6467
}
6568

66-
type dailyBar struct {
69+
type chartBar struct {
6770
X int
6871
W int
69-
CallsY int
70-
CallsH int
71-
CostY int
72+
Y int
73+
H int
7274
DayLabel string
73-
Calls int
74-
CostUSD float64
75+
// Tooltip content — both metrics shown on hover so users don't need to
76+
// cross-reference.
77+
Calls int
78+
CostUSD float64
79+
}
80+
81+
type yTick struct {
82+
Y int
83+
Label string
7584
}
7685

7786
type xAxisTag struct {
@@ -191,25 +200,27 @@ func resolvePeriod(period string) (time.Time, string) {
191200
}
192201
}
193202

194-
// buildDailyChart turns a slice of UsageDayBucket into SVG coordinates. Calls
195-
// are bars; cost is an overlaid line with markers. Size is fixed 480×120;
196-
// bar width is adaptive so sparse data (1-3 days) still looks like a chart
197-
// and not one lonely pixel-wide sliver.
203+
// buildDailyChart produces two synchronized SVG chart descriptions (calls
204+
// on top, cost on bottom) sharing padLeft/padRight + slot widths so day
205+
// columns line up vertically. Size: 520×100 per chart, Y-axis on the left
206+
// with up to 3 tick labels (0 / mid / max).
198207
func buildDailyChart(buckets []llm.UsageDayBucket) dailyChart {
199208
const (
200-
width = 480
201-
height = 120
202-
padLeft = 12
203-
padRight = 12
204-
padTop = 10
205-
padBottom = 22
209+
width = 520
210+
height = 100
211+
padLeft = 48 // room for Y-axis labels
212+
padRight = 8
213+
padTop = 8
214+
padBottom = 20
206215
)
207216
c := dailyChart{
208-
Width: width,
209-
Height: height,
210-
PlotTop: padTop,
211-
PlotBot: height - padBottom,
212-
Days: len(buckets),
217+
Width: width,
218+
Height: height,
219+
PlotTop: padTop,
220+
PlotBot: height - padBottom,
221+
PlotLeft: padLeft,
222+
PlotRight: width - padRight,
223+
Days: len(buckets),
213224
}
214225
if len(buckets) == 0 {
215226
return c
@@ -231,55 +242,62 @@ func buildDailyChart(buckets []llm.UsageDayBucket) dailyChart {
231242
c.MaxCost = 0.0001
232243
}
233244

234-
plotW := width - padLeft - padRight
245+
plotW := c.PlotRight - c.PlotLeft
235246
plotH := c.PlotBot - c.PlotTop
236247
slot := float64(plotW) / float64(len(buckets))
237-
// Bars are up to 60% of a slot, capped at 28px to look sensible for
238-
// sparse data without becoming huge.
239-
barW := int(slot * 0.6)
248+
barW := int(slot * 0.65)
240249
if barW < 2 {
241250
barW = 2
242251
}
243252
if barW > 28 {
244253
barW = 28
245254
}
246255

247-
var pathBuilder strings.Builder
248256
for i, b := range buckets {
249-
slotCenter := padLeft + int((float64(i)+0.5)*slot)
257+
slotCenter := c.PlotLeft + int((float64(i)+0.5)*slot)
250258
barX := slotCenter - barW/2
259+
dayLabel := b.Day.Format("01-02")
251260

261+
// Calls bar
252262
callsH := int(float64(b.Calls) / float64(c.MaxCalls) * float64(plotH))
253263
if callsH < 0 {
254264
callsH = 0
255265
}
256-
costY := c.PlotBot - int(b.CostUSD/c.MaxCost*float64(plotH))
257-
258-
bar := dailyBar{
259-
X: barX,
260-
W: barW,
261-
CallsY: c.PlotBot - callsH,
262-
CallsH: callsH,
263-
CostY: costY,
264-
DayLabel: b.Day.Format("01-02"),
265-
Calls: b.Calls,
266-
CostUSD: b.CostUSD,
267-
}
268-
c.Bars = append(c.Bars, bar)
269-
c.CostDots = append(c.CostDots, dailyBar{X: slotCenter, CostY: costY, DayLabel: bar.DayLabel, Calls: bar.Calls, CostUSD: bar.CostUSD})
266+
c.CallsBars = append(c.CallsBars, chartBar{
267+
X: barX, W: barW,
268+
Y: c.PlotBot - callsH, H: callsH,
269+
DayLabel: dayLabel, Calls: b.Calls, CostUSD: b.CostUSD,
270+
})
270271

271-
if i == 0 {
272-
fmt.Fprintf(&pathBuilder, "M %d %d", slotCenter, costY)
273-
} else {
274-
fmt.Fprintf(&pathBuilder, " L %d %d", slotCenter, costY)
272+
// Cost bar
273+
costH := int(b.CostUSD / c.MaxCost * float64(plotH))
274+
if costH < 0 {
275+
costH = 0
275276
}
277+
c.CostBars = append(c.CostBars, chartBar{
278+
X: barX, W: barW,
279+
Y: c.PlotBot - costH, H: costH,
280+
DayLabel: dayLabel, Calls: b.Calls, CostUSD: b.CostUSD,
281+
})
282+
}
283+
284+
// Y-axis ticks: 0, max/2, max — 3 lines only, keeps chart uncluttered
285+
// while giving the reader concrete numbers to reference.
286+
c.CallsYAxis = []yTick{
287+
{Y: c.PlotBot, Label: "0"},
288+
{Y: c.PlotTop + (c.PlotBot-c.PlotTop)/2, Label: intFmt(c.MaxCalls / 2)},
289+
{Y: c.PlotTop, Label: intFmt(c.MaxCalls)},
290+
}
291+
c.CostYAxis = []yTick{
292+
{Y: c.PlotBot, Label: "$0"},
293+
{Y: c.PlotTop + (c.PlotBot-c.PlotTop)/2, Label: priceFmt(c.MaxCost / 2)},
294+
{Y: c.PlotTop, Label: priceFmt(c.MaxCost)},
276295
}
277-
c.CostPath = pathBuilder.String()
278296

279-
// X-axis labels: first, middle, last — keeps the axis readable on both
280-
// 1-day and 30-day views without collisions.
297+
// X-axis — sparse labels at first / middle / last day, applied to the
298+
// bottom chart in the template.
281299
addTag := func(i int) {
282-
slotCenter := padLeft + int((float64(i)+0.5)*slot)
300+
slotCenter := c.PlotLeft + int((float64(i)+0.5)*slot)
283301
c.XAxisTags = append(c.XAxisTags, xAxisTag{X: slotCenter, Label: buckets[i].Day.Format("01-02")})
284302
}
285303
addTag(0)

0 commit comments

Comments
 (0)