Skip to content

Commit df7b842

Browse files
Alexey Panfilovclaude
andcommitted
fix(adminapi): shrink daily chart + replace paired bars with bar+line
The chart was too large (720×160) and the two bars per day looked identical whenever both metrics peaked on the same day — each was independently normalised to its own axis max, so visually "calls at 100% of calls-max" is the same height as "cost at 100% of cost-max". New layout, 480×120 (CSS-capped to 160px tall, 640px wide): * Calls remain as vertical bars on the primary axis. * Cost moves to a line + point overlay on the right-hand axis. Line shape is distinct from bar shape, so a day with many cheap calls vs. few expensive ones reads correctly at a glance. * Bar width is adaptive (60% of a slot, capped at 28px) so a 2-day dataset shows thick readable bars instead of 8px slivers in a wide empty chart. * X-axis gets sparse labels at first / middle / last day (avoids collisions at 30 days, still informative at 1 day). * Tooltip on both the bar and the cost dot shows both numbers. Peak summary in the title keeps the old "calls peak / cost peak" annotation so exact values are one glance away. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 52d3f09 commit df7b842

3 files changed

Lines changed: 126 additions & 42 deletions

File tree

internal/adminapi/templates/partials_layout.html

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,9 +37,9 @@
3737
.usage-card__label { font-size: 0.8em; color: var(--text-secondary, rgba(0,0,0,0.6)); text-transform: uppercase; letter-spacing: 0.05em; }
3838
.usage-card__value { font-size: 1.5em; font-weight: 600; font-variant-numeric: tabular-nums; margin-top: 0.25rem; }
3939
.usage-card__sub { font-size: 0.8em; color: var(--text-secondary, rgba(0,0,0,0.6)); margin-top: 0.25rem; }
40-
.usage-chart-wrap { margin-bottom: 1.5rem; padding: 0.75rem; background: rgba(0,0,0,0.03); border-radius: var(--radius, 8px); }
40+
.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; }
42+
.usage-chart { width: 100%; height: auto; display: block; max-height: 160px; }
4343
.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)); }
4444
.usage-chart-legend__swatch { display: inline-block; width: 10px; height: 10px; margin-right: 0.25rem; border-radius: 2px; vertical-align: middle; }
4545
.usage-section-h { font-size: 0.95em; margin-top: 1.5rem; margin-bottom: 0.5rem; font-weight: 600; }

