Files
Ilker ManapandClaude Opus 5.5 11231d8c2b The portal in English and Turkish: a catalogue per language, a switcher at the foot of every page, landing.language, Turkish numbers and dates
Every template and handler message in internal/web goes through a JSON
catalogue under internal/web/locales/<code>/, with English as the fallback.
The language is the person's choice (a cookie set by /_lang/{code}), else
Accept-Language, else landing.language. Tests refuse a key some language
lacks, a translation with other arguments than English, and template text
left in English.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 00:12:53 +02:00

137 lines
3.9 KiB
Go

package web
import (
"context"
"html/template"
"time"
"gitea.mynodes.xyz/ilker/llmgw/internal/accounting"
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
)
// SelfUsage is what a person's own page reads of their use. Optional: a
// store without it leaves the page showing limits alone.
type SelfUsage interface {
UsageOfUser(ctx context.Context, userID string, since time.Time, width time.Duration) ([]store.PlaceUsage, []store.Bucket, error)
SpendOfPool(ctx context.Context, l store.Limits, since time.Time) (int64, error)
}
// selfWindow is how far back a person's own page looks.
const selfWindow = 30 * 24 * time.Hour
// ownUse is the person's last thirty days, for the top of their page.
type ownUse struct {
Requests, Tokens int64
Spend string
Chart template.HTML
}
// budgetBar is one budget that applies, and how much of it is gone.
type budgetBar struct {
// Label says whose pool it is: the person's own, or one they share.
Label string
Used, Budget string
Period string
Percent int
// Near is past four-fifths, when it is worth colouring.
Near, Over bool
}
// placeUse is what a person spent in one place over the window.
type placeUse struct {
Requests, Tokens int64
Spend string
}
func (p *AccountPages) fillOwnUse(ctx context.Context, l *Lang, user store.User, d *profileData) {
su, ok := p.keys.(SelfUsage)
if !ok {
return
}
places, buckets, err := su.UsageOfUser(ctx, user.ID, time.Now().Add(-selfWindow), 24*time.Hour)
if err != nil {
p.log.Warn("could not read a person's own usage", "err", err)
return
}
u := &ownUse{}
var cost int64
byPlace := map[string]placeUse{}
for _, pl := range places {
u.Requests += pl.Requests
u.Tokens += pl.Tokens
cost += pl.CostMicros
byPlace[pl.ProjectID] = placeUse{pl.Requests, pl.Tokens, l.Units(pl.CostMicros)}
}
u.Spend = l.Units(cost)
s := Series{Colour: "--accent"}
for _, b := range buckets {
s.Values = append(s.Values, float64(b.Prompt+b.Completion))
s.Labels = append(s.Labels, l.Format(b.Start.UTC(), "Jan 2"))
}
if u.Requests > 0 {
u.Chart = l.BarChart(s, l.T("selfuse.chart_unit"))
}
d.Own = u
for i := range d.LimitsBy {
c := &d.LimitsBy[i]
if pu, ok := byPlace[c.project]; ok {
c.Use = &pu
}
for _, lim := range c.chain {
if lim.Budget == nil || lim.Scope == store.ScopeKey {
continue
}
start := accounting.PeriodStart(lim.BudgetPeriod, time.Now())
spent, err := su.SpendOfPool(ctx, lim, start)
if err != nil {
p.log.Warn("could not read a pool's spend", "scope", lim.Scope, "err", err)
continue
}
bar := budgetBar{Label: poolLabel(l, lim.Scope), Used: l.Units(spent), Budget: l.Units(*lim.Budget),
Period: periodWord(l, lim.BudgetPeriod)}
if *lim.Budget > 0 {
bar.Percent = int(min(spent*100 / *lim.Budget, 100))
bar.Near = spent*5 >= *lim.Budget*4
bar.Over = spent >= *lim.Budget
}
c.Budgets = append(c.Budgets, bar)
}
}
}
// poolLabel says whose budget a limit is, which is the thing a person needs
// to read a bar right: their own is theirs to spend, a project's is shared
// with everyone in it.
func poolLabel(l *Lang, s store.Scope) string {
switch s {
case store.ScopeUser:
return l.T("selfuse.pool.user")
case store.ScopeProjectMember:
return l.T("selfuse.pool.project_member")
case store.ScopeTeamMember:
return l.T("selfuse.pool.team_member")
case store.ScopeOrgMember:
return l.T("selfuse.pool.org_member")
case store.ScopeProject:
return l.T("selfuse.pool.project")
case store.ScopeTeam:
return l.T("selfuse.pool.team")
case store.ScopeOrg:
return l.T("selfuse.pool.org")
}
return string(s)
}
func periodWord(l *Lang, p store.Period) string {
switch p {
case store.PeriodDay:
return l.T("selfuse.period.day")
case store.PeriodMonth:
return l.T("selfuse.period.month")
case store.PeriodNone:
return l.T("selfuse.period.none")
}
return l.T("selfuse.period.other", string(p))
}