usage: quota cards draw what was left over time, against an even burn, and the tray card its cycle's line (#651, songlairui) — each reading magpie makes anyway of a subscription's windows (and of a plan bought with a key) is kept as a point in quota-history.json beside the config: provider, account, window, when read, percent left, the window's start and reset — no token, no prompt. A failed reading, one kept from before (As of), an unlimited window and a model family's aren't kept; Antigravity's pools are kept by pool. Within a cycle points are 5 minutes apart at least and a run of one figure is its two ends; a cycle ends where the window starts again (its reset passed, or moved further than the time between two readings, so a rolling window's isn't one). 45 days and 6000 points a window are kept. The Usage page's card has, under its meters, a line a window (several tiers of one account on one chart), broken at each reset, a dashed even burn from each window's start to its reset and an upright now line, with 2 days / Cycle (the account's longest current window, start to reset) in the app's segmented control, remembered, turning every card without moving the page. The tray's card has a thin line of its first window's current cycle. GET /v1/magpie/quotas/history (?days=, ?provider=, ?user=; local or LAN with the key, as /v1/magpie/quotas) and /api/usage/quotas/history serve it; /v1/magpie/quotas and magpie quota stay snapshots. Computers sharing usage (WebDAV/S3, Settings › Sync › usage) put their history beside their days, sealed, as <id>.magpie-quotas, and merge the others': the union by account, window and time, two points within 30 s the newer, nothing twice however often they sync; an older magpie never reads the file. TestQuotaHistoryRecords, TestQuotaHistoryCycles, TestMergeQuotaHistory, TestQuotasHistory and TestQuotaHistorySharedBetweenComputers cover recording, cycles, pruning, merge, the API and two computers on one WebDAV folder; quota-curve.test.cjs (WebKit, en/zh, light and dark) the curves, the toggle, no curve without readings, and the tray's line (Chromium doesn't start in this sandbox). go vet, builds (darwin, GOOS=linux, GOOS=windows) and go test -tags nogui -count=1 ./... under a temp HOME pass. Not tried with real accounts; pay-as-you-go balances aren't drawn.

This commit is contained in:
yetone committed 2026-10-03 15:19:19 +08:00
1 parent c91529f9aa
commit 9ccabdfbd8
14 files changed
+1140 -22

No files matched your search

+91
View File
@@ -0,0 +1,91 @@
package davsync
import (
"context"
"fmt"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/yetone/magpie/internal/provider"
)
// Two computers sharing usage share what they read of their accounts'
// windows too (#651): each puts its own sealed beside its days, and each
// ends with the points of both, merged by account and window, nothing
// twice however often it syncs.
func TestQuotaHistorySharedBetweenComputers(t *testing.T) {
old := usageEvery
usageEvery = 0
t.Cleanup(func() { usageEvery = old })
fake := &usageDAV{fakeDAV: &fakeDAV{files: map[string][]byte{}, etags: map[string]string{}, dirs: map[string]bool{"/dav": true}}}
srv := httptest.NewServer(fake)
defer srv.Close()
cfg := Config{URL: srv.URL + "/dav/", User: "me", Password: "pw", Passphrase: "correct horse", Keys: true, Usage: true}
sync := func(t *testing.T) {
t.Helper()
if err := Now(context.Background()); err != nil {
t.Fatal(err)
}
if v := Status(); v.UsageError != "" {
t.Fatalf("usage not shared: %+v", v)
}
}
now := time.Now().UTC().Truncate(time.Second)
seed := func(t *testing.T, mins ...int) {
t.Helper()
var pts []string
for _, m := range mins {
pts = append(pts, fmt.Sprintf(`{"at":%q,"left":%d}`, now.Add(-time.Duration(m)*time.Minute).Format(time.RFC3339), 100-m/10))
}
if err := provider.MergeQuotaHistory([]byte(`{"codex|a@x.com":{"5 hours":[`+strings.Join(pts, ",")+`]}}`), now); err != nil {
t.Fatal(err)
}
}
points := func(t *testing.T) int {
t.Helper()
hs := provider.QuotaHistories(time.Time{}, "codex", "a@x.com")
if len(hs) != 1 {
t.Fatalf("histories: %+v", hs)
}
return len(hs[0].Lines[0].Points)
}
a, b := newComputer(t), newComputer(t)
a.use(t)
if err := Configure(cfg); err != nil {
t.Fatal(err)
}
seed(t, 100, 60)
sync(t)
var sealed []byte
for _, f := range fake.usageFiles() {
if strings.HasSuffix(f, quotasExt) {
sealed = fake.files["/dav/magpie/usage/"+f]
}
}
if sealed == nil || strings.Contains(string(sealed), "a@x.com") {
t.Fatalf("a's history on the server, sealed: %v", fake.usageFiles())
}
b.use(t)
if err := Configure(cfg); err != nil {
t.Fatal(err)
}
seed(t, 80, 20)
sync(t)
if n := points(t); n != 4 {
t.Fatalf("b has %d points, want its 2 and a's 2", n)
}
sync(t)
sync(t)
if n := points(t); n != 4 {
t.Fatalf("b, synced again: %d points", n)
}
a.use(t)
sync(t)
if n := points(t); n != 4 {
t.Fatalf("a has %d points, want its 2 and b's 2", n)
}
}
+85 -18
View File
@@ -3,7 +3,10 @@ package davsync
// Shared usage (#542): with Config.Usage on, each computer puts its calls on
// the server beside the backup, at magpie/usage/<id>-<day>.magpie-usage, one
// file a day, sealed with the passphrase, and brings the other computers'
// down for the Usage page. A computer writes and deletes its own files
// down for the Usage page. Beside its days, a computer puts what it read
// of its accounts' windows over time (#651), at magpie/usage/<id>.magpie-quotas,
// sealed alike, and merges the others' into its own (provider.MergeQuotaHistory)
// for the quota cards' curves. A computer writes and deletes its own files
// alone, so none is ever written over by another; nothing of them is in the
// backup, nor in what is hashed to tell whether it changed. A magpie
// before them never looks in the folder.
@@ -24,6 +27,7 @@ import (
"time"
"github.com/yetone/magpie/internal/backup"
"github.com/yetone/magpie/internal/provider"
"github.com/yetone/magpie/internal/usage"
)
@@ -31,6 +35,7 @@ import (
const (
usageFolder = "usage"
usageExt = ".magpie-usage"
quotasExt = ".magpie-quotas"
)
// usageEvery is how often a sync shares usage; a sync asked for (SyncNow)
@@ -48,6 +53,10 @@ type usageState struct {
// yesterday and today are made again
Whole bool `json:"whole,omitempty"`
Error string `json:"error,omitempty"`
// QuotasSent is this computer's quota history as last put, hashed;
// Quotas the others' brought in, each at the version listed
QuotasSent string `json:"quotasSent,omitempty"`
Quotas map[string]string `json:"quotas,omitempty"`
}
// files are a remote's usage files: listed with a version each, read,
@@ -186,6 +195,59 @@ func shareWith(ctx context.Context, c Config, u *usageState, fs files) error {
delete(u.Got, f)
}
}
return shareQuotas(ctx, c, u, fs, listed, id)
}
// shareQuotas merges the other computers' quota histories into this one's,
// then puts this one's up when it changed. The merge keeps each point once
// (by account, window and when it was read), so what came from another and
// goes up again with this one's is nothing twice.
func shareQuotas(ctx context.Context, c Config, u *usageState, fs files, listed map[string]string, id string) error {
if u.Quotas == nil {
u.Quotas = map[string]string{}
}
for f, ver := range listed {
who, ok := strings.CutSuffix(f, quotasExt)
if !ok || who == id || !usage.ValidComputer(who) {
continue
}
if ver != "" && u.Quotas[f] == ver {
continue
}
plain, err := readSealed(ctx, fs, f, c.Passphrase)
if err != nil {
return err
}
if err := provider.MergeQuotaHistory(plain, time.Now()); err != nil {
return fmt.Errorf("reading %s: %w", f, err)
}
u.Quotas[f] = ver
}
for f := range u.Quotas {
if _, ok := listed[f]; !ok {
delete(u.Quotas, f)
}
}
plain := provider.QuotaHistoryData()
if len(plain) == 0 {
return nil
}
h := sum(plain)
if u.QuotasSent == h {
return nil
}
var z bytes.Buffer
w := gzip.NewWriter(&z)
w.Write(plain)
w.Close()
sealed, err := backup.SealData(z.Bytes(), c.Passphrase)
if err != nil {
return err
}
if err := fs.write(ctx, id+quotasExt, sealed); err != nil {
return err
}
u.QuotasSent = h
return nil
}
@@ -216,28 +278,33 @@ func usageName(f string) (who, day string, ok bool) {
func readDay(ctx context.Context, fs files, f, pass string) (usage.SharedDay, error) {
var d usage.SharedDay
sealed, err := fs.read(ctx, f)
if err != nil {
return d, err
}
z, err := backup.OpenData(sealed, pass)
if errors.Is(err, backup.ErrPassphrase) {
return d, fmt.Errorf("the passphrase doesn't open %s: another computer shares its usage sealed with another one", f)
}
if err != nil {
return d, err
}
zr, err := gzip.NewReader(bytes.NewReader(z))
if err != nil {
return d, err
}
plain, err := io.ReadAll(io.LimitReader(zr, 256<<20))
plain, err := readSealed(ctx, fs, f, pass)
if err != nil {
return d, err
}
return d, json.Unmarshal(plain, &d)
}
// readSealed is a usage folder's file read, opened and unzipped.
func readSealed(ctx context.Context, fs files, f, pass string) ([]byte, error) {
sealed, err := fs.read(ctx, f)
if err != nil {
return nil, err
}
z, err := backup.OpenData(sealed, pass)
if errors.Is(err, backup.ErrPassphrase) {
return nil, fmt.Errorf("the passphrase doesn't open %s: another computer shares its usage sealed with another one", f)
}
if err != nil {
return nil, err
}
zr, err := gzip.NewReader(bytes.NewReader(z))
if err != nil {
return nil, err
}
return io.ReadAll(io.LimitReader(zr, 256<<20))
}
// WebDAV
func (d *dav) usageURL(name string) string { return d.url(folder, usageFolder, name) }
@@ -273,7 +340,7 @@ func (d *dav) list(ctx context.Context) (map[string]string, error) {
h = u.Path
}
name := pathpkg.Base(strings.TrimRight(h, "/"))
if r.Collected != nil || !strings.HasSuffix(name, usageExt) {
if r.Collected != nil || !strings.HasSuffix(name, usageExt) && !strings.HasSuffix(name, quotasExt) {
continue
}
v := r.ETag
+15 -1
View File
@@ -448,6 +448,7 @@ func (s *Server) Handler() http.Handler {
writeJSON(w, 200, map[string]any{"name": "magpie", "version": Version})
})
mux.HandleFunc("GET /v1/magpie/quotas", s.quotas)
mux.HandleFunc("GET /v1/magpie/quotas/history", s.quotasHistory)
mux.HandleFunc("GET /v1/magpie/route", s.sessionRoute)
mux.HandleFunc("GET /v1/magpie/concurrency", s.concurrency)
mux.HandleFunc("GET /v1/magpie/limit", s.keyLimit)
@@ -482,7 +483,7 @@ func (s *Server) Handler() http.Handler {
func (s *Server) info(w http.ResponseWriter, r *http.Request) {
writeJSON(w, 200, map[string]any{"name": "magpie", "version": Version, "models": len(provider.Catalog()), "window": Window,
"apis": []string{"/v1/chat/completions", "/v1/responses", "/v1/messages", "/v1/systemone", "/v1beta/models/{model}:generateContent", "/v1/images/generations", "/v1/images/edits", "/v1/videos", "/v1/magpie/quotas", "/v1/magpie/route"}})
"apis": []string{"/v1/chat/completions", "/v1/responses", "/v1/messages", "/v1/systemone", "/v1beta/models/{model}:generateContent", "/v1/images/generations", "/v1/images/edits", "/v1/videos", "/v1/magpie/quotas", "/v1/magpie/quotas/history", "/v1/magpie/route"}})
}
// quotas is what is left of every subscription, plan and key magpie has,
@@ -500,6 +501,19 @@ func (s *Server) quotas(w http.ResponseWriter, r *http.Request) {
writeJSON(w, 200, map[string]any{"object": "list", "data": provider.QuotaReport(ctx, time.Now())})
}
// quotasHistory is what was left of each account's windows over time, as
// this magpie (and the computers sharing usage with it) read them (#651):
// ?days= back (45 at most), ?provider= and ?user= to pick an account.
// /v1/magpie/quotas stays the snapshot.
func (s *Server) quotasHistory(w http.ResponseWriter, r *http.Request) {
if !local(r) && !sharedWith(r) {
writeError(w, provider.Chat, http.StatusForbidden, "magpie's quotas are told to another machine only when magpie is shared on the local network (Settings → Share on local network) and the request carries its API key (Authorization: Bearer <key> or x-api-key: <key>)")
return
}
q := r.URL.Query()
writeJSON(w, 200, map[string]any{"object": "list", "data": provider.QuotaHistories(provider.QuotaHistorySince(q.Get("days"), time.Now()), q.Get("provider"), q.Get("user"))})
}
func modelObject(e provider.Entry) map[string]any {
type reasoningLevel struct {
Effort string `json:"effort"`
+48
View File
@@ -0,0 +1,48 @@
package gateway
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/yetone/magpie/internal/provider"
)
// GET /v1/magpie/quotas/history is each account's windows over time
// (#651): the points of the last ?days=, ?provider= and ?user= picking
// one account's, and another machine without the shared key gets nothing.
func TestQuotasHistory(t *testing.T) {
fresh(t)
t.Setenv("MAGPIE_ADDR", "")
now := time.Now().UTC().Truncate(time.Second)
ago := func(h int) string { return now.Add(-time.Duration(h) * time.Hour).Format(time.RFC3339) }
seed := fmt.Sprintf(`{"codex|a@x.com":{"5 hours":[{"at":%q,"left":90},{"at":%q,"left":70}]},
"claude|":{"Weekly":[{"at":%q,"left":40}]}}`, ago(100), ago(1), ago(2))
if err := provider.MergeQuotaHistory([]byte(seed), now); err != nil {
t.Fatal(err)
}
h := lanGuard(New().Handler())
get := func(from, q string) (int, []provider.QuotaHistory) {
r := httptest.NewRequest("GET", "/v1/magpie/quotas/history"+q, nil)
r.RemoteAddr = from
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
var out struct{ Data []provider.QuotaHistory }
json.Unmarshal(w.Body.Bytes(), &out)
return w.Code, out.Data
}
c, all := get("127.0.0.1:5000", "")
if c != 200 || len(all) != 2 {
t.Fatalf("all: %d %+v", c, all)
}
_, codex := get("127.0.0.1:5000", "?provider=codex&user=A@x.com&days=2")
if len(codex) != 1 || codex[0].User != "a@x.com" || len(codex[0].Lines[0].Points) != 2 {
t.Fatalf("codex, 2 days (the point the range begins in, and the last): %+v", codex)
}
if c, _ := get("192.168.1.9:5000", ""); c == http.StatusOK {
t.Fatal("another machine without a key got the history")
}
}
+18
View File
@@ -555,6 +555,20 @@ body.window .field.solo { grid-column: auto; }
.credit-days .cd-day.unknown { background: none; outline: 1px dashed var(--line); outline-offset: -1px; }
.credit-days .cd-day i { display: block; width: 100%; border-radius: 2px; background: var(--accent); }
.credit-days .cd-note { margin-top: 4px; font-size: 10px; color: var(--faint); }
/* what was left over time (#651): a line a window, an even burn dashed, now upright */
.quota-curve { margin-top: 10px; }
.quota-curve .qc-head { display: flex; justify-content: space-between; align-items: center; gap: 6px; font-size: 10.5px; color: var(--muted); }
.quota-curve .qc-range .opt { height: 20px; padding: 0 9px; font-size: 11px; }
.quota-curve .qc-plot { display: block; width: 100%; height: 64px; margin-top: 6px; overflow: hidden; }
.qc-grid { stroke: var(--line); stroke-width: 1; vector-effect: non-scaling-stroke; }
.qc-line { fill: none; stroke-width: 1.6; stroke-linejoin: round; stroke-linecap: round; vector-effect: non-scaling-stroke; }
.qc-even { stroke-width: 1; stroke-dasharray: 3 3; opacity: .55; vector-effect: non-scaling-stroke; }
.qc-now { stroke: var(--faint); stroke-width: 1; stroke-dasharray: 1 2; vector-effect: non-scaling-stroke; }
.quota-curve .qc-axis { display: flex; justify-content: space-between; margin-top: 3px; font-size: 10px; color: var(--faint); font-variant-numeric: tabular-nums; }
.quota-curve .qc-legend { display: flex; flex-wrap: wrap; gap: 3px 12px; margin-top: 4px; font-size: 10.5px; color: var(--muted); }
.qc-key { display: inline-flex; align-items: center; gap: 5px; }
.qc-key i { width: 7px; height: 7px; border-radius: 50%; }
.qc-key b { color: var(--fg-2); font-weight: 600; font-variant-numeric: tabular-nums; }
.quota-resets { display: flex; align-items: center; gap: 8px; margin-top: 9px; font-size: 10.5px; font-variant-numeric: tabular-nums; }
.quota-resets .text { margin-left: auto; flex: none; font-size: 11px; padding: 2px 7px; }
.subscription-card.several .quota-resets + .subscription-account { margin-top: 10px; padding-top: 9px; border-top: 1px solid var(--line); }
@@ -1146,6 +1160,10 @@ body.panel .profiles #save { height: 25px; } /* the field's: the line keeps its
.pq-rn { max-width: 44px; font-size: 9.5px; line-height: 1.2; color: var(--faint); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.pq-rn2 { display: flex; flex-direction: column; align-items: center; }
.pq-rn2 > span { max-width: 100%; overflow: hidden; text-overflow: ellipsis; }
/* the first window's current cycle, a thin line across the card (#651) */
.pq-spark-box { grid-column: 1 / -1; display: block; height: 16px; }
.pq-spark { display: block; width: 100%; height: 16px; overflow: hidden; }
.pq-spark .qc-line { stroke-width: 1.2; }
/* a Codex account's rate-limit resets, under a hairline across the card */
.pq-resets { grid-column: 1 / -1; display: flex; align-items: center; gap: 8px; margin-top: 5px; padding-top: 7px; border-top: 1px solid var(--line-2); font-size: 11px; font-variant-numeric: tabular-nums; }
.resets-words { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+186 -2
View File
@@ -8711,6 +8711,7 @@ const PERIODS = [["today", "Today"], ["7d", "7 days"], ["30d", "30 days"], ["all
// never waits for them. They don't depend on the period either.
let quotas = null;
let quotasAt = 0; // when they came in
let quotaHist = []; // each account's windows over time, for the curves (#651)
// asked: the reader opened the page, so a Claude account's usage is read
// at once, by running Claude Code's own /usage, rather than when its last
// reading is due (every 5 to 15 minutes, at random, once Claude Code was used).
@@ -8732,8 +8733,8 @@ let quotasLoading = null;
function loadQuotas(asked) {
if (quotasLoading && (!asked || quotasLoading.asked)) return quotasLoading;
const p = Promise.resolve(quotasLoading).catch(() => {})
.then(() => api("usage/quotas" + (asked ? "?asked=1" : "")))
.then((q) => { quotas = q || []; quotasAt = Date.now(); }, () => { quotas = quotas || []; })
.then(() => Promise.all([api("usage/quotas" + (asked ? "?asked=1" : "")), api("usage/quotas/history?days=35").catch(() => null)]))
.then(([q, h]) => { quotas = q || []; quotasAt = Date.now(); if (h) quotaHist = h; }, () => { quotas = quotas || []; })
.finally(() => { if (quotasLoading === p) quotasLoading = null; renderQuotas(); });
p.asked = !!asked;
quotasLoading = p;
@@ -8872,6 +8873,9 @@ function renderQuotas() {
card.append(who);
} else if (every) head.append(every);
card.append(meters);
// what was left over time, against an even burn (#651)
const curve = !sub.error && quotaCurve(sub);
if (curve) card.append(curve);
// WorkBuddy's credits, day by day, as magpie counted them (#568)
if (sub.daily && !sub.error) card.append(creditDays(sub));
// what is left besides the windows, under them
@@ -8945,6 +8949,183 @@ function creditDays(sub) {
return box;
}
// What was left of an account's windows over time (#651), from magpie's
// readings of them (and those of the computers sharing usage with it): a
// line a window, the percent left, broken where the window started again;
// a dashed line from the window's start to its reset is an even burn, and
// a thin upright one is now. "2 days" is the last two days; "Cycle" the
// current cycle of the account's longest window, its start to its reset.
let quotaRange = "cycle";
try { if (localStorage.getItem("magpie.quotaRange") === "2d") quotaRange = "2d"; } catch {}
const QUOTA_RANGES = [["2d", "2 days"], ["cycle", "Cycle"]];
function quotaHistOf(sub) {
const user = (sub.user || "").toLowerCase();
return quotaHist?.find?.((h) => h.provider === sub.provider && h.user === user);
}
// the lines of sub's windows, as its card lists them, with points
function quotaLines(sub) {
const h = quotaHistOf(sub);
if (!h) return [];
const out = [];
for (const w of sub.windows || []) {
if (w.unlimited || w.family) continue;
// a pool's window is kept under the pool's name and its own
const name = w.pool ? w.pool + " · " + w.name : w.name;
const l = h.lines.find((x) => x.name === name);
if (!l?.points?.length || out.some((x) => x.name === l.name)) continue;
out.push({ name, label: w.pool ? w.pool + " · " + t(w.name) : t(w.name), window: w, points: l.points.map((p) => ({
at: Date.parse(p.at), left: p.left,
start: p.start ? Date.parse(p.start) : null, reset: p.resetsAt ? Date.parse(p.resetsAt) : null,
})) });
}
return out;
}
// quotaCycleBreak: whether b, read after a, is of its window started again,
// as magpie's own record of them says (quota_history.go's newCycle)
function quotaCycleBreak(a, b) {
if (a.reset == null && b.reset == null) return b.left > a.left + 5;
if (a.reset == null || b.reset == null) return true;
if (a.reset <= b.at) return true;
const moved = b.reset - a.reset, slack = 5 * 60e3;
return moved > b.at - a.at + slack || moved < -slack;
}
// the current cycle of a line: its start and its reset, the window's as
// the card has it, else its last reading's
function quotaCycle(l, now) {
const last = l.points[l.points.length - 1];
let reset = l.window.resetsAt ? Date.parse(l.window.resetsAt) : last.reset;
let start = last.start != null && last.reset != null && reset != null ? last.start + (reset - last.reset) : last.start;
if (start == null) { // its first reading of this cycle
let i = l.points.length - 1;
while (i > 0 && !quotaCycleBreak(l.points[i - 1], l.points[i])) i--;
start = l.points[i].at;
}
if (reset != null && reset <= now) reset = null;
return { start: Math.min(start, now), reset };
}
function quotaSpan(lines, range, now) {
if (range === "2d") return [now - 48 * 3600e3, now];
let x0 = Infinity, x1 = now;
for (const l of lines) {
const c = quotaCycle(l, now);
x0 = Math.min(x0, c.start);
if (c.reset) x1 = Math.max(x1, c.reset);
}
if (!(x0 < now)) x0 = now - 3600e3;
return [x0, x1];
}
// quotaPaths: a line's points in [x0, x1] as path data on a w×h box, a
// new subpath at each cycle's start; the point before x0 and the one after
// x1 go along, so the line reaches the box's edges
function quotaPaths(points, x0, x1, w, h) {
const X = (t) => ((t - x0) / (x1 - x0)) * w, Y = (v) => h - (Math.max(0, Math.min(100, v)) / 100) * h;
let d = "", prev = null;
for (let i = 0; i < points.length; i++) {
const p = points[i];
const next = points[i + 1];
if (p.at < x0 && (!next || next.at < x0 || quotaCycleBreak(p, next))) continue;
if (prev && p.at > x1 && prev.at > x1) break;
const brk = !prev || quotaCycleBreak(prev, p);
d += (brk ? "M" : "L") + X(p.at).toFixed(1) + " " + Y(p.left).toFixed(1);
if (brk && (!points[i + 1] || quotaCycleBreak(p, points[i + 1]))) d += "h0.01"; // a lone point is a dot
prev = p;
}
return d;
}
function quotaTimeText(at, now) {
const d = new Date(at), lang = locale === "zh" ? "zh-CN" : undefined;
if (Math.abs(at - now) < 60e3) return t("Now");
const sameDay = d.toDateString() === new Date(now).toDateString();
const time = d.toLocaleTimeString(lang, { hour: "2-digit", minute: "2-digit", hour12: false });
return sameDay ? time : d.toLocaleDateString(lang, { month: "short", day: "numeric" }) + " " + time;
}
// drawQuotaPlot puts lines' curves for [x0, x1] in svg, a w×h box:
// the grid, the even burns, the lines, and now.
function drawQuotaPlot(g, lines, x0, x1, w, h, now, thin) {
g.replaceChildren();
if (!thin) for (const v of [0, 50, 100]) g.append(sv("line", { x1: 0, x2: w, y1: h - (v / 100) * h, y2: h - (v / 100) * h, class: "qc-grid" }));
const X = (t) => ((t - x0) / (x1 - x0)) * w;
lines.forEach((l, i) => {
const color = `var(--c${(i % 7) + 1})`;
const c = quotaCycle(l, now);
if (c.reset && c.start < c.reset) {
const e = sv("line", { x1: X(c.start), y1: 0, x2: X(c.reset), y2: h, class: "qc-even" }, { stroke: color });
g.append(e);
}
const d = quotaPaths(l.points, x0, x1, w, h);
if (d) g.append(sv("path", { d, class: "qc-line", "data-name": l.name }, { stroke: color }));
});
if (now >= x0 && now <= x1) g.append(sv("line", { x1: X(now), x2: X(now), y1: 0, y2: h, class: "qc-now" }));
}
// quotaCurve: an account's windows over time, under its meters, with the
// range turned for every card at once
function quotaCurve(sub) {
const lines = quotaLines(sub);
if (!lines.length) return null;
const box = el("div", "quota-curve");
const head = el("div", "qc-head");
head.append(el("span", "", t("Left over time")));
const seg = el("div", "segs qc-range");
for (const [id, name] of QUOTA_RANGES) {
const b = el("button", "opt" + (id === quotaRange ? " on" : ""), t(name));
b.dataset.range = id;
b.title = t(id === "2d" ? "The last two days" : "The current cycle, from the window's start to its reset");
b.onclick = () => setQuotaRange(id);
seg.append(b);
}
head.append(seg);
const W = 300, H = 60;
const g = sv("svg", { viewBox: `0 0 ${W} ${H}`, preserveAspectRatio: "none", class: "qc-plot", role: "img" });
const axis = el("div", "qc-axis");
const legend = el("div", "qc-legend");
lines.forEach((l, i) => {
const k = el("span", "qc-key");
const sw = el("i");
sw.style.background = `var(--c${(i % 7) + 1})`;
const last = l.points[l.points.length - 1];
k.append(sw, el("span", "", l.label), el("b", "", Math.round(last.left) + "%"));
k.title = t("{name}: {n} left, read {when}", { name: l.label, n: Math.round(last.left) + "%", when: quotaTimeText(last.at, Date.now()) });
legend.append(k);
});
box.draw = () => {
const now = Date.now();
const [x0, x1] = quotaSpan(lines, quotaRange, now);
drawQuotaPlot(g, lines, x0, x1, W, H, now);
g.setAttribute("aria-label", t("Left over time") + " · " + t(quotaRange === "2d" ? "2 days" : "Cycle"));
axis.replaceChildren(el("span", "", quotaTimeText(x0, now)), el("span", "", quotaTimeText(x1, now)));
for (const b of seg.querySelectorAll(".opt")) b.classList.toggle("on", b.dataset.range === quotaRange);
// its thumb is put once the card is on the page
if (seg.isConnected) slide(seg, "quotaRange"); else queueMicrotask(() => slide(seg, "quotaRange"));
};
box.append(head, g, axis, legend);
box.draw();
return box;
}
function setQuotaRange(id) {
if (id === quotaRange) return;
quotaRange = id;
try { localStorage.setItem("magpie.quotaRange", id); } catch {}
for (const b of document.querySelectorAll(".quota-curve")) b.draw?.();
}
// quotaSpark: the tray card's thin line of a window's current cycle, the
// even burn faint under it
function quotaSpark(q, w) {
const lines = quotaLines({ ...q, windows: [w.window ? { ...w, name: w.window } : w] });
if (!lines.length) return null;
const now = Date.now(), c = quotaCycle(lines[0], now);
const x1 = c.reset || now;
if (!(c.start < x1)) return null;
const W = 200, H = 16;
const g = sv("svg", { viewBox: `0 0 ${W} ${H}`, preserveAspectRatio: "none", class: "pq-spark", role: "img" });
g.setAttribute("aria-label", t("{name}: this cycle", { name: t(w.name) }));
drawQuotaPlot(g, lines, c.start, x1, W, H, now, true);
const s = el("span", "pq-spark-box");
s.title = t("{name}: what was left this cycle, against an even burn", { name: t(w.name) });
s.append(g);
return s;
}
// planTerm says when a plan's paid time ends: renewed then, over, or
// either (the vendor doesn't say which), as short as a panel row needs.
function planTerm(q) {
@@ -9398,6 +9579,9 @@ function panelQuotaCard(q) {
rings.append(r);
}
card.append(rings);
// the first window's cycle, a thin line under the card (#651)
const spark = ws[0] && !ws[0].tiers ? quotaSpark(q, ws[0]) : null;
if (spark) card.append(spark);
if (q.asOf) card.append(el("span", "pq-sub pq-asof", asOfText(q, true)));
if (q.resets?.count) {
const r = el("div", "pq-resets");
+9
View File
@@ -1669,6 +1669,15 @@ const I18N = {
"live, as the gateway decides": "实时 · 网关正在做的决定",
"Hide accounts": "账号打码",
"Credits used per day": "每天用掉的积分",
"Left over time": "剩余额度走势",
"2 days": "2 天",
"Cycle": "本周期",
"The last two days": "最近两天",
"The current cycle, from the window's start to its reset": "当前周期:从窗口开始到重置",
"{name}: {n} left, read {when}": "{name}:剩余 {n},读取于 {when}",
"Now": "现在",
"{name}: this cycle": "{name}:本周期",
"{name}: what was left this cycle, against an even burn": "{name}:本周期的剩余额度,虚线为匀速消耗",
"Counted from magpie's readings of the vendor's meter, since {date}: what is used while magpie isn't reading it is counted on the day it next does": "自 {date} 起,由 magpie 读取的额度累计算出;magpie 没在读取时用掉的积分,记在下一次读到的那天",
"{n} today": "今天 {n}",
"{n} in {days} days": "{days} 天共 {n}",
+7 -1
View File
@@ -1374,10 +1374,16 @@ yet saved and an id typed in that the vendor doesn't list (added by hand)
outlive both, and Save sends them. No click moves the page. English and
Chinese, Chromium and WebKit, with `/api/plugins` faked.
`quota-curve.test.cjs` checks the quota cards' curves (#651): a line a
window under the meters, broken at a reset, a dashed even burn and a now
line, the 2 days / Cycle control turning every card without moving the page,
no curve without readings, the theme's chart colours in light and dark, and
the tray card's thin current-cycle line, in Chinese and English.
With Node.js and Playwright available:
```sh
node --test internal/gui/tests/menu-scroll.test.cjs internal/gui/tests/click-scroll.test.cjs internal/gui/tests/panel-fold.test.cjs internal/gui/tests/rollup-short.test.cjs internal/gui/tests/panel-routing.test.cjs internal/gui/tests/gateway-fold.test.cjs internal/gui/tests/routing-kind.test.cjs internal/gui/tests/routing-log-icons.test.cjs internal/gui/tests/balance-fix.test.cjs internal/gui/tests/cli-update.test.cjs internal/gui/tests/login-import.test.cjs internal/gui/tests/agy-launch.test.cjs internal/gui/tests/currency.test.cjs internal/gui/tests/usage-ledger.test.cjs internal/gui/tests/update-check.test.cjs internal/gui/tests/omarchy.test.cjs internal/gui/tests/brand.test.cjs internal/gui/tests/library-loading.test.cjs internal/gui/tests/provider-typed.test.cjs internal/gui/tests/phone-web.test.cjs node --test internal/gui/tests/signin-callback.test.cjs internal/gui/tests/routing-steady.test.cjs internal/gui/tests/old-webkit.test.cjs internal/gui/tests/session-terminal.test.cjs internal/gui/tests/text-size.test.cjs internal/gui/tests/zcode-site.test.cjs internal/gui/tests/rail-tip.test.cjs internal/gui/tests/sync-update.test.cjs internal/gui/tests/plugin-signin.test.cjs internal/gui/tests/plugin-market.test.cjs internal/gui/tests/s3-sync.test.cjs internal/gui/tests/routing-wb-refused.test.cjs internal/gui/tests/routing-blocked.test.cjs internal/gui/tests/routing-zcode-blocked.test.cjs internal/gui/tests/routing-shape.test.cjs internal/gui/tests/market-have.test.cjs internal/gui/tests/sessions-today.test.cjs internal/gui/tests/routing-side-calls.test.cjs internal/gui/tests/import-all-skills.test.cjs internal/gui/tests/routing-flood.test.cjs internal/gui/tests/routing-busy.test.cjs internal/gui/tests/own-skill.test.cjs internal/gui/tests/routing-manual.test.cjs internal/gui/tests/tray-usages.test.cjs internal/gui/tests/provider-remove.test.cjs internal/gui/tests/signin-link.test.cjs internal/gui/tests/tray-inuse.test.cjs internal/gui/tests/codex-auto-reset.test.cjs internal/gui/tests/routing-auto-reset.test.cjs internal/gui/tests/claude-usage-asked.test.cjs internal/gui/tests/claude-risk.test.cjs internal/gui/tests/library-toggle-status.test.cjs internal/gui/tests/library-all.test.cjs internal/gui/tests/routing-reroute-title.test.cjs internal/gui/tests/plain-names.test.cjs internal/gui/tests/panel-effort.test.cjs internal/gui/tests/agent-models-all-hidden.test.cjs internal/gui/tests/omp-roles.test.cjs internal/gui/tests/claude-effort.test.cjs internal/gui/tests/remote-magpie.test.cjs internal/gui/tests/qoder-cn-tile.test.cjs internal/gui/tests/provider-levels-scroll.test.cjs internal/gui/tests/signin-paste-codex.test.cjs internal/gui/tests/signin-paste-key.test.cjs internal/gui/tests/usage-alert.test.cjs internal/gui/tests/quota-families.test.cjs internal/gui/tests/provider-search.test.cjs internal/gui/tests/webdav-settings-refresh.test.cjs internal/gui/tests/lan-docker.test.cjs internal/gui/tests/plugin-key-hint.test.cjs internal/gui/tests/update-hide.test.cjs internal/gui/tests/tray-logos.test.cjs internal/gui/tests/routing-proxy.test.cjs internal/gui/tests/sse-body.test.cjs internal/gui/tests/remote-group-served.test.cjs internal/gui/tests/plugin-move-overflow.test.cjs internal/gui/tests/plugin-movable-review.test.cjs internal/gui/tests/deprecation-notice.test.cjs internal/gui/tests/routing-time.test.cjs internal/gui/tests/unlisted-models.test.cjs internal/gui/tests/plugin-updates.test.cjs internal/gui/tests/request-archive.test.cjs internal/gui/tests/group-member-fast.test.cjs internal/gui/tests/providers-off-fold.test.cjs internal/gui/tests/account-return.test.cjs internal/gui/tests/usage-ledger-detail.test.cjs internal/gui/tests/usage-ledger-chart.test.cjs internal/gui/tests/usage-chart-axis.test.cjs internal/gui/tests/panel-usage.test.cjs internal/gui/tests/usage-refresh.test.cjs internal/gui/tests/usage-ledger-content.test.cjs internal/gui/tests/claude-direct.test.cjs internal/gui/tests/signin-again.test.cjs internal/gui/tests/sessions-calendar-fill.test.cjs internal/gui/tests/editor-short-window.test.cjs internal/gui/tests/group-from-model-add.test.cjs internal/gui/tests/sync-other-kind.test.cjs internal/gui/tests/web-page.test.cjs internal/gui/tests/balance-parts.test.cjs internal/gui/tests/search-api.test.cjs internal/gui/tests/searcher-pick.test.cjs internal/gui/tests/provider-file-error.test.cjs internal/gui/tests/sessions-manage.test.cjs internal/gui/tests/number-units.test.cjs internal/gui/tests/sessions-bar-names.test.cjs internal/gui/tests/model-suffix-own.test.cjs internal/gui/tests/routing-effort-change.test.cjs internal/gui/tests/every-skill.test.cjs internal/gui/tests/header-centre.test.cjs internal/gui/tests/public-url.test.cjs internal/gui/tests/strip-scrollbar.test.cjs internal/gui/tests/remove-all-skills.test.cjs internal/gui/tests/request-archive-usage.test.cjs internal/gui/tests/agent-layout.test.cjs internal/gui/tests/group-found-off.test.cjs internal/gui/tests/provider-pickLine truncated
node --test internal/gui/tests/menu-scroll.test.cjs internal/gui/tests/click-scroll.test.cjs internal/gui/tests/panel-fold.test.cjs internal/gui/tests/rollup-short.test.cjs internal/gui/tests/panel-routing.test.cjs internal/gui/tests/gateway-fold.test.cjs internal/gui/tests/routing-kind.test.cjs internal/gui/tests/routing-log-icons.test.cjs internal/gui/tests/balance-fix.test.cjs internal/gui/tests/cli-update.test.cjs internal/gui/tests/login-import.test.cjs internal/gui/tests/agy-launch.test.cjs internal/gui/tests/currency.test.cjs internal/gui/tests/usage-ledger.test.cjs internal/gui/tests/update-check.test.cjs internal/gui/tests/omarchy.test.cjs internal/gui/tests/brand.test.cjs internal/gui/tests/library-loading.test.cjs internal/gui/tests/provider-typed.test.cjs internal/gui/tests/phone-web.test.cjs node --test internal/gui/tests/signin-callback.test.cjs internal/gui/tests/routing-steady.test.cjs internal/gui/tests/old-webkit.test.cjs internal/gui/tests/session-terminal.test.cjs internal/gui/tests/text-size.test.cjs internal/gui/tests/zcode-site.test.cjs internal/gui/tests/rail-tip.test.cjs internal/gui/tests/sync-update.test.cjs internal/gui/tests/plugin-signin.test.cjs internal/gui/tests/plugin-market.test.cjs internal/gui/tests/s3-sync.test.cjs internal/gui/tests/routing-wb-refused.test.cjs internal/gui/tests/routing-blocked.test.cjs internal/gui/tests/routing-zcode-blocked.test.cjs internal/gui/tests/routing-shape.test.cjs internal/gui/tests/market-have.test.cjs internal/gui/tests/sessions-today.test.cjs internal/gui/tests/routing-side-calls.test.cjs internal/gui/tests/import-all-skills.test.cjs internal/gui/tests/routing-flood.test.cjs internal/gui/tests/routing-busy.test.cjs internal/gui/tests/own-skill.test.cjs internal/gui/tests/routing-manual.test.cjs internal/gui/tests/tray-usages.test.cjs internal/gui/tests/provider-remove.test.cjs internal/gui/tests/signin-link.test.cjs internal/gui/tests/tray-inuse.test.cjs internal/gui/tests/codex-auto-reset.test.cjs internal/gui/tests/routing-auto-reset.test.cjs internal/gui/tests/claude-usage-asked.test.cjs internal/gui/tests/claude-risk.test.cjs internal/gui/tests/library-toggle-status.test.cjs internal/gui/tests/library-all.test.cjs internal/gui/tests/routing-reroute-title.test.cjs internal/gui/tests/plain-names.test.cjs internal/gui/tests/panel-effort.test.cjs internal/gui/tests/agent-models-all-hidden.test.cjs internal/gui/tests/omp-roles.test.cjs internal/gui/tests/claude-effort.test.cjs internal/gui/tests/remote-magpie.test.cjs internal/gui/tests/qoder-cn-tile.test.cjs internal/gui/tests/provider-levels-scroll.test.cjs internal/gui/tests/signin-paste-codex.test.cjs internal/gui/tests/signin-paste-key.test.cjs internal/gui/tests/usage-alert.test.cjs internal/gui/tests/quota-families.test.cjs internal/gui/tests/provider-search.test.cjs internal/gui/tests/webdav-settings-refresh.test.cjs internal/gui/tests/lan-docker.test.cjs internal/gui/tests/plugin-key-hint.test.cjs internal/gui/tests/update-hide.test.cjs internal/gui/tests/tray-logos.test.cjs internal/gui/tests/routing-proxy.test.cjs internal/gui/tests/sse-body.test.cjs internal/gui/tests/remote-group-served.test.cjs internal/gui/tests/plugin-move-overflow.test.cjs internal/gui/tests/plugin-movable-review.test.cjs internal/gui/tests/deprecation-notice.test.cjs internal/gui/tests/routing-time.test.cjs internal/gui/tests/unlisted-models.test.cjs internal/gui/tests/plugin-updates.test.cjs internal/gui/tests/request-archive.test.cjs internal/gui/tests/group-member-fast.test.cjs internal/gui/tests/providers-off-fold.test.cjs internal/gui/tests/account-return.test.cjs internal/gui/tests/usage-ledger-detail.test.cjs internal/gui/tests/usage-ledger-chart.test.cjs internal/gui/tests/usage-chart-axis.test.cjs internal/gui/tests/panel-usage.test.cjs internal/gui/tests/usage-refresh.test.cjs internal/gui/tests/usage-ledger-content.test.cjs internal/gui/tests/claude-direct.test.cjs internal/gui/tests/signin-again.test.cjs internal/gui/tests/sessions-calendar-fill.test.cjs internal/gui/tests/editor-short-window.test.cjs internal/gui/tests/group-from-model-add.test.cjs internal/gui/tests/sync-other-kind.test.cjs internal/gui/tests/web-page.test.cjs internal/gui/tests/balance-parts.test.cjs internal/gui/tests/search-api.test.cjs internal/gui/tests/searcher-pick.test.cjs internal/gui/tests/provider-file-error.test.cjs internal/gui/tests/sessions-manage.test.cjs internal/gui/tests/number-units.test.cjs internal/gui/tests/sessions-bar-names.test.cjs internal/gui/tests/model-suffix-own.test.cjs internal/gui/tests/routing-effort-change.test.cjs internal/gui/tests/every-skill.test.cjs internal/gui/tests/header-centre.test.cjs internal/gui/tests/public-url.test.cjs internal/gui/tests/strip-scrollbar.test.cjs internal/gui/tests/remove-all-skills.test.cjs internal/gui/tests/request-archive-usage.test.cjs internal/gui/tests/agent-layout.test.cjs internal/gui/tests/group-found-off.test.cjs internal/gui/tests/provider-pickLine truncated
```
If Playwright is installed outside the repository, set `NODE_PATH` to the
+146
View File
@@ -0,0 +1,146 @@
// Run with Node's test runner and Playwright on the module path; see README.md.
// What was left over time (#651): under an account's meters on the Usage
// page, a line a window from magpie's readings — broken where the 5-hour
// window started again — a dashed even burn from each window's start to its
// reset, and an upright line at now; "2 days" / "Cycle" turns every card's
// range at once, in the app's own segmented control, the pick remembered,
// and the click doesn't move the page. An account with no readings has no
// curve. The tray's card has a thin line of its first window's cycle.
// English and Chinese, light and dark; no backend, the API is faked here.
const assert = require("node:assert/strict");
const fs = require("node:fs/promises");
const path = require("node:path");
const { test } = require("node:test");
const { chromium, webkit } = require("playwright");
const assets = path.resolve(__dirname, "../assets");
const H = 3600e3;
const iso = (ms) => new Date(ms).toISOString();
function fixtures(now) {
const r5 = now + 2 * H, rw = now + 3 * 24 * H;
const quotas = [
{ provider: "codex", name: "Codex", icon: "openai", plan: "Plus", user: "a@x.com", windows: [
{ name: "5 hours", used: 40, resetsAt: iso(r5) },
{ name: "Weekly", used: 30, resetsAt: iso(rw) },
] },
{ provider: "kimi", name: "Kimi", icon: "kimi-color", windows: [{ name: "Weekly", used: 10, resetsAt: iso(rw) }] },
];
const five = [], week = [];
// the 5-hour window before the last reset, then this one
for (let m = 0; m <= 180; m += 30) five.push({ at: iso(r5 - 10 * H + m * 60e3), left: 100 - m / 3, start: iso(r5 - 10 * H), resetsAt: iso(r5 - 5 * H) });
for (let m = 0; m <= 180; m += 30) five.push({ at: iso(r5 - 5 * H + m * 60e3), left: 100 - m / 3, start: iso(r5 - 5 * H), resetsAt: iso(r5) });
for (let h = 90; h >= 0; h -= 6) week.push({ at: iso(now - h * H), left: 70 + h / 9, start: iso(rw - 168 * H), resetsAt: iso(rw) });
const history = [{ provider: "codex", user: "a@x.com", lines: [{ name: "5 hours", points: five }, { name: "Weekly", points: week }] }];
return { quotas, history };
}
function serve(lang, theme, panel, data) {
const settings = { theme, lang, tray: "panel", quotaLeft: false, currency: "usd" };
return async (route) => {
const url = new URL(route.request().url());
const json = (body) => route.fulfill({ json: body });
if (url.pathname === "/boot.js") return route.fulfill({ contentType: "text/javascript", body: `window.bootPrefs = {lang:"${lang}",theme:"${theme}",web:${!panel}};` });
if (url.pathname === "/wails/runtime.js") return route.fulfill({ contentType: "text/javascript", body: "export const Window = {};" });
if (url.pathname === "/api/state") return json({ agents: [], profiles: [], settings });
if (url.pathname === "/api/settings") return json(settings);
if (url.pathname === "/api/providers") return json({ providers: [], presets: [], excluded: [], gateway: { running: true, window: true } });
if (url.pathname === "/api/usage/quotas") return json(data.quotas);
if (url.pathname === "/api/usage/quotas/history") return json(data.history);
if (url.pathname === "/api/groups") return json({ groups: [], models: [] });
if (url.pathname === "/api/plugins") return json({ plugins: [] });
if (url.pathname.startsWith("/api/")) return json({});
const file = path.join(assets, url.pathname === "/" ? "index.html" : url.pathname);
const contentType = { ".html": "text/html", ".js": "text/javascript", ".css": "text/css", ".svg": "image/svg+xml", ".png": "image/png" }[path.extname(file)];
try { await route.fulfill({ body: await fs.readFile(file), contentType }); } catch { await route.fulfill({ status: 404, body: "" }); }
};
}
const words = {
en: { head: "Left over time", two: "2 days", cycle: "Cycle", five: "5 hours", week: "Weekly" },
zh: { head: "剩余额度走势", two: "2 天", cycle: "本周期", five: "5 小时", week: "每周" },
};
for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "webkit"])) {
for (const lang of ["en", "zh"]) {
const w = words[lang];
test(`${engine} ${lang}: quota cards draw what was left over time`, async (t) => {
const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch({ channel: "chromium" }));
const pages = [];
t.after(async () => {
if (process.env.ARTIFACT_DIR) {
await fs.mkdir(process.env.ARTIFACT_DIR, { recursive: true });
for (const [name, p] of pages) await p.screenshot({ path: path.join(process.env.ARTIFACT_DIR, `${engine}-${lang}-quota-curve-${name}.png`) });
}
await browser.close();
});
const errors = [];
const data = fixtures(Date.now());
const open = async (name, url, theme, viewport) => {
const page = await (await browser.newContext({ viewport, reducedMotion: "reduce" })).newPage();
pages.push([name, page]);
page.setDefaultTimeout(5000);
page.on("pageerror", (e) => errors.push(e.message));
await page.route("**/*", serve(lang, theme, url.includes("mode=panel"), data));
await page.goto(url);
return page;
};
const strokes = {};
for (const theme of ["light", "dark"]) {
const page = await open(theme, "http://magpie.test/?view=usage", theme, { width: 900, height: 620 });
const card = page.locator(".subscription-card", { hasText: "Codex" });
const curve = card.locator(".quota-curve");
await curve.waitFor();
assert.equal(await curve.locator(".qc-head > span").textContent(), w.head);
assert.deepEqual(await curve.locator(".qc-range .opt").allTextContents(), [w.two, w.cycle]);
assert.equal(await curve.locator(".qc-range select").count(), 0);
assert.equal(await curve.locator(".qc-range .opt.on").textContent(), w.cycle, "Cycle first");
// a line a window, an even burn each, now
assert.equal(await curve.locator("path.qc-line").count(), 2);
assert.equal(await curve.locator("line.qc-even").count(), 2);
assert.equal(await curve.locator("line.qc-now").count(), 1);
const five = await curve.locator('path.qc-line[data-name="5 hours"]').getAttribute("d");
assert.equal((five.match(/M/g) || []).length, 2, "the 5-hour line breaks where its window started again: " + five);
const legend = await curve.locator(".qc-key").allTextContents();
assert.deepEqual(legend, [w.five + "40%", w.week + "70%"]);
assert.equal(await page.locator(".subscription-card", { hasText: "Kimi" }).locator(".quota-curve").count(), 0, "no readings, no curve");
// the colours are the theme's chart colours, nothing outside the card
strokes[theme] = await curve.locator("path.qc-line").first().evaluate((e) => getComputedStyle(e).stroke);
const c1 = await page.evaluate(() => { const s = document.createElement("i"); s.style.color = "var(--c1)"; document.body.append(s); const c = getComputedStyle(s).color; s.remove(); return c; });
assert.equal(strokes[theme], c1);
const box = await card.boundingBox(), plot = await curve.locator(".qc-plot").boundingBox();
assert.ok(plot.x >= box.x && plot.x + plot.width <= box.x + box.width + 0.5, "the plot fits its card");
const left = await curve.evaluate((e) => [...e.querySelectorAll("*")].filter((x) => parseFloat(getComputedStyle(x).borderLeftWidth) > 0).length);
assert.equal(left, 0, "no left borders");
if (theme === "light") {
// 2 days: the range turns, the page doesn't move, the pick is kept
const scroller = await page.evaluate(() => { const s = document.scrollingElement; s.scrollTop = 40; return s.scrollTop; });
const before = await curve.locator(".qc-axis").textContent();
await curve.locator(".qc-range .opt", { hasText: w.two }).click();
assert.equal(await curve.locator(".qc-range .opt.on").textContent(), w.two);
assert.notEqual(await curve.locator(".qc-axis").textContent(), before);
assert.notEqual(await curve.locator('path.qc-line[data-name="5 hours"]').getAttribute("d"), five);
assert.equal(await page.evaluate(() => document.scrollingElement.scrollTop), scroller, "the click moved the page");
assert.equal(await page.evaluate(() => localStorage.getItem("magpie.quotaRange")), "2d");
}
}
assert.notEqual(strokes.light, strokes.dark, "dark has its own line colour");
// the tray: a thin line of the 5-hour window's cycle, across the card
const panel = await open("panel", "http://magpie.test/?mode=panel", "dark", { width: 440, height: 640 });
await panel.locator('#ptabs [data-ptab="usage"]').click();
const pcard = panel.locator(".pq-card", { hasText: "a@x.com" });
const spark = pcard.locator(".pq-spark");
await spark.waitFor();
assert.equal(await spark.locator("path.qc-line").count(), 1);
assert.equal((((await spark.locator("path.qc-line").getAttribute("d")).match(/M/g)) || []).length, 1, "the current cycle only");
assert.equal(await spark.locator("line.qc-even").count(), 1);
const sb = await spark.boundingBox(), cb = await pcard.boundingBox();
assert.ok(sb.height <= 17 && sb.width > cb.width * 0.7, `thin and across the card: ${JSON.stringify(sb)}`);
assert.equal(await panel.locator(".pq-card", { hasText: "Kimi" }).locator(".pq-spark").count(), 0);
assert.deepEqual(errors, []);
});
}
}
+5
View File
@@ -414,6 +414,11 @@ func usageRoutes(mux *http.ServeMux, w Windows) {
defer cancel()
writeJSON(rw, provider.Quotas(ctx))
})
// what was left of each window over time, for the quota cards' curves
// (#651); ?days= back
mux.HandleFunc("GET /api/usage/quotas/history", func(rw http.ResponseWriter, r *http.Request) {
writeJSON(rw, provider.QuotaHistories(provider.QuotaHistorySince(r.URL.Query().Get("days"), time.Now()), "", ""))
})
// spends one of a Codex account's rate-limit resets, which the page
// has asked the user about first; what it did comes back
mux.HandleFunc("POST /api/usage/codex-reset", func(rw http.ResponseWriter, r *http.Request) {
+1
View File
@@ -514,6 +514,7 @@ func PlanQuotas(ctx context.Context) []SubscriptionQuota {
}
out = append(out, <-stepfun...)
if ctx.Err() == nil {
noteQuotaHistory(out, time.Now())
c.Lock()
c.at, c.data = time.Now(), out
c.Unlock()
+360
View File
@@ -0,0 +1,360 @@
package provider
// What was left of each window over time (#651): each reading of an
// account's windows that magpie makes anyway (the subscriptions', a minute
// apart at most while something asks; the plans' bought with a key) is kept
// as a point — when it was read, the percent left, when the window started
// and when it starts again — so the Usage page can draw the cycle's curve
// against an even burn, and the tray its current cycle's line.
//
// Kept in quota-history.json, by provider and account name, and by window:
// the figures only — no token, no prompt, nothing else of the vendor's. A
// reading kept from before (AsOf) or failed isn't a new one; a window with no
// limit, or one set aside, has nothing to draw. Points come at least
// quotaHistGap apart within a cycle (a nearer one moves the last instead),
// and a run of the same figure is its first and last point only. A cycle
// ends where the window starts again: its reset passed, or moved further
// than the time between two readings (a rolling window's moves with it).
// Points older than quotaHistKept go, and a window keeps quotaHistMax.
//
// Other computers' points come in through the usage sync (davsync), merged
// by MergeQuotaHistory: the union of both, sorted, a point within
// quotaHistSame of the next one dropped for it.
import (
"encoding/json"
"maps"
"math"
"os"
"path/filepath"
"regexp"
"slices"
"strconv"
"strings"
"sync"
"time"
"github.com/yetone/magpie/internal/filememo"
)
const (
quotaHistKept = 45 * 24 * time.Hour
quotaHistMax = 6000
quotaHistGap = 5 * time.Minute
quotaHistSame = 30 * time.Second
// a reset moved this much past the time between two readings is a
// window started again, not a reset read a little differently
quotaHistSlack = 5 * time.Minute
)
// QuotaPoint is one reading of a window: at At, Left percent was left;
// the window began at Start and starts again at ResetsAt, when told.
type QuotaPoint struct {
At time.Time `json:"at"`
Left float64 `json:"left"`
Start *time.Time `json:"start,omitempty"`
ResetsAt *time.Time `json:"resetsAt,omitempty"`
}
// QuotaLine is one window's points, oldest first.
type QuotaLine struct {
Name string `json:"name"`
Points []QuotaPoint `json:"points"`
}
// QuotaHistory is one account's windows over time. User is lowercased.
type QuotaHistory struct {
Provider string `json:"provider"`
User string `json:"user"`
Lines []QuotaLine `json:"lines"`
}
// quotaHist is the file: account ("provider|user") → window → points.
type quotaHist map[string]map[string][]QuotaPoint
var quotaHistMu sync.Mutex
func quotaHistPath() string { return filepath.Join(filepath.Dir(Path()), "quota-history.json") }
func parseQuotaHist(b []byte) (quotaHist, error) {
h := quotaHist{}
if err := json.Unmarshal(b, &h); err != nil || h == nil {
return quotaHist{}, nil
}
return h, nil
}
func readQuotaHist() quotaHist {
if b, err := os.ReadFile(quotaHistPath()); err == nil {
h, _ := parseQuotaHist(b)
return h
}
return quotaHist{}
}
func writeQuotaHist(h quotaHist) {
b, err := json.Marshal(h)
if err != nil {
return
}
path := quotaHistPath()
_ = os.MkdirAll(filepath.Dir(path), 0o700)
_ = writeFileAtomic(path, b)
}
var spanWords = regexp.MustCompile(`(?i)(\d+)\s*-?\s*(h|hr|hour|d|day|w|wk|week|month)s?\b`)
// windowSpan is how long a window runs: as its vendor said, else as its
// name says ("5 hours", "7d", "Weekly"), else 0, not known.
func windowSpan(w QuotaWindow) time.Duration {
if w.Span > 0 {
return w.Span
}
unit := map[string]time.Duration{"h": time.Hour, "hr": time.Hour, "hour": time.Hour, "d": 24 * time.Hour, "day": 24 * time.Hour,
"w": 7 * 24 * time.Hour, "wk": 7 * 24 * time.Hour, "week": 7 * 24 * time.Hour, "month": 30 * 24 * time.Hour}
if m := spanWords.FindStringSubmatch(w.Name); m != nil {
if n, err := strconv.Atoi(m[1]); err == nil && n > 0 {
return time.Duration(n) * unit[strings.ToLower(m[2])]
}
}
switch n := strings.ToLower(w.Name); {
case strings.Contains(n, "daily"):
return 24 * time.Hour
case strings.Contains(n, "weekly"):
return 7 * 24 * time.Hour
case strings.Contains(n, "monthly"):
return 30 * 24 * time.Hour
}
return 0
}
// newCycle says whether b, read after a, is of a window started again.
func newCycle(a, b QuotaPoint) bool {
if a.ResetsAt == nil && b.ResetsAt == nil {
return b.Left > a.Left+5 // filled again
}
if a.ResetsAt == nil || b.ResetsAt == nil { // begun, or over
return true
}
if !a.ResetsAt.After(b.At) { // its reset has passed
return true
}
moved := b.ResetsAt.Sub(*a.ResetsAt)
return moved > b.At.Sub(a.At)+quotaHistSlack || moved < -quotaHistSlack
}
// pointOf is what w says at now, if it is something to draw.
func pointOf(w QuotaWindow, now time.Time) (QuotaPoint, bool) {
if w.Unlimited || w.Aside || w.Family != "" || w.Name == "" || math.IsNaN(w.Used) {
return QuotaPoint{}, false
}
p := QuotaPoint{At: now.UTC().Truncate(time.Second), Left: math.Round(max(0, min(100, 100-w.Used))*100) / 100}
reset := w.ResetsAt
if reset == nil && w.ResetSecs > 0 {
r := now.Add(time.Duration(w.ResetSecs) * time.Second)
reset = &r
}
if reset != nil {
r := reset.UTC().Truncate(time.Second)
p.ResetsAt = &r
if span := windowSpan(w); span > 0 {
s := r.Add(-span)
p.Start = &s
}
}
return p, true
}
// addPoint puts n after pts, the last moved to it when near or the same.
func addPoint(pts []QuotaPoint, n QuotaPoint) []QuotaPoint {
k := len(pts)
if k > 0 && !n.At.After(pts[k-1].At) {
return pts // not newer than what is kept
}
if k >= 2 {
p1, p2 := pts[k-2], pts[k-1]
if !newCycle(p1, p2) && !newCycle(p2, n) {
flat := math.Abs(n.Left-p2.Left) < 0.05 && math.Abs(p2.Left-p1.Left) < 0.05
if flat || n.At.Sub(p1.At) < quotaHistGap {
pts[k-1] = n
return pts
}
}
}
if k >= 1 && n.At.Sub(pts[k-1].At) < quotaHistSame && !newCycle(pts[k-1], n) {
pts[k-1] = n
return pts
}
return append(pts, n)
}
// prune drops what is older than kept and what is past quotaHistMax.
func (h quotaHist) prune(now time.Time) {
cut := now.Add(-quotaHistKept)
for key, ws := range h {
for name, pts := range ws {
i, _ := slices.BinarySearchFunc(pts, cut, func(p QuotaPoint, t time.Time) int { return p.At.Compare(t) })
pts = pts[i:]
if len(pts) > quotaHistMax {
pts = pts[len(pts)-quotaHistMax:]
}
if len(pts) == 0 {
delete(ws, name)
} else {
ws[name] = slices.Clip(pts)
}
}
if len(ws) == 0 {
delete(h, key)
}
}
}
func quotaHistKey(provider, user string) string { return provider + "|" + strings.ToLower(user) }
// noteQuotaHistory keeps what each window among qs says, read at now.
func noteQuotaHistory(qs []SubscriptionQuota, now time.Time) {
quotaHistMu.Lock()
defer quotaHistMu.Unlock()
var h quotaHist
changed := false
for _, q := range qs {
if q.Error != "" || q.AsOf != nil || q.Provider == "" {
continue // a reading kept from before isn't a new one
}
for _, w := range q.Windows {
p, ok := pointOf(w, now)
if !ok {
continue
}
if h == nil {
h = readQuotaHist()
}
key := quotaHistKey(q.Provider, q.User)
if h[key] == nil {
h[key] = map[string][]QuotaPoint{}
}
name := w.Name
if w.Pool != "" { // Antigravity's pools each have a 5-hour window
name = w.Pool + " · " + w.Name
}
before := h[key][name]
k := len(before)
var last QuotaPoint
if k > 0 {
last = before[k-1]
}
after := addPoint(before, p)
if len(after) != k || k > 0 && after[k-1] != last {
changed = true
}
h[key][name] = after
}
}
if !changed {
return
}
h.prune(now)
writeQuotaHist(h)
}
// mergeQuotaHist puts b's points into a: the union of both, a point within
// quotaHistSame of the next one (of the same cycle) dropped for it.
func mergeQuotaHist(a, b quotaHist) (changed bool) {
for key, ws := range b {
if a[key] == nil {
a[key] = map[string][]QuotaPoint{}
}
for name, pts := range ws {
old := a[key][name]
all := slices.Concat(old, pts)
slices.SortStableFunc(all, func(x, y QuotaPoint) int { return x.At.Compare(y.At) })
out := all[:0:0]
for i, p := range all {
if i+1 < len(all) && all[i+1].At.Sub(p.At) < quotaHistSame && !newCycle(p, all[i+1]) {
continue // the newer of two near the same
}
out = append(out, p)
}
if !slices.EqualFunc(old, out, samePoint) {
a[key][name] = out
changed = true
}
}
}
return changed
}
func samePoint(x, y QuotaPoint) bool {
eq := func(a, b *time.Time) bool { return a == nil && b == nil || a != nil && b != nil && a.Equal(*b) }
return x.At.Equal(y.At) && x.Left == y.Left && eq(x.Start, y.Start) && eq(x.ResetsAt, y.ResetsAt)
}
// QuotaHistoryData is this computer's history as kept, for the usage sync
// to share.
func QuotaHistoryData() []byte {
quotaHistMu.Lock()
defer quotaHistMu.Unlock()
b, _ := os.ReadFile(quotaHistPath())
return b
}
// MergeQuotaHistory merges another computer's history (as QuotaHistoryData
// gave it there) into this one's.
func MergeQuotaHistory(data []byte, now time.Time) error {
var b quotaHist
if err := json.Unmarshal(data, &b); err != nil {
return err
}
quotaHistMu.Lock()
defer quotaHistMu.Unlock()
h := readQuotaHist()
if !mergeQuotaHist(h, b) {
return nil
}
h.prune(now)
writeQuotaHist(h)
return nil
}
// QuotaHistories is every account's windows' points since since, the
// accounts and windows in name order; provider and user, when given, pick
// one provider's, or one account's.
func QuotaHistories(since time.Time, provider, user string) []QuotaHistory {
h, err := filememo.Read("quota-history", quotaHistPath(), parseQuotaHist)
if err != nil {
return []QuotaHistory{}
}
out := []QuotaHistory{}
for _, key := range slices.Sorted(maps.Keys(h)) {
p, u, _ := strings.Cut(key, "|")
if provider != "" && p != provider || user != "" && u != strings.ToLower(user) {
continue
}
a := QuotaHistory{Provider: p, User: u, Lines: []QuotaLine{}}
for _, name := range slices.Sorted(maps.Keys(h[key])) {
pts := h[key][name]
i, _ := slices.BinarySearchFunc(pts, since, func(p QuotaPoint, t time.Time) int { return p.At.Compare(t) })
if i > 0 {
i-- // the one before, so a line reaches the range's edge
}
if i < len(pts) {
a.Lines = append(a.Lines, QuotaLine{Name: name, Points: slices.Clone(pts[i:])})
}
}
if len(a.Lines) > 0 {
out = append(out, a)
}
}
return out
}
// QuotaHistorySince is the start of a range days long (as a query gives
// it) to now: all that is kept when not a number of days.
func QuotaHistorySince(days string, now time.Time) time.Time {
n, err := strconv.ParseFloat(days, 64)
if err != nil || n <= 0 || n*24*float64(time.Hour) > float64(quotaHistKept) {
return now.Add(-quotaHistKept)
}
return now.Add(-time.Duration(n * 24 * float64(time.Hour)))
}
+168
View File
@@ -0,0 +1,168 @@
package provider
import (
"encoding/json"
"testing"
"time"
)
func histCard(user string, used5h float64, reset5h time.Time, usedWeek float64, resetWeek time.Time) SubscriptionQuota {
return SubscriptionQuota{Provider: "codex", Name: "Codex", User: user, Windows: []QuotaWindow{
{Name: "5 hours", Used: used5h, ResetsAt: &reset5h},
{Name: "Weekly", Used: usedWeek, ResetsAt: &resetWeek, Span: 7 * 24 * time.Hour},
{Name: "Opus", Used: 1, Family: "Claude"},
{Name: "Reviews", Unlimited: true},
}}
}
func lineOf(t *testing.T, hs []QuotaHistory, user, name string) []QuotaPoint {
t.Helper()
for _, h := range hs {
if h.User != user {
continue
}
for _, l := range h.Lines {
if l.Name == name {
return l.Points
}
}
}
t.Fatalf("no %s line for %s in %+v", name, user, hs)
return nil
}
// TestQuotaHistoryRecords: each reading of an account's windows is a point
// of the percent left, with when its window began and starts again; a
// window of a family, an unlimited one, a failed reading and one kept from
// before aren't kept; readings a minute apart within five are one point,
// and a run of the same figure its first and last (#651).
func TestQuotaHistoryRecords(t *testing.T) {
t.Setenv("HOME", t.TempDir())
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
t0 := time.Date(2026, 10, 3, 8, 0, 0, 0, time.UTC)
r5, rw := t0.Add(3*time.Hour), t0.Add(4*24*time.Hour)
at := func(m int) time.Time { return t0.Add(time.Duration(m) * time.Minute) }
noteQuotaHistory([]SubscriptionQuota{histCard("A@x.com", 10, r5, 40, rw)}, at(0))
noteQuotaHistory([]SubscriptionQuota{histCard("A@x.com", 12, r5, 40, rw)}, at(1)) // within 5 min of the first: one more point
noteQuotaHistory([]SubscriptionQuota{histCard("A@x.com", 13, r5, 40, rw)}, at(2)) // moves it
noteQuotaHistory([]SubscriptionQuota{histCard("A@x.com", 20, r5, 40, rw)}, at(10))
noteQuotaHistory([]SubscriptionQuota{histCard("A@x.com", 20, r5, 40, rw)}, at(20)) // the same: the run's end moves
noteQuotaHistory([]SubscriptionQuota{histCard("A@x.com", 20, r5, 40, rw)}, at(30))
bad := histCard("A@x.com", 90, r5, 90, rw)
bad.Error = "401"
stale := histCard("A@x.com", 90, r5, 90, rw)
stale.AsOf = &t0
noteQuotaHistory([]SubscriptionQuota{bad, stale}, at(40))
hs := QuotaHistories(t0.Add(-time.Hour), "", "")
if len(hs) != 1 || hs[0].Provider != "codex" || hs[0].User != "a@x.com" || len(hs[0].Lines) != 2 {
t.Fatalf("histories = %+v", hs)
}
five := lineOf(t, hs, "a@x.com", "5 hours")
want := []struct {
m int
left float64
}{{0, 90}, {2, 87}, {10, 80}, {30, 80}}
if len(five) != len(want) {
t.Fatalf("5 hours points = %+v", five)
}
for i, w := range want {
if !five[i].At.Equal(at(w.m)) || five[i].Left != w.left {
t.Errorf("point %d = %v %v, want %v %v", i, five[i].At, five[i].Left, at(w.m), w.left)
}
}
if five[0].ResetsAt == nil || !five[0].ResetsAt.Equal(r5) || five[0].Start == nil || !five[0].Start.Equal(r5.Add(-5*time.Hour)) {
t.Errorf("5 hours window = %v .. %v", five[0].Start, five[0].ResetsAt)
}
week := lineOf(t, hs, "a@x.com", "Weekly")
if len(week) != 2 || week[0].Left != 60 || !week[0].Start.Equal(rw.Add(-7*24*time.Hour)) {
t.Errorf("weekly = %+v", week)
}
}
// TestQuotaHistoryCycles: a window started again is a new cycle, never
// folded into the one before, a rolling window's reset moving with the
// clock isn't one, and points older than 45 days go.
func TestQuotaHistoryCycles(t *testing.T) {
t.Setenv("HOME", t.TempDir())
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
t0 := time.Date(2026, 8, 1, 0, 0, 0, 0, time.UTC)
week := t0.Add(7 * 24 * time.Hour)
card := func(used float64, r5 time.Time) []SubscriptionQuota {
return []SubscriptionQuota{histCard("", used, r5, 0, week)}
}
// a flat run, then the 5-hour window starts again at the same figure
noteQuotaHistory(card(30, t0.Add(time.Hour)), t0)
noteQuotaHistory(card(30, t0.Add(time.Hour)), t0.Add(20*time.Minute))
noteQuotaHistory(card(30, t0.Add(time.Hour)), t0.Add(50*time.Minute))
noteQuotaHistory(card(30, t0.Add(6*time.Hour)), t0.Add(70*time.Minute))
noteQuotaHistory(card(30, t0.Add(6*time.Hour)), t0.Add(90*time.Minute))
pts := lineOf(t, QuotaHistories(time.Time{}, "codex", ""), "", "5 hours")
if len(pts) != 4 || !pts[1].At.Equal(t0.Add(50*time.Minute)) || !pts[2].At.Equal(t0.Add(70*time.Minute)) {
t.Fatalf("cycles folded: %+v", pts)
}
if !newCycle(pts[1], pts[2]) || newCycle(pts[0], pts[1]) {
t.Errorf("newCycle wrong at the reset")
}
// a rolling window: its reset 5 hours from each reading
a := QuotaPoint{At: t0, ResetsAt: new(t0.Add(5 * time.Hour))}
b := QuotaPoint{At: t0.Add(30 * time.Minute), ResetsAt: new(t0.Add(5*time.Hour + 30*time.Minute))}
if newCycle(a, b) {
t.Error("a rolling window's reset moving with the clock read as a new cycle")
}
// 46 days on, the old points go
later := t0.Add(46 * 24 * time.Hour)
noteQuotaHistory([]SubscriptionQuota{histCard("", 5, later.Add(time.Hour), 0, later.Add(24*time.Hour))}, later)
pts = lineOf(t, QuotaHistories(time.Time{}, "", ""), "", "5 hours")
if len(pts) != 1 || !pts[0].At.Equal(later) {
t.Errorf("after 46 days: %+v", pts)
}
}
// TestMergeQuotaHistory: another computer's points join this one's: the
// union, sorted, two within half a minute of each other the newer one,
// and merging the same again changes nothing.
func TestMergeQuotaHistory(t *testing.T) {
t.Setenv("HOME", t.TempDir())
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
t0 := time.Date(2026, 10, 3, 8, 0, 0, 0, time.UTC)
r := t0.Add(4 * time.Hour)
at := func(m int) time.Time { return t0.Add(time.Duration(m) * time.Minute) }
noteQuotaHistory([]SubscriptionQuota{histCard("a@x.com", 10, r, 0, r)}, at(0))
noteQuotaHistory([]SubscriptionQuota{histCard("a@x.com", 30, r, 0, r)}, at(20))
other := quotaHist{
"codex|a@x.com": {"5 hours": {
{At: at(10), Left: 80, ResetsAt: &r},
{At: at(20).Add(10 * time.Second), Left: 69, ResetsAt: &r}, // near this one's: the newer stays
{At: at(40), Left: 60, ResetsAt: &r},
}},
"claude|b@y.com": {"Weekly": {{At: at(5), Left: 50}}},
}
data, _ := json.Marshal(other)
if err := MergeQuotaHistory(data, at(41)); err != nil {
t.Fatal(err)
}
five := lineOf(t, QuotaHistories(time.Time{}, "", ""), "a@x.com", "5 hours")
lefts := []float64{}
for _, p := range five {
lefts = append(lefts, p.Left)
}
if len(lefts) != 4 || lefts[0] != 90 || lefts[1] != 80 || lefts[2] != 69 || lefts[3] != 60 {
t.Fatalf("merged = %v", lefts)
}
lineOf(t, QuotaHistories(time.Time{}, "", ""), "b@y.com", "Weekly")
before := string(QuotaHistoryData())
if err := MergeQuotaHistory(data, at(41)); err != nil {
t.Fatal(err)
}
if string(QuotaHistoryData()) != before {
t.Error("merging the same points again changed the file")
}
if err := MergeQuotaHistory([]byte("{nope"), at(41)); err == nil {
t.Error("a broken file merged")
}
}
+1
View File
@@ -128,6 +128,7 @@ func SubscriptionUsage(ctx context.Context) []SubscriptionQuota {
start := time.Now()
out := fetchSubscriptionUsage()
noteDailyCredits(out, time.Now())
noteQuotaHistory(out, time.Now())
c.Lock()
c.at, c.data, c.pending = time.Now(), out, nil
if claudeAsked.Load() > start.UnixNano() {