internal/adminapi/templates/usage.html

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -60,22 +60,34 @@
6060
{{if gt .DailyChart.Days 0}}
6161
<div class="usage-chart-wrap">
6262
<div class="usage-chart-title">
63-
Daily calls & cost (30-day window) — bar height is relative to the 30-day max.
64-
<span style="color:var(--text-secondary);">Calls peak {{.DailyChart.MaxCalls}}, cost peak {{priceFmt .DailyChart.MaxCost}}.</span>
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>
6565
</div>
6666
<svg viewBox="0 0 {{.DailyChart.Width}} {{.DailyChart.Height}}" class="usage-chart" xmlns="http://www.w3.org/2000/svg">
67-
<line x1="20" y1="{{sub .DailyChart.Height 20}}" x2="{{sub .DailyChart.Width 10}}" y2="{{sub .DailyChart.Height 20}}" stroke="#ccc" />
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 -->
6870
{{range .DailyChart.Bars}}
69-
<rect x="{{.X}}" y="{{.CallsY}}" width="8" height="{{.CallsH}}" fill="#3b82f6">
70-
<title>{{.DayLabel}}: {{.Calls}} calls, {{priceFmt .CostUSD}}</title>
71-
</rect>
72-
<rect x="{{add .X 9}}" y="{{.CostY}}" width="8" height="{{.CostH}}" fill="#10b981">
73-
<title>{{.DayLabel}}: {{priceFmt .CostUSD}}, {{.Calls}} calls</title>
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>
7473
</rect>
7574
{{end}}
75+
<!-- cost line overlay -->
76+
{{if .DailyChart.CostPath}}
77+
<path d="{{.DailyChart.CostPath}}" fill="none" stroke="#10b981" stroke-width="1.5" />
78+
{{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>
83+
{{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>
87+
{{end}}
7688
</svg>
7789
<div class="usage-chart-legend">
78-
<span><span class="usage-chart-legend__swatch" style="background:#3b82f6;"></span> calls</span>
90+
<span><span class="usage-chart-legend__swatch" style="background:#3b82f6;opacity:0.75;"></span> calls</span>
7991
<span><span class="usage-chart-legend__swatch" style="background:#10b981;"></span> cost (USD)</span>
8092
</div>
8193
</div>

internal/adminapi/usage.go

Lines changed: 103 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -44,24 +44,39 @@ type expensiveTurnView struct {
4444

4545
// dailyChart is a pre-sized SVG description rendered in the template — keeps
4646
// 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".
4752
type dailyChart struct {
48-
Width int
49-
Height int
50-
Bars []dailyBar
51-
MaxCalls int
52-
MaxCost float64
53-
Days int
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
61+
MaxCost float64
62+
Days int
63+
XAxisTags []xAxisTag // sparse labels at first/middle/last day
5464
}
5565

5666
type dailyBar struct {
57-
X int
58-
CallsY int
59-
CallsH int
60-
CostY int
61-
CostH int
62-
DayLabel string // short label for x-axis, e.g. "04-20"
63-
Calls int
64-
CostUSD float64
67+
X int
68+
W int
69+
CallsY int
70+
CallsH int
71+
CostY int
72+
DayLabel string
73+
Calls int
74+
CostUSD float64
75+
}
76+
77+
type xAxisTag struct {
78+
X int
79+
Label string
6580
}
6681

6782
func (s *Server) handleUsage(w http.ResponseWriter, r *http.Request) {
@@ -176,14 +191,31 @@ func resolvePeriod(period string) (time.Time, string) {
176191
}
177192
}
178193

179-
// buildDailyChart turns a slice of UsageDayBucket into SVG coordinates. The
180-
// chart is a fixed 600×160 box with two bars per day (calls + cost, grouped).
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.
181198
func buildDailyChart(buckets []llm.UsageDayBucket) dailyChart {
182-
c := dailyChart{Width: 720, Height: 160, Days: len(buckets)}
199+
const (
200+
width = 480
201+
height = 120
202+
padLeft = 12
203+
padRight = 12
204+
padTop = 10
205+
padBottom = 22
206+
)
207+
c := dailyChart{
208+
Width: width,
209+
Height: height,
210+
PlotTop: padTop,
211+
PlotBot: height - padBottom,
212+
Days: len(buckets),
213+
}
183214
if len(buckets) == 0 {
184215
return c
185216
}
186-
// Normalize — find max for each axis.
217+
sort.Slice(buckets, func(i, j int) bool { return buckets[i].Day.Before(buckets[j].Day) })
218+
187219
for _, b := range buckets {
188220
if b.Calls > c.MaxCalls {
189221
c.MaxCalls = b.Calls
@@ -198,24 +230,64 @@ func buildDailyChart(buckets []llm.UsageDayBucket) dailyChart {
198230
if c.MaxCost == 0 {
199231
c.MaxCost = 0.0001
200232
}
201-
// Layout: barWidth pair per day, 20px of padding total. For 30-day view we
202-
// want ~10px per day comfortably.
203-
sort.Slice(buckets, func(i, j int) bool { return buckets[i].Day.Before(buckets[j].Day) })
204-
dayWidth := (c.Width - 40) / max(len(buckets), 1)
233+
234+
plotW := width - padLeft - padRight
235+
plotH := c.PlotBot - c.PlotTop
236+
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)
240+
if barW < 2 {
241+
barW = 2
242+
}
243+
if barW > 28 {
244+
barW = 28
245+
}
246+
247+
var pathBuilder strings.Builder
205248
for i, b := range buckets {
206-
x := 20 + i*dayWidth
207-
callsH := int(float64(b.Calls) / float64(c.MaxCalls) * 140)
208-
costH := int(b.CostUSD / c.MaxCost * 140)
209-
c.Bars = append(c.Bars, dailyBar{
210-
X: x,
211-
CallsY: c.Height - 20 - callsH,
249+
slotCenter := padLeft + int((float64(i)+0.5)*slot)
250+
barX := slotCenter - barW/2
251+
252+
callsH := int(float64(b.Calls) / float64(c.MaxCalls) * float64(plotH))
253+
if callsH < 0 {
254+
callsH = 0
255+
}
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,
212262
CallsH: callsH,
213-
CostY: c.Height - 20 - costH,
214-
CostH: costH,
263+
CostY: costY,
215264
DayLabel: b.Day.Format("01-02"),
216265
Calls: b.Calls,
217266
CostUSD: b.CostUSD,
218-
})
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})
270+
271+
if i == 0 {
272+
fmt.Fprintf(&pathBuilder, "M %d %d", slotCenter, costY)
273+
} else {
274+
fmt.Fprintf(&pathBuilder, " L %d %d", slotCenter, costY)
275+
}
276+
}
277+
c.CostPath = pathBuilder.String()
278+
279+
// X-axis labels: first, middle, last — keeps the axis readable on both
280+
// 1-day and 30-day views without collisions.
281+
addTag := func(i int) {
282+
slotCenter := padLeft + int((float64(i)+0.5)*slot)
283+
c.XAxisTags = append(c.XAxisTags, xAxisTag{X: slotCenter, Label: buckets[i].Day.Format("01-02")})
284+
}
285+
addTag(0)
286+
if len(buckets) >= 3 {
287+
addTag(len(buckets) / 2)
288+
}
289+
if len(buckets) > 1 {
290+
addTag(len(buckets) - 1)
219291
}
220292
return c
221293
}

0 commit comments

Comments
 (0)