Files
yetone d1d4b7ed2a usage: a heatmap of the last 53 weeks on the Requests tab, by tokens, requests or cost (#1369, nianlee-official)
What changed: the Requests tab of the Usage page shows a heatmap card like
CC Switch's: a cell per day for the last 53 weeks, Monday on top, month
names over the weeks and weekday names at the edge, a Tokens / Requests /
Cost switch (kept in localStorage), a five-shade Less…More legend, and a
tip on each day with its date, tokens, requests and cost.

How: GET /api/usage/heatmap (usage.HeatmapOf) reads QueryPage's cached
request index over a hidden period, "heatmap", which starts on the Monday 52
weeks before this week's. Its rows are summed by local calendar day in both
the compact and the ledger path. So no raw log is scanned per render, and the
days count what the chart counts:
- the page's agent/provider/model/key filters, but not the period or day
  picked;
- the gateway's own rejections left out;
- cost of priced calls only, with an unpriced count.
The days are in the local time zone and the shades use theme tokens
(.l0–.l4, as in the sessions calendar). There are no left-border accents and
no native <select>. A click never scrolls the page. A narrow window scrolls
the card sideways inside itself, with the newest week in sight (kept there
as it's laid out or resized, until the reader scrolls back), and the weekday
names stay at the card's edge. The card is hidden when no day has calls. New
strings are in zh, zh-TW, ja and de.

Run:
- go test -tags nogui ./internal/usage ./internal/gui (new TestHeatmapDays,
  TestHeatmapOf, TestUsageHeatmapRoute); gofmt -l clean; go vet darwin,
  GOOS=linux and GOOS=windows go vet -tags nogui on both packages; go build
  for darwin, linux and windows.
- New internal/gui/tests/usage-heatmap.test.cjs in Chromium and WebKit, at
  420 and 1100, in en, zh, zh-TW, ja and de, plus dark and the empty case.
  It checks:
  - the cells, labels, legend and the busiest and quiet days;
  - the newest week in sight and only the card scrolling sideways;
  - the tip's text and that it stays inside the card;
  - that a metric click moves nothing and is kept;
  - that the filters, but no period or day, are sent.
- Breaking checks: with the keep-at-end guard reverted, 5 of its 11 subtests
  fail in WebKit; with the server's heatmap dropped, TestUsageHeatmapRoute
  fails.
- i18n-keys, click-scroll, gui-de, gui-ja, gui-zh-tw, sessions-usage-open
  and all usage-*.test.cjs GUI tests pass in both engines (161 each).
- A sandboxed `magpie web` (temp HOME, 2605 fake calls over 400 days, ports
  3671/3672): the card fills 369 days at 1280 and 420 wide, and the tip
  reads "2026年10月1日周四 / 2.6M token · 23 个请求 · ≈$1.05+".
2026-10-09 11:33:47 +08:00

100 lines
3.0 KiB
Go

package usage
// The heatmap (#1369): the last 53 weeks a day each, as a calendar of
// contributions lays them out, Monday on top. It is the request page's own
// index read over that span — QueryPage's cached chunks, priced once — and
// not another pass over the logs, so a page asking for it again reads only
// what changed. It counts what the Requests tab's chart counts: the rows its
// filters keep, but for the day picked, and not the calls the gateway turned
// away itself.
import (
"slices"
"time"
)
// HeatmapWeeks is how many weeks the heatmap shows, this one the last.
const HeatmapWeeks = 53
// heatmapPeriod is the span the heatmap is read over: QueryPage's period for
// it, from the Monday HeatmapWeeks-1 weeks before this week's. Not one the
// page's period switch offers.
const heatmapPeriod Period = "heatmap"
// heatmapSince is the Monday the heatmap starts on, as of now.
func heatmapSince(now time.Time) time.Time {
day := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
return day.AddDate(0, 0, -(int(day.Weekday())+6)%7-7*(HeatmapWeeks-1))
}
// Day is one day's calls, by the local calendar day they fell on.
type Day struct {
Date string `json:"date"` // YYYY-MM-DD
Calls int `json:"calls"`
Tokens int `json:"tokens"` // in, out and through the cache, as the chart counts them
Cost float64 `json:"cost"` // of the priced calls, USD
// Unpriced: calls with tokens but no known price, not in Cost
Unpriced int `json:"unpriced,omitempty"`
}
// Heatmap is the days from From to To (today), both YYYY-MM-DD in the local
// time zone: only those with calls are listed, oldest first.
type Heatmap struct {
From string `json:"from"`
To string `json:"to"`
Days []Day `json:"days"`
}
// HeatmapOf is the last HeatmapWeeks weeks of the calls the filter keeps, a
// day each. The filter's Day is not one of its dimensions here: the heatmap
// is every day's.
func HeatmapOf(f Filter) Heatmap {
f.Day = ""
if h := QueryPage(heatmapPeriod, f, 0, 1).Heat; h != nil {
return *h
}
return Heatmap{Days: []Day{}}
}
// heatmapOf sums the rows visit gives it by their local day, from since to
// now's day; a row outside those days is left out.
func heatmapOf(since, now time.Time, visit func(add func(Row))) *Heatmap {
h := &Heatmap{From: since.Format(time.DateOnly), To: now.In(time.Local).Format(time.DateOnly), Days: []Day{}}
at := map[string]*Day{}
visit(func(r Row) {
if r.IsRejected() || r.Time.Before(since) {
return
}
d := r.Time.In(time.Local).Format(time.DateOnly)
if d > h.To {
return
}
x := at[d]
if x == nil {
x = &Day{Date: d}
at[d] = x
}
x.Calls++
x.Tokens += r.Input + r.Output + r.CacheRead + r.CacheWrite
switch {
case r.Priced:
x.Cost += r.Cost
case r.Input+r.Output > 0:
x.Unpriced++
}
})
for _, x := range at {
h.Days = append(h.Days, *x)
}
slices.SortFunc(h.Days, func(a, b Day) int {
if a.Date < b.Date {
return -1
}
if a.Date > b.Date {
return 1
}
return 0
})
return h
}