M cmd/lethe/main.go => cmd/lethe/main.go +6 -1
@@ 27,6 27,7 @@ import (
"sourcecraft.dev/bigbes/lethe/internal/domain/ingest"
"sourcecraft.dev/bigbes/lethe/internal/domain/project"
"sourcecraft.dev/bigbes/lethe/internal/domain/session"
+ "sourcecraft.dev/bigbes/lethe/internal/domain/stats"
"sourcecraft.dev/bigbes/lethe/internal/platform/database"
"sourcecraft.dev/bigbes/lethe/internal/platform/health"
"sourcecraft.dev/bigbes/lethe/internal/platform/observability"
@@ 99,13 100,15 @@ func run() int {
sessionHnd = &session.Handler{}
projectRepo = &project.Repository{}
projectHnd = &project.Handler{}
+ statsRepo = &stats.Repository{}
+ statsHnd = &stats.Handler{}
serverSvc = &server.Server{}
)
registered := []any{
loggerSvc, metricsSvc, dbSvc, dbCheckSvc, healthSetSvc,
authSvc, ingestRepo, ingestSvc, ingestHnd,
- sessionRepo, sessionHnd, projectRepo, projectHnd, serverSvc,
+ sessionRepo, sessionHnd, projectRepo, projectHnd, statsRepo, statsHnd, serverSvc,
}
mgr.AddComponent(ctx,
@@ 123,6 126,8 @@ func run() int {
steward.MustServiceAsset(sessionHnd),
steward.MustServiceAsset(projectRepo),
steward.MustServiceAsset(projectHnd),
+ steward.MustServiceAsset(statsRepo),
+ steward.MustServiceAsset(statsHnd),
steward.MustServiceAsset(serverSvc, steward.Root()),
)
M cmd/lethe/main_e2e_test.go => cmd/lethe/main_e2e_test.go +3 -0
@@ 23,6 23,7 @@ import (
"sourcecraft.dev/bigbes/lethe/internal/domain/ingest"
"sourcecraft.dev/bigbes/lethe/internal/domain/project"
"sourcecraft.dev/bigbes/lethe/internal/domain/session"
+ "sourcecraft.dev/bigbes/lethe/internal/domain/stats"
"sourcecraft.dev/bigbes/lethe/internal/platform/database"
"sourcecraft.dev/bigbes/lethe/internal/platform/health"
"sourcecraft.dev/bigbes/lethe/internal/platform/observability"
@@ 83,6 84,8 @@ func TestEndToEnd_MultiUserIsolation(t *testing.T) {
steward.MustServiceAsset(&session.Handler{}),
steward.MustServiceAsset(&project.Repository{}),
steward.MustServiceAsset(&project.Handler{}),
+ steward.MustServiceAsset(&stats.Repository{}),
+ steward.MustServiceAsset(&stats.Handler{}),
steward.MustServiceAsset(srv, steward.Root()),
)
A internal/domain/stats/buckets.go => internal/domain/stats/buckets.go +67 -0
@@ 0,0 1,67 @@
+// Package stats implements the read-only /api/v1/stats aggregation API. It
+// bundles six queries into one round-trip and fills deterministic time windows
+// (daily, heatmap, hour-of-day) so missing slots appear as zeros on the wire.
+package stats
+
+import "time"
+
+// DailyWindow returns days+1 unix seconds (at UTC midnight), oldest first,
+// ending today. Example: days=7 returns 8 entries — today plus the 7 prior
+// days. Each pair is exactly 86400 seconds apart.
+//
+// The boundary rule: time.Unix(now,0).UTC().Truncate(24h) gives today's
+// midnight in UTC.
+func DailyWindow(now int64, days int) []int64 {
+ today := time.Unix(now, 0).UTC().Truncate(24 * time.Hour).Unix()
+ out := make([]int64, days+1)
+ for i := 0; i <= days; i++ {
+ // oldest first: index 0 is `days` days before today
+ out[i] = today - int64(days-i)*86400
+ }
+ return out
+}
+
+// FillDaily left-joins rows onto the window. Each window slot appears exactly
+// once in the output (oldest first). Missing slots get PerTool: map[string]int64{}
+// (empty map, not nil — so JSON encodes as {}). Rows whose DateUnix does not
+// match any window slot are silently dropped (they are outside the range).
+func FillDaily(window []int64, rows []DailyBucket) []DailyBucket {
+ // Build a lookup map from the rows.
+ rowByDate := make(map[int64]DailyBucket, len(rows))
+ for _, r := range rows {
+ rowByDate[r.DateUnix] = r
+ }
+
+ out := make([]DailyBucket, len(window))
+ for i, ts := range window {
+ if row, ok := rowByDate[ts]; ok {
+ out[i] = row
+ } else {
+ out[i] = DailyBucket{DateUnix: ts, PerTool: map[string]int64{}}
+ }
+ }
+ return out
+}
+
+// HeatmapWindow returns 84 unix seconds (at UTC midnight), oldest first,
+// ending today — 12 weeks × 7 days. The length is fixed regardless of the
+// request's range.
+func HeatmapWindow(now int64) []int64 {
+ const cells = 84 // 12 weeks × 7 days
+ today := time.Unix(now, 0).UTC().Truncate(24 * time.Hour).Unix()
+ out := make([]int64, cells)
+ for i := 0; i < cells; i++ {
+ out[i] = today - int64(cells-1-i)*86400
+ }
+ return out
+}
+
+// HourWindow returns 24 zero-initialised HourBucket values, one per hour
+// (Hour 0..23), in ascending order.
+func HourWindow() []HourBucket {
+ out := make([]HourBucket, 24)
+ for i := range out {
+ out[i] = HourBucket{Hour: i, Count: 0}
+ }
+ return out
+}
A internal/domain/stats/buckets_test.go => internal/domain/stats/buckets_test.go +237 -0
@@ 0,0 1,237 @@
+package stats_test
+
+import (
+ "testing"
+ "time"
+
+ "sourcecraft.dev/bigbes/lethe/internal/domain/stats"
+)
+
+// utcMidnight returns the unix seconds at midnight UTC for the given reference
+// time. Mirrors the bucketing rule: time.Unix(now, 0).UTC().Truncate(24h).
+func utcMidnight(t time.Time) int64 {
+ return t.UTC().Truncate(24 * time.Hour).Unix()
+}
+
+// ────────────────────────────────────────────────────────────
+// DailyWindow
+// ────────────────────────────────────────────────────────────
+
+func TestDailyWindow_Length(t *testing.T) {
+ now := time.Date(2025, 6, 15, 14, 30, 0, 0, time.UTC).Unix()
+ for _, days := range []int{0, 7, 30, 90} {
+ got := stats.DailyWindow(now, days)
+ if len(got) != days+1 {
+ t.Errorf("DailyWindow(days=%d): len=%d; want %d", days, len(got), days+1)
+ }
+ }
+}
+
+func TestDailyWindow_OldestFirst(t *testing.T) {
+ now := time.Date(2025, 6, 15, 14, 30, 0, 0, time.UTC).Unix()
+ got := stats.DailyWindow(now, 7)
+ for i := 1; i < len(got); i++ {
+ if got[i] <= got[i-1] {
+ t.Errorf("DailyWindow not strictly ascending at index %d: %d <= %d", i, got[i], got[i-1])
+ }
+ }
+}
+
+func TestDailyWindow_LastEntryIsToday(t *testing.T) {
+ ref := time.Date(2025, 6, 15, 23, 59, 59, 0, time.UTC)
+ got := stats.DailyWindow(ref.Unix(), 7)
+ wantToday := utcMidnight(ref)
+ last := got[len(got)-1]
+ if last != wantToday {
+ t.Errorf("DailyWindow last entry: got %d; want %d (%v vs %v)",
+ last, wantToday,
+ time.Unix(last, 0).UTC(), time.Unix(wantToday, 0).UTC())
+ }
+}
+
+func TestDailyWindow_FirstEntryIsDaysMidnightsBack(t *testing.T) {
+ ref := time.Date(2025, 6, 15, 12, 0, 0, 0, time.UTC)
+ days := 7
+ got := stats.DailyWindow(ref.Unix(), days)
+ wantFirst := utcMidnight(ref.AddDate(0, 0, -days))
+ if got[0] != wantFirst {
+ t.Errorf("DailyWindow first entry: got %d; want %d (%v vs %v)",
+ got[0], wantFirst,
+ time.Unix(got[0], 0).UTC(), time.Unix(wantFirst, 0).UTC())
+ }
+}
+
+func TestDailyWindow_EachEntryAtMidnightUTC(t *testing.T) {
+ ref := time.Date(2025, 3, 10, 18, 45, 0, 0, time.UTC)
+ got := stats.DailyWindow(ref.Unix(), 30)
+ // Each consecutive pair must be exactly 86400 seconds apart (one UTC day).
+ for i := 1; i < len(got); i++ {
+ diff := got[i] - got[i-1]
+ if diff != 86400 {
+ t.Errorf("DailyWindow entry spacing at index %d: got %d; want 86400", i, diff)
+ }
+ }
+}
+
+func TestDailyWindow_DeterministicAtMidnightBoundary(t *testing.T) {
+ // Exactly at midnight UTC — today's slot should still be that midnight.
+ ref := time.Date(2025, 1, 20, 0, 0, 0, 0, time.UTC)
+ got := stats.DailyWindow(ref.Unix(), 3)
+ last := got[len(got)-1]
+ if last != ref.Unix() {
+ t.Errorf("midnight boundary: last=%d; want %d", last, ref.Unix())
+ }
+}
+
+// ────────────────────────────────────────────────────────────
+// FillDaily
+// ────────────────────────────────────────────────────────────
+
+func TestFillDaily_MissingDaysGetEmptyMap(t *testing.T) {
+ now := time.Date(2025, 6, 15, 12, 0, 0, 0, time.UTC).Unix()
+ window := stats.DailyWindow(now, 3) // 4 entries
+ got := stats.FillDaily(window, nil)
+ if len(got) != 4 {
+ t.Fatalf("FillDaily(nil rows): len=%d; want 4", len(got))
+ }
+ for _, b := range got {
+ if b.PerTool == nil {
+ t.Errorf("missing slot has nil PerTool; want empty map (got DateUnix=%d)", b.DateUnix)
+ }
+ }
+}
+
+func TestFillDaily_RowsMatchedToCorrectSlot(t *testing.T) {
+ now := time.Date(2025, 6, 15, 12, 0, 0, 0, time.UTC).Unix()
+ window := stats.DailyWindow(now, 3)
+
+ // Seed one row at the second slot (yesterday-2).
+ target := window[1]
+ rows := []stats.DailyBucket{
+ {DateUnix: target, PerTool: map[string]int64{"cc": 5}},
+ }
+
+ got := stats.FillDaily(window, rows)
+ if len(got) != 4 {
+ t.Fatalf("len=%d; want 4", len(got))
+ }
+ for _, b := range got {
+ if b.DateUnix == target {
+ if b.PerTool["cc"] != 5 {
+ t.Errorf("slot %d: cc=%d; want 5", target, b.PerTool["cc"])
+ }
+ } else {
+ if len(b.PerTool) != 0 {
+ t.Errorf("slot %d: PerTool=%v; want empty", b.DateUnix, b.PerTool)
+ }
+ }
+ }
+}
+
+func TestFillDaily_OutsideWindowRowsDropped(t *testing.T) {
+ now := time.Date(2025, 6, 15, 12, 0, 0, 0, time.UTC).Unix()
+ window := stats.DailyWindow(now, 2)
+
+ // Row 1000 days ago — outside the 3-slot window.
+ outside := utcMidnight(time.Unix(now, 0).UTC().AddDate(0, 0, -1000))
+ rows := []stats.DailyBucket{
+ {DateUnix: outside, PerTool: map[string]int64{"cc": 99}},
+ }
+
+ got := stats.FillDaily(window, rows)
+ if len(got) != 3 {
+ t.Fatalf("len=%d; want 3", len(got))
+ }
+ for _, b := range got {
+ if len(b.PerTool) != 0 {
+ t.Errorf("slot %d should be empty; got %v", b.DateUnix, b.PerTool)
+ }
+ }
+}
+
+func TestFillDaily_PreservesWindowOrder(t *testing.T) {
+ now := time.Date(2025, 6, 15, 12, 0, 0, 0, time.UTC).Unix()
+ window := stats.DailyWindow(now, 6)
+ got := stats.FillDaily(window, nil)
+ for i := 1; i < len(got); i++ {
+ if got[i].DateUnix <= got[i-1].DateUnix {
+ t.Errorf("FillDaily order broken at index %d: %d <= %d", i, got[i].DateUnix, got[i-1].DateUnix)
+ }
+ }
+}
+
+// ────────────────────────────────────────────────────────────
+// HeatmapWindow
+// ────────────────────────────────────────────────────────────
+
+func TestHeatmapWindow_Length(t *testing.T) {
+ now := time.Date(2025, 6, 15, 10, 0, 0, 0, time.UTC).Unix()
+ got := stats.HeatmapWindow(now)
+ if len(got) != 84 {
+ t.Errorf("HeatmapWindow: len=%d; want 84", len(got))
+ }
+}
+
+func TestHeatmapWindow_LastEntryIsToday(t *testing.T) {
+ ref := time.Date(2025, 6, 15, 22, 0, 0, 0, time.UTC)
+ got := stats.HeatmapWindow(ref.Unix())
+ wantToday := utcMidnight(ref)
+ last := got[len(got)-1]
+ if last != wantToday {
+ t.Errorf("HeatmapWindow last: got %d; want %d", last, wantToday)
+ }
+}
+
+func TestHeatmapWindow_OldestFirst(t *testing.T) {
+ now := time.Date(2025, 6, 15, 10, 0, 0, 0, time.UTC).Unix()
+ got := stats.HeatmapWindow(now)
+ for i := 1; i < len(got); i++ {
+ if got[i] <= got[i-1] {
+ t.Errorf("HeatmapWindow not ascending at index %d: %d <= %d", i, got[i], got[i-1])
+ }
+ }
+}
+
+func TestHeatmapWindow_FixedAt84Cells(t *testing.T) {
+ // Different "now" values — always exactly 84 cells.
+ nows := []time.Time{
+ time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC),
+ time.Date(2025, 12, 31, 23, 59, 59, 0, time.UTC),
+ time.Date(2024, 2, 29, 12, 0, 0, 0, time.UTC), // leap year
+ }
+ for _, n := range nows {
+ got := stats.HeatmapWindow(n.Unix())
+ if len(got) != 84 {
+ t.Errorf("HeatmapWindow(%v): len=%d; want 84", n, len(got))
+ }
+ }
+}
+
+// ────────────────────────────────────────────────────────────
+// HourWindow
+// ────────────────────────────────────────────────────────────
+
+func TestHourWindow_Length(t *testing.T) {
+ got := stats.HourWindow()
+ if len(got) != 24 {
+ t.Errorf("HourWindow: len=%d; want 24", len(got))
+ }
+}
+
+func TestHourWindow_AllZero(t *testing.T) {
+ got := stats.HourWindow()
+ for _, b := range got {
+ if b.Count != 0 {
+ t.Errorf("HourWindow[%d].Count = %d; want 0", b.Hour, b.Count)
+ }
+ }
+}
+
+func TestHourWindow_HoursAreContiguous(t *testing.T) {
+ got := stats.HourWindow()
+ for i, b := range got {
+ if b.Hour != i {
+ t.Errorf("HourWindow[%d].Hour = %d; want %d", i, b.Hour, i)
+ }
+ }
+}
A internal/domain/stats/handler.go => internal/domain/stats/handler.go +112 -0
@@ 0,0 1,112 @@
+package stats
+
+import (
+ "context"
+ "log/slog"
+ "net/http"
+ "strings"
+ "time"
+
+ "github.com/go-chi/chi/v5"
+ "go.bigb.es/auxilia/culpa"
+ "go.bigb.es/auxilia/scribe"
+
+ "sourcecraft.dev/bigbes/lethe/internal/domain/session"
+ "sourcecraft.dev/bigbes/lethe/internal/pkg/apierror"
+ "sourcecraft.dev/bigbes/lethe/internal/pkg/httputil"
+ "sourcecraft.dev/bigbes/lethe/internal/server/auth"
+)
+
+// allOwnersSentinel is the ?owner=* value that an admin uses to read across
+// all owners. Identical to the session and project handler sentinels.
+const allOwnersSentinel = "*"
+
+// validRanges is the set of accepted ?range= values.
+var validRanges = map[string]int{
+ "7d": 7,
+ "30d": 30,
+ "90d": 90,
+}
+
+// Handler is the steward-managed HTTP boundary for the stats aggregation API.
+type Handler struct {
+ Repo *Repository `inject:""`
+}
+
+// Init satisfies the steward Initer contract.
+func (h *Handler) Init(_ context.Context) error { return nil }
+
+// Mount registers the single read route under r. Server.Init mounts this
+// inside the /api/v1 group, so the effective path is /api/v1/stats.
+func (h *Handler) Mount(r chi.Router) {
+ r.Get("/stats", h.List)
+}
+
+// List handles GET /stats. Reads ?range=7d|30d|90d|all (defaults to 30d),
+// resolves the owner scope, and returns a Stats bundle.
+func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
+ scope, err := h.resolveScope(r)
+ if err != nil {
+ apierror.Render(w, r, err)
+ return
+ }
+
+ now := time.Now().Unix()
+ f := Filter{Owner: scope, Now: now}
+
+ rangeParam := r.URL.Query().Get("range")
+ switch {
+ case rangeParam == "" || rangeParam == "30d":
+ // default: 30 days
+ since := now - 30*86400
+ f.RangeSince = &since
+ case rangeParam == "all":
+ f.RangeSince = nil
+ default:
+ days, ok := validRanges[rangeParam]
+ if !ok {
+ apierror.Render(w, r, culpa.WithCode(
+ culpa.WithPublic(
+ culpa.Errorf("unrecognized range %q", rangeParam),
+ `?range= must be one of: 7d, 30d, 90d, all`,
+ ),
+ "INVALID",
+ ))
+ return
+ }
+ since := now - int64(days)*86400
+ f.RangeSince = &since
+ }
+
+ result, err := h.Repo.Stats(r.Context(), f)
+ if err != nil {
+ apierror.Render(w, r, err)
+ return
+ }
+
+ if writeErr := httputil.WriteJSON(w, http.StatusOK, result); writeErr != nil {
+ slog.Default().ErrorContext(r.Context(), "write stats response", scribe.Err(writeErr))
+ }
+}
+
+// resolveScope reads the authenticated identity off the context and the
+// optional ?owner= query parameter, then returns the appropriate
+// session.OwnerScope. Non-admin requests with ?owner= set are 403.
+func (h *Handler) resolveScope(r *http.Request) (session.OwnerScope, error) {
+ id := auth.MustIdentity(r.Context())
+ param := r.URL.Query().Get("owner")
+ if param == "" {
+ return session.OwnerScope{User: id.User}, nil
+ }
+ if !id.IsAdmin {
+ return session.OwnerScope{}, culpa.WithCode(
+ culpa.WithPublic(culpa.New("?owner= is admin-only"), "?owner= is admin-only"),
+ "FORBIDDEN",
+ )
+ }
+ if param == allOwnersSentinel {
+ return session.OwnerScope{User: id.User, AllOwners: true}, nil
+ }
+ owner := strings.ToLower(param)
+ return session.OwnerScope{User: id.User, SpecificOwner: &owner}, nil
+}
A internal/domain/stats/handler_test.go => internal/domain/stats/handler_test.go +197 -0
@@ 0,0 1,197 @@
+package stats_test
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/go-chi/chi/v5"
+
+ "sourcecraft.dev/bigbes/lethe/internal/domain/stats"
+ "sourcecraft.dev/bigbes/lethe/internal/server/auth"
+)
+
+// fakeAuthMiddleware injects a fixed Identity onto the request context.
+func fakeAuthMiddleware(id auth.Identity) func(http.Handler) http.Handler {
+ return func(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ ctx := auth.WithIdentity(r.Context(), id)
+ next.ServeHTTP(w, r.WithContext(ctx))
+ })
+ }
+}
+
+// newStatsHandler wires a Handler against a fresh in-memory database.
+func newStatsHandler(t *testing.T) *stats.Handler {
+ t.Helper()
+ repo, _ := newStatsRepo(t)
+ h := &stats.Handler{Repo: repo}
+ if err := h.Init(t.Context()); err != nil {
+ t.Fatalf("stats.Handler.Init: %v", err)
+ }
+ return h
+}
+
+// mountWithIdentity builds a chi router with the fake auth middleware and the
+// stats handler mounted under /api/v1.
+func mountWithIdentity(h *stats.Handler, id auth.Identity) http.Handler {
+ r := chi.NewRouter()
+ r.Route("/api/v1", func(r chi.Router) {
+ r.Use(fakeAuthMiddleware(id))
+ h.Mount(r)
+ })
+ return r
+}
+
+// problemBody captures the RFC 7807 fields tests assert on.
+type problemBody struct {
+ Status int `json:"status"`
+ Code string `json:"code"`
+}
+
+func doStats(t *testing.T, router http.Handler, query string) (*httptest.ResponseRecorder, *stats.Stats) {
+ t.Helper()
+ req := httptest.NewRequest(http.MethodGet, "/api/v1/stats"+query, nil)
+ rec := httptest.NewRecorder()
+ router.ServeHTTP(rec, req)
+ if rec.Code == http.StatusOK {
+ var body stats.Stats
+ if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
+ t.Fatalf("unmarshal stats body: %v (body=%s)", err, rec.Body.String())
+ }
+ return rec, &body
+ }
+ return rec, nil
+}
+
+func TestStatsHandler_Range7d_Returns200(t *testing.T) {
+ h := newStatsHandler(t)
+ router := mountWithIdentity(h, auth.Identity{User: "alice"})
+
+ rec, body := doStats(t, router, "?range=7d")
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
+ }
+ if body == nil {
+ t.Fatal("body is nil")
+ }
+ // Daily window for 7d = 8 slots.
+ if len(body.Daily) != 8 {
+ t.Errorf("Daily len=%d; want 8", len(body.Daily))
+ }
+ if len(body.Heatmap) != 84 {
+ t.Errorf("Heatmap len=%d; want 84", len(body.Heatmap))
+ }
+ if len(body.HourOfDay) != 24 {
+ t.Errorf("HourOfDay len=%d; want 24", len(body.HourOfDay))
+ }
+}
+
+func TestStatsHandler_Range30d_Returns200(t *testing.T) {
+ h := newStatsHandler(t)
+ router := mountWithIdentity(h, auth.Identity{User: "alice"})
+
+ rec, body := doStats(t, router, "?range=30d")
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
+ }
+ // Daily window for 30d = 31 slots.
+ if len(body.Daily) != 31 {
+ t.Errorf("Daily len=%d; want 31", len(body.Daily))
+ }
+}
+
+func TestStatsHandler_Range90d_Returns200(t *testing.T) {
+ h := newStatsHandler(t)
+ router := mountWithIdentity(h, auth.Identity{User: "alice"})
+
+ rec, body := doStats(t, router, "?range=90d")
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
+ }
+ // Daily window for 90d = 91 slots.
+ if len(body.Daily) != 91 {
+ t.Errorf("Daily len=%d; want 91", len(body.Daily))
+ }
+}
+
+func TestStatsHandler_RangeAll_Returns200(t *testing.T) {
+ h := newStatsHandler(t)
+ router := mountWithIdentity(h, auth.Identity{User: "alice"})
+
+ rec, _ := doStats(t, router, "?range=all")
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
+ }
+}
+
+func TestStatsHandler_MissingRange_Defaults30d(t *testing.T) {
+ h := newStatsHandler(t)
+ router := mountWithIdentity(h, auth.Identity{User: "alice"})
+
+ rec, body := doStats(t, router, "")
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
+ }
+ // Default 30d window = 31 slots.
+ if len(body.Daily) != 31 {
+ t.Errorf("default range Daily len=%d; want 31", len(body.Daily))
+ }
+}
+
+func TestStatsHandler_BadRange_Returns400(t *testing.T) {
+ h := newStatsHandler(t)
+ router := mountWithIdentity(h, auth.Identity{User: "alice"})
+
+ for _, bad := range []string{"?range=foo", "?range=7", "?range=7days", "?range=0d"} {
+ rec, _ := doStats(t, router, bad)
+ if rec.Code != http.StatusBadRequest {
+ t.Fatalf("range %q: status=%d; want 400; body=%s", bad, rec.Code, rec.Body.String())
+ }
+ var p problemBody
+ _ = json.Unmarshal(rec.Body.Bytes(), &p)
+ if p.Code != "INVALID" {
+ t.Fatalf("range %q: code=%q; want INVALID", bad, p.Code)
+ }
+ }
+}
+
+func TestStatsHandler_NonAdminOwnerParam_Returns403(t *testing.T) {
+ h := newStatsHandler(t)
+ router := mountWithIdentity(h, auth.Identity{User: "alice", IsAdmin: false})
+
+ for _, q := range []string{"?owner=alice", "?owner=bob", "?owner=*"} {
+ req := httptest.NewRequest(http.MethodGet, "/api/v1/stats"+q, nil)
+ rec := httptest.NewRecorder()
+ router.ServeHTTP(rec, req)
+ if rec.Code != http.StatusForbidden {
+ t.Fatalf("query %q: status=%d; want 403; body=%s", q, rec.Code, rec.Body.String())
+ }
+ var p problemBody
+ _ = json.Unmarshal(rec.Body.Bytes(), &p)
+ if p.Code != "FORBIDDEN" {
+ t.Fatalf("query %q: code=%q; want FORBIDDEN", q, p.Code)
+ }
+ }
+}
+
+func TestStatsHandler_Mount_RegistersRoute(t *testing.T) {
+ h := newStatsHandler(t)
+ router := chi.NewRouter()
+ router.Route("/api/v1", func(r chi.Router) {
+ r.Use(fakeAuthMiddleware(auth.Identity{User: "alice"}))
+ h.Mount(r)
+ })
+
+ found := false
+ _ = chi.Walk(router, func(method, route string, _ http.Handler, _ ...func(http.Handler) http.Handler) error {
+ if method == http.MethodGet && route == "/api/v1/stats" {
+ found = true
+ }
+ return nil
+ })
+ if !found {
+ t.Error("expected GET /api/v1/stats registered; not found")
+ }
+}
A internal/domain/stats/repository.go => internal/domain/stats/repository.go +386 -0
@@ 0,0 1,386 @@
+package stats
+
+import (
+ "context"
+ "strings"
+
+ "go.bigb.es/auxilia/culpa"
+
+ "sourcecraft.dev/bigbes/lethe/internal/domain/session"
+ "sourcecraft.dev/bigbes/lethe/internal/platform/database"
+)
+
+// ToolRollup holds aggregate stats for one tool across the requested range.
+// DailySparkline has one entry per daily-window bucket (oldest first),
+// containing the turn count for that bucket.
+type ToolRollup struct {
+ Tool string `json:"tool"`
+ Sessions int64 `json:"sessions"`
+ Turns int64 `json:"turns"`
+ TokensIn int64 `json:"tokens_in"`
+ TokensOut int64 `json:"tokens_out"`
+ DailySparkline []int64 `json:"daily_sparkline"`
+}
+
+// DailyBucket is one day in the daily time-series. PerTool maps tool name to
+// turn count for that day.
+type DailyBucket struct {
+ DateUnix int64 `json:"date_unix"`
+ PerTool map[string]int64 `json:"per_tool"`
+}
+
+// HeatmapCell is one day in the 84-cell activity heatmap.
+type HeatmapCell struct {
+ DateUnix int64 `json:"date_unix"`
+ Count int64 `json:"count"`
+}
+
+// CwdRow is one entry in the top-cwd ranking.
+type CwdRow struct {
+ Cwd string `json:"cwd"`
+ Count int64 `json:"count"`
+}
+
+// HourBucket is one hour-of-day bucket (0..23) with its turn count.
+type HourBucket struct {
+ Hour int `json:"hour"`
+ Count int64 `json:"count"`
+}
+
+// HostRow is one entry in the host breakdown.
+type HostRow struct {
+ Host string `json:"host"`
+ Count int64 `json:"count"`
+}
+
+// Stats is the single response bundling all six aggregations.
+type Stats struct {
+ PerTool []ToolRollup `json:"per_tool"`
+ Daily []DailyBucket `json:"daily"`
+ Heatmap []HeatmapCell `json:"heatmap"`
+ TopCwd []CwdRow `json:"top_cwd"`
+ HourOfDay []HourBucket `json:"hour_of_day"`
+ HostSplit []HostRow `json:"host_split"`
+}
+
+// Filter controls which rows Stats includes.
+// - RangeSince=nil means all time (no lower timestamp bound).
+// - Now is injected for deterministic window calculations in tests; in
+// production the handler injects time.Now().Unix().
+type Filter struct {
+ Owner session.OwnerScope
+ RangeSince *int64 // nil = all
+ Now int64 // injected for determinism
+}
+
+// Repository is the SQL steward for the stats aggregation API.
+type Repository struct {
+ Database *database.Database `inject:""`
+}
+
+// Init satisfies the steward Initer contract.
+func (r *Repository) Init(_ context.Context) error { return nil }
+
+// ownerClause returns the SQL fragment and arg (if any) for the owner scope,
+// joining the given table alias. prefix is the "AND " string prepended when
+// used in a WHERE continuation; connector is e.g. "WHERE " for the first clause.
+// Returns an empty string and nil arg when AllOwners is set.
+func ownerClause(alias string, owner session.OwnerScope) (clause string, arg any, hasArg bool) {
+ switch {
+ case owner.AllOwners:
+ return "", nil, false
+ case owner.SpecificOwner != nil:
+ return alias + ".owner = ?", *owner.SpecificOwner, true
+ default:
+ return alias + ".owner = ?", owner.User, true
+ }
+}
+
+// appendOwnerClause appends the owner WHERE/AND clause to the builder.
+// sep should be " WHERE " for the first clause, " AND " for subsequent ones.
+func appendOwnerClause(sb *strings.Builder, args *[]any, sep, alias string, owner session.OwnerScope) {
+ clause, arg, hasArg := ownerClause(alias, owner)
+ if clause == "" {
+ return
+ }
+ sb.WriteString(sep)
+ sb.WriteString(clause)
+ if hasArg {
+ *args = append(*args, arg)
+ }
+}
+
+// sparklineCap is the maximum number of daily buckets in a ToolRollup sparkline.
+const sparklineCap = 60
+
+// Stats runs six queries against the turns/sessions tables and returns a
+// fully-populated Stats struct. All returned slices are non-nil; missing
+// time-window slots are filled with zero values.
+func (r *Repository) Stats(ctx context.Context, f Filter) (*Stats, error) {
+ // Determine daily window size from range.
+ // dailyDays is the raw request days (may exceed sparklineCap).
+ // For all-time (RangeSince=nil) we use sparklineCap so Daily has a
+ // bounded, useful window.
+ var dailyDays int
+ if f.RangeSince != nil {
+ dailyDays = int((f.Now - *f.RangeSince) / 86400)
+ } else {
+ dailyDays = sparklineCap
+ }
+
+ // sparklineDays is the sparkline length, capped at sparklineCap.
+ sparklineDays := dailyDays
+ if sparklineDays > sparklineCap {
+ sparklineDays = sparklineCap
+ }
+
+ // ── 1. PerTool rollup ───────────────────────────────────────────────────
+ type toolRow struct {
+ Tool string `db:"tool"`
+ Sessions int64 `db:"sessions"`
+ Turns int64 `db:"turns"`
+ TokensIn int64 `db:"tokens_in"`
+ TokensOut int64 `db:"tokens_out"`
+ }
+ var toolRows []toolRow
+ {
+ var sb strings.Builder
+ var args []any
+ sb.WriteString(`
+SELECT
+ t.tool,
+ COUNT(DISTINCT s.owner || '|' || s.tool || '|' || s.host || '|' || s.session_id) AS sessions,
+ COUNT(t.turn_id) AS turns,
+ COALESCE(SUM(t.tokens_in), 0) AS tokens_in,
+ COALESCE(SUM(t.tokens_out), 0) AS tokens_out
+FROM turns t
+JOIN sessions s ON s.owner = t.owner AND s.tool = t.tool AND s.host = t.host AND s.session_id = t.session_id
+WHERE 1=1`)
+ appendOwnerClause(&sb, &args, " AND ", "t", f.Owner)
+ if f.RangeSince != nil {
+ sb.WriteString(" AND t.timestamp >= ?")
+ args = append(args, *f.RangeSince)
+ }
+ sb.WriteString(" GROUP BY t.tool ORDER BY turns DESC")
+ toolRows = make([]toolRow, 0)
+ if err := r.Database.DB.SelectContext(ctx, &toolRows, sb.String(), args...); err != nil {
+ return nil, culpa.WithCode(culpa.Wrap(err, "stats per_tool"), "DB_QUERY")
+ }
+ }
+
+ // ── 2. Daily time-series ────────────────────────────────────────────────
+ // The daily window spans the full requested range (not capped at 60).
+ dailyWindow := DailyWindow(f.Now, dailyDays)
+
+ var dailyDBRows []dailyBucketDBRow
+ {
+ var sb strings.Builder
+ var args []any
+ // Bucket by UTC midnight: (timestamp / 86400) * 86400
+ sb.WriteString(`
+SELECT
+ (t.timestamp / 86400) * 86400 AS date_unix,
+ t.tool,
+ COUNT(t.turn_id) AS turns
+FROM turns t
+JOIN sessions s ON s.owner = t.owner AND s.tool = t.tool AND s.host = t.host AND s.session_id = t.session_id
+WHERE 1=1`)
+ appendOwnerClause(&sb, &args, " AND ", "t", f.Owner)
+ if f.RangeSince != nil {
+ sb.WriteString(" AND t.timestamp >= ?")
+ args = append(args, *f.RangeSince)
+ }
+ sb.WriteString(" GROUP BY date_unix, t.tool ORDER BY date_unix ASC")
+ dailyDBRows = make([]dailyBucketDBRow, 0)
+ if err := r.Database.DB.SelectContext(ctx, &dailyDBRows, sb.String(), args...); err != nil {
+ return nil, culpa.WithCode(culpa.Wrap(err, "stats daily"), "DB_QUERY")
+ }
+ }
+
+ // Convert DB rows to DailyBucket slice (sparse), then fill the window.
+ sparseDaily := buildSparseDaily(dailyDBRows)
+ filledDaily := FillDaily(dailyWindow, sparseDaily)
+
+ // ── 3. Heatmap (always 84 cells regardless of range) ───────────────────
+ heatmapWindow := HeatmapWindow(f.Now)
+ heatmapStart := heatmapWindow[0]
+
+ type heatmapDBRow struct {
+ DateUnix int64 `db:"date_unix"`
+ Count int64 `db:"count"`
+ }
+ var heatmapDBRows []heatmapDBRow
+ {
+ var sb strings.Builder
+ var args []any
+ sb.WriteString(`
+SELECT
+ (t.timestamp / 86400) * 86400 AS date_unix,
+ COUNT(t.turn_id) AS count
+FROM turns t
+JOIN sessions s ON s.owner = t.owner AND s.tool = t.tool AND s.host = t.host AND s.session_id = t.session_id
+WHERE t.timestamp >= ?`)
+ args = append(args, heatmapStart)
+ appendOwnerClause(&sb, &args, " AND ", "t", f.Owner)
+ sb.WriteString(" GROUP BY date_unix ORDER BY date_unix ASC")
+ heatmapDBRows = make([]heatmapDBRow, 0)
+ if err := r.Database.DB.SelectContext(ctx, &heatmapDBRows, sb.String(), args...); err != nil {
+ return nil, culpa.WithCode(culpa.Wrap(err, "stats heatmap"), "DB_QUERY")
+ }
+ }
+
+ // Build heatmap: left-join heatmap DB rows onto the window.
+ heatmapByDate := make(map[int64]int64, len(heatmapDBRows))
+ for _, row := range heatmapDBRows {
+ heatmapByDate[row.DateUnix] = row.Count
+ }
+ heatmap := make([]HeatmapCell, len(heatmapWindow))
+ for i, ts := range heatmapWindow {
+ heatmap[i] = HeatmapCell{DateUnix: ts, Count: heatmapByDate[ts]}
+ }
+
+ // ── 4. TopCwd (capped at 20) ────────────────────────────────────────────
+ var topCwd []CwdRow
+ {
+ var sb strings.Builder
+ var args []any
+ sb.WriteString(`
+SELECT
+ s.working_dir AS cwd,
+ COUNT(t.turn_id) AS count
+FROM turns t
+JOIN sessions s ON s.owner = t.owner AND s.tool = t.tool AND s.host = t.host AND s.session_id = t.session_id
+WHERE s.working_dir IS NOT NULL`)
+ appendOwnerClause(&sb, &args, " AND ", "t", f.Owner)
+ if f.RangeSince != nil {
+ sb.WriteString(" AND t.timestamp >= ?")
+ args = append(args, *f.RangeSince)
+ }
+ sb.WriteString(" GROUP BY s.working_dir ORDER BY count DESC LIMIT 20")
+ topCwd = make([]CwdRow, 0)
+ if err := r.Database.DB.SelectContext(ctx, &topCwd, sb.String(), args...); err != nil {
+ return nil, culpa.WithCode(culpa.Wrap(err, "stats top_cwd"), "DB_QUERY")
+ }
+ }
+
+ // ── 5. HourOfDay ────────────────────────────────────────────────────────
+ type hourDBRow struct {
+ Hour int `db:"hour"`
+ Count int64 `db:"count"`
+ }
+ var hourDBRows []hourDBRow
+ {
+ var sb strings.Builder
+ var args []any
+ // SQLite: strftime('%H', ts, 'unixepoch') returns the UTC hour as "00"–"23".
+ // CAST to INTEGER gives 0..23.
+ sb.WriteString(`
+SELECT
+ CAST(strftime('%H', t.timestamp, 'unixepoch') AS INTEGER) AS hour,
+ COUNT(t.turn_id) AS count
+FROM turns t
+JOIN sessions s ON s.owner = t.owner AND s.tool = t.tool AND s.host = t.host AND s.session_id = t.session_id
+WHERE 1=1`)
+ appendOwnerClause(&sb, &args, " AND ", "t", f.Owner)
+ if f.RangeSince != nil {
+ sb.WriteString(" AND t.timestamp >= ?")
+ args = append(args, *f.RangeSince)
+ }
+ sb.WriteString(" GROUP BY hour ORDER BY hour ASC")
+ hourDBRows = make([]hourDBRow, 0)
+ if err := r.Database.DB.SelectContext(ctx, &hourDBRows, sb.String(), args...); err != nil {
+ return nil, culpa.WithCode(culpa.Wrap(err, "stats hour_of_day"), "DB_QUERY")
+ }
+ }
+ // Fill 24-slot window.
+ hourSlots := HourWindow()
+ for _, row := range hourDBRows {
+ if row.Hour >= 0 && row.Hour < 24 {
+ hourSlots[row.Hour].Count = row.Count
+ }
+ }
+
+ // ── 6. HostSplit ─────────────────────────────────────────────────────────
+ var hostSplit []HostRow
+ {
+ var sb strings.Builder
+ var args []any
+ sb.WriteString(`
+SELECT
+ s.host,
+ COUNT(t.turn_id) AS count
+FROM turns t
+JOIN sessions s ON s.owner = t.owner AND s.tool = t.tool AND s.host = t.host AND s.session_id = t.session_id
+WHERE 1=1`)
+ appendOwnerClause(&sb, &args, " AND ", "t", f.Owner)
+ if f.RangeSince != nil {
+ sb.WriteString(" AND t.timestamp >= ?")
+ args = append(args, *f.RangeSince)
+ }
+ sb.WriteString(" GROUP BY s.host ORDER BY count DESC")
+ hostSplit = make([]HostRow, 0)
+ if err := r.Database.DB.SelectContext(ctx, &hostSplit, sb.String(), args...); err != nil {
+ return nil, culpa.WithCode(culpa.Wrap(err, "stats host_split"), "DB_QUERY")
+ }
+ }
+
+ // ── Assemble PerTool with sparklines ─────────────────────────────────────
+ // Sparklines are capped at sparklineCap buckets (most recent).
+ sparklineSlice := filledDaily
+ if len(filledDaily) > sparklineDays+1 {
+ sparklineSlice = filledDaily[len(filledDaily)-(sparklineDays+1):]
+ }
+ perToolOut := make([]ToolRollup, 0, len(toolRows))
+ for _, tr := range toolRows {
+ sparkline := make([]int64, len(sparklineSlice))
+ for i, b := range sparklineSlice {
+ sparkline[i] = b.PerTool[tr.Tool]
+ }
+ perToolOut = append(perToolOut, ToolRollup{
+ Tool: tr.Tool,
+ Sessions: tr.Sessions,
+ Turns: tr.Turns,
+ TokensIn: tr.TokensIn,
+ TokensOut: tr.TokensOut,
+ DailySparkline: sparkline,
+ })
+ }
+
+ return &Stats{
+ PerTool: perToolOut,
+ Daily: filledDaily,
+ Heatmap: heatmap,
+ TopCwd: topCwd,
+ HourOfDay: hourSlots,
+ HostSplit: hostSplit,
+ }, nil
+}
+
+// buildSparseDaily converts flat DB rows (date_unix, tool, turns) into
+// DailyBucket values. Rows are grouped by date_unix.
+func buildSparseDaily(rows []dailyBucketDBRow) []DailyBucket {
+ if len(rows) == 0 {
+ return []DailyBucket{}
+ }
+ // Group by date.
+ byDate := make(map[int64]map[string]int64)
+ for _, r := range rows {
+ if byDate[r.DateUnix] == nil {
+ byDate[r.DateUnix] = make(map[string]int64)
+ }
+ byDate[r.DateUnix][r.Tool] += r.Turns
+ }
+ out := make([]DailyBucket, 0, len(byDate))
+ for dateUnix, perTool := range byDate {
+ out = append(out, DailyBucket{DateUnix: dateUnix, PerTool: perTool})
+ }
+ return out
+}
+
+// dailyBucketDBRow is the local scan target for the daily query.
+type dailyBucketDBRow struct {
+ DateUnix int64 `db:"date_unix"`
+ Tool string `db:"tool"`
+ Turns int64 `db:"turns"`
+}
+
A internal/domain/stats/repository_test.go => internal/domain/stats/repository_test.go +346 -0
@@ 0,0 1,346 @@
+package stats_test
+
+import (
+ "context"
+ "testing"
+ "time"
+
+ "github.com/jmoiron/sqlx"
+ _ "modernc.org/sqlite"
+
+ "sourcecraft.dev/bigbes/lethe/internal/config"
+ "sourcecraft.dev/bigbes/lethe/internal/domain/session"
+ "sourcecraft.dev/bigbes/lethe/internal/domain/stats"
+ "sourcecraft.dev/bigbes/lethe/internal/platform/database"
+)
+
+// ────────────────────────────────────────────────────────────
+// DB helpers (local to stats_test package)
+// ────────────────────────────────────────────────────────────
+
+func newTestDatabase(t *testing.T) *database.Database {
+ t.Helper()
+ d := &database.Database{
+ Cfg: config.DatabaseConfig{
+ Path: ":memory:",
+ BusyTimeout: 5 * time.Second,
+ },
+ }
+ if err := d.Init(context.Background()); err != nil {
+ t.Fatalf("database.Init: %v", err)
+ }
+ t.Cleanup(func() { _ = d.Destroy(context.Background()) })
+ return d
+}
+
+func newStatsRepo(t *testing.T) (*stats.Repository, *sqlx.DB) {
+ t.Helper()
+ d := newTestDatabase(t)
+ repo := &stats.Repository{Database: d}
+ if err := repo.Init(context.Background()); err != nil {
+ t.Fatalf("stats.Repository.Init: %v", err)
+ }
+ return repo, d.DB
+}
+
+// seedSession inserts a minimal session row (NULL working_dir).
+func seedSession(t *testing.T, db *sqlx.DB, owner, tool, host, sid string, startedAt, endedAt int64) {
+ t.Helper()
+ _, err := db.Exec(`
+ INSERT INTO sessions (owner, tool, host, session_id, started_at, ended_at, working_dir, source_file, metadata)
+ VALUES (?, ?, ?, ?, ?, ?, NULL, ?, NULL)`,
+ owner, tool, host, sid, startedAt, endedAt, "/tmp/x.jsonl",
+ )
+ if err != nil {
+ t.Fatalf("seedSession %s/%s/%s/%s: %v", owner, tool, host, sid, err)
+ }
+}
+
+// seedSessionCwd inserts a session row with a non-NULL working_dir.
+func seedSessionCwd(t *testing.T, db *sqlx.DB, owner, tool, host, sid string, startedAt, endedAt int64, cwd string) {
+ t.Helper()
+ _, err := db.Exec(`
+ INSERT INTO sessions (owner, tool, host, session_id, started_at, ended_at, working_dir, source_file, metadata)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL)`,
+ owner, tool, host, sid, startedAt, endedAt, cwd, "/tmp/x.jsonl",
+ )
+ if err != nil {
+ t.Fatalf("seedSessionCwd %s/%s/%s/%s cwd=%s: %v", owner, tool, host, sid, cwd, err)
+ }
+}
+
+// seedTurn inserts a turn with NULL optional columns.
+func seedTurn(t *testing.T, db *sqlx.DB, owner, tool, host, sid, tid string, seq, ts int64) {
+ t.Helper()
+ _, err := db.Exec(`
+ INSERT INTO turns (owner, tool, host, session_id, turn_id, seq, role, timestamp, content,
+ model, tokens_in, tokens_out, cost_usd, tool_calls, metadata)
+ VALUES (?, ?, ?, ?, ?, ?, 'user', ?, 'hello', NULL, NULL, NULL, NULL, NULL, NULL)`,
+ owner, tool, host, sid, tid, seq, ts,
+ )
+ if err != nil {
+ t.Fatalf("seedTurn %s/%s: %v", sid, tid, err)
+ }
+}
+
+// seedTurnFull inserts a turn with tokens and model set.
+func seedTurnFull(t *testing.T, db *sqlx.DB, owner, tool, host, sid, tid string, seq, ts, tokensIn, tokensOut int64) {
+ t.Helper()
+ _, err := db.Exec(`
+ INSERT INTO turns (owner, tool, host, session_id, turn_id, seq, role, timestamp, content,
+ model, tokens_in, tokens_out, cost_usd, tool_calls, metadata)
+ VALUES (?, ?, ?, ?, ?, ?, 'assistant', ?, 'response', 'gpt-4', ?, ?, NULL, NULL, NULL)`,
+ owner, tool, host, sid, tid, seq, ts, tokensIn, tokensOut,
+ )
+ if err != nil {
+ t.Fatalf("seedTurnFull %s/%s: %v", sid, tid, err)
+ }
+}
+
+// ────────────────────────────────────────────────────────────
+// Tests
+// ────────────────────────────────────────────────────────────
+
+func TestStats_EmptyDB(t *testing.T) {
+ repo, _ := newStatsRepo(t)
+ now := time.Date(2025, 6, 15, 12, 0, 0, 0, time.UTC).Unix()
+ days := int64(7)
+ since := now - days*86400
+
+ result, err := repo.Stats(context.Background(), stats.Filter{
+ Owner: session.OwnerScope{User: "alice"},
+ RangeSince: &since,
+ Now: now,
+ })
+ if err != nil {
+ t.Fatalf("Stats: %v", err)
+ }
+ if result == nil {
+ t.Fatal("expected non-nil result")
+ }
+
+ // All slices must be non-nil (not nil — JSON must encode as []).
+ if result.PerTool == nil {
+ t.Error("PerTool is nil; want non-nil empty slice")
+ }
+ if result.Daily == nil {
+ t.Error("Daily is nil; want non-nil empty slice")
+ }
+ if result.Heatmap == nil {
+ t.Error("Heatmap is nil; want non-nil empty slice")
+ }
+ if result.TopCwd == nil {
+ t.Error("TopCwd is nil; want non-nil empty slice")
+ }
+ if result.HourOfDay == nil {
+ t.Error("HourOfDay is nil; want non-nil slice")
+ }
+ if result.HostSplit == nil {
+ t.Error("HostSplit is nil; want non-nil empty slice")
+ }
+}
+
+func TestStats_OneTool_ThreeTurns_TwoDays(t *testing.T) {
+ repo, db := newStatsRepo(t)
+
+ // Two sessions on two different days.
+ // Day 1 (2025-06-13 UTC midnight = 1749772800): session s1 with 1 turn.
+ // Day 2 (2025-06-14 UTC midnight = 1749859200): session s2 with 2 turns.
+ day1 := time.Date(2025, 6, 13, 12, 0, 0, 0, time.UTC).Unix()
+ day2 := time.Date(2025, 6, 14, 12, 0, 0, 0, time.UTC).Unix()
+ now := time.Date(2025, 6, 15, 12, 0, 0, 0, time.UTC).Unix()
+
+ seedSession(t, db, "alice", "cc", "host1", "s1", day1, day1+60)
+ seedSession(t, db, "alice", "cc", "host1", "s2", day2, day2+60)
+
+ seedTurnFull(t, db, "alice", "cc", "host1", "s1", "t1", 1, day1+10, 10, 20)
+ seedTurnFull(t, db, "alice", "cc", "host1", "s2", "t2", 1, day2+10, 30, 40)
+ seedTurnFull(t, db, "alice", "cc", "host1", "s2", "t3", 2, day2+20, 50, 60)
+
+ since := now - 7*86400
+ result, err := repo.Stats(context.Background(), stats.Filter{
+ Owner: session.OwnerScope{User: "alice"},
+ RangeSince: &since,
+ Now: now,
+ })
+ if err != nil {
+ t.Fatalf("Stats: %v", err)
+ }
+
+ // PerTool: exactly one tool "cc" with 3 turns.
+ if len(result.PerTool) != 1 {
+ t.Fatalf("PerTool len=%d; want 1", len(result.PerTool))
+ }
+ tr := result.PerTool[0]
+ if tr.Tool != "cc" {
+ t.Errorf("PerTool[0].Tool=%q; want cc", tr.Tool)
+ }
+ if tr.Turns != 3 {
+ t.Errorf("PerTool[0].Turns=%d; want 3", tr.Turns)
+ }
+ if tr.TokensIn != 90 {
+ t.Errorf("PerTool[0].TokensIn=%d; want 90", tr.TokensIn)
+ }
+ if tr.TokensOut != 120 {
+ t.Errorf("PerTool[0].TokensOut=%d; want 120", tr.TokensOut)
+ }
+
+ // Daily: window is 8 entries (days=7 → days+1). The two days with turns
+ // must be populated; others have empty PerTool maps.
+ if len(result.Daily) != 8 {
+ t.Fatalf("Daily len=%d; want 8", len(result.Daily))
+ }
+ popCount := 0
+ for _, b := range result.Daily {
+ if len(b.PerTool) > 0 {
+ popCount++
+ }
+ }
+ if popCount != 2 {
+ t.Errorf("Daily populated slots=%d; want 2", popCount)
+ }
+}
+
+func TestStats_HeatmapAlways84Cells(t *testing.T) {
+ repo, db := newStatsRepo(t)
+
+ now := time.Date(2025, 6, 15, 12, 0, 0, 0, time.UTC).Unix()
+
+ // Seed some data 100 days ago (outside any 7d/30d/90d range, inside 84-week window).
+ seedSession(t, db, "alice", "cc", "host1", "s1", now-100*86400, now-100*86400+60)
+ seedTurn(t, db, "alice", "cc", "host1", "s1", "t1", 1, now-100*86400+10)
+
+ since := now - 7*86400
+ result, err := repo.Stats(context.Background(), stats.Filter{
+ Owner: session.OwnerScope{User: "alice"},
+ RangeSince: &since,
+ Now: now,
+ })
+ if err != nil {
+ t.Fatalf("Stats: %v", err)
+ }
+ if len(result.Heatmap) != 84 {
+ t.Errorf("Heatmap len=%d; want 84", len(result.Heatmap))
+ }
+}
+
+func TestStats_TopCwdCappedAt20(t *testing.T) {
+ repo, db := newStatsRepo(t)
+
+ now := time.Date(2025, 6, 15, 12, 0, 0, 0, time.UTC).Unix()
+ since := now - 90*86400
+
+ // Insert 25 distinct cwds.
+ for i := 0; i < 25; i++ {
+ sid := "s" + string(rune('A'+i))
+ cwd := "/code/dir" + string(rune('A'+i))
+ seedSessionCwd(t, db, "alice", "cc", "host1", sid, now-int64(i)*86400, now-int64(i)*86400+60, cwd)
+ seedTurn(t, db, "alice", "cc", "host1", sid, "t"+sid, 1, now-int64(i)*86400+10)
+ }
+
+ result, err := repo.Stats(context.Background(), stats.Filter{
+ Owner: session.OwnerScope{User: "alice"},
+ RangeSince: &since,
+ Now: now,
+ })
+ if err != nil {
+ t.Fatalf("Stats: %v", err)
+ }
+ if len(result.TopCwd) > 20 {
+ t.Errorf("TopCwd len=%d; want <= 20", len(result.TopCwd))
+ }
+}
+
+func TestStats_OwnerScope_AllOwners(t *testing.T) {
+ repo, db := newStatsRepo(t)
+
+ now := time.Date(2025, 6, 15, 12, 0, 0, 0, time.UTC).Unix()
+ since := now - 30*86400
+
+ seedSession(t, db, "alice", "cc", "host1", "sA", now-86400, now)
+ seedSession(t, db, "bob", "gemini", "host2", "sB", now-86400, now)
+ seedTurnFull(t, db, "alice", "cc", "host1", "sA", "tA", 1, now-3600, 10, 20)
+ seedTurnFull(t, db, "bob", "gemini", "host2", "sB", "tB", 1, now-3600, 30, 40)
+
+ result, err := repo.Stats(context.Background(), stats.Filter{
+ Owner: session.OwnerScope{User: "admin", AllOwners: true},
+ RangeSince: &since,
+ Now: now,
+ })
+ if err != nil {
+ t.Fatalf("Stats AllOwners: %v", err)
+ }
+ if len(result.PerTool) != 2 {
+ t.Errorf("AllOwners PerTool len=%d; want 2 (cc + gemini)", len(result.PerTool))
+ }
+}
+
+func TestStats_OwnerScope_SpecificOwner(t *testing.T) {
+ repo, db := newStatsRepo(t)
+
+ now := time.Date(2025, 6, 15, 12, 0, 0, 0, time.UTC).Unix()
+ since := now - 30*86400
+
+ seedSession(t, db, "alice", "cc", "host1", "sA", now-86400, now)
+ seedSession(t, db, "bob", "gemini", "host2", "sB", now-86400, now)
+ seedTurnFull(t, db, "alice", "cc", "host1", "sA", "tA", 1, now-3600, 10, 20)
+ seedTurnFull(t, db, "bob", "gemini", "host2", "sB", "tB", 1, now-3600, 30, 40)
+
+ bob := "bob"
+ result, err := repo.Stats(context.Background(), stats.Filter{
+ Owner: session.OwnerScope{User: "admin", SpecificOwner: &bob},
+ RangeSince: &since,
+ Now: now,
+ })
+ if err != nil {
+ t.Fatalf("Stats SpecificOwner: %v", err)
+ }
+ if len(result.PerTool) != 1 || result.PerTool[0].Tool != "gemini" {
+ t.Errorf("SpecificOwner(bob) PerTool=%v; want [gemini]", result.PerTool)
+ }
+}
+
+func TestStats_OwnerScope_DefaultScopeIsolation(t *testing.T) {
+ repo, db := newStatsRepo(t)
+
+ now := time.Date(2025, 6, 15, 12, 0, 0, 0, time.UTC).Unix()
+ since := now - 30*86400
+
+ seedSession(t, db, "alice", "cc", "host1", "sA", now-86400, now)
+ seedSession(t, db, "bob", "gemini", "host2", "sB", now-86400, now)
+ seedTurnFull(t, db, "alice", "cc", "host1", "sA", "tA", 1, now-3600, 10, 20)
+ seedTurnFull(t, db, "bob", "gemini", "host2", "sB", "tB", 1, now-3600, 30, 40)
+
+ result, err := repo.Stats(context.Background(), stats.Filter{
+ Owner: session.OwnerScope{User: "alice"},
+ RangeSince: &since,
+ Now: now,
+ })
+ if err != nil {
+ t.Fatalf("Stats default scope: %v", err)
+ }
+ if len(result.PerTool) != 1 || result.PerTool[0].Tool != "cc" {
+ t.Errorf("default scope(alice) PerTool=%v; want [cc]", result.PerTool)
+ }
+}
+
+func TestStats_AllScopeNil_ReturnsAll(t *testing.T) {
+ repo, db := newStatsRepo(t)
+
+ now := time.Date(2025, 6, 15, 12, 0, 0, 0, time.UTC).Unix()
+
+ seedSession(t, db, "alice", "cc", "host1", "sA", now-86400, now)
+ seedTurnFull(t, db, "alice", "cc", "host1", "sA", "tA", 1, now-3600, 10, 20)
+
+ // RangeSince = nil means all time.
+ result, err := repo.Stats(context.Background(), stats.Filter{
+ Owner: session.OwnerScope{User: "alice"},
+ Now: now,
+ })
+ if err != nil {
+ t.Fatalf("Stats(nil range): %v", err)
+ }
+ if len(result.PerTool) != 1 {
+ t.Errorf("nil range PerTool len=%d; want 1", len(result.PerTool))
+ }
+}
M internal/server/server.go => internal/server/server.go +3 -0
@@ 32,6 32,7 @@ import (
"sourcecraft.dev/bigbes/lethe/internal/domain/ingest"
"sourcecraft.dev/bigbes/lethe/internal/domain/project"
"sourcecraft.dev/bigbes/lethe/internal/domain/session"
+ "sourcecraft.dev/bigbes/lethe/internal/domain/stats"
"sourcecraft.dev/bigbes/lethe/internal/pkg/apierror"
"sourcecraft.dev/bigbes/lethe/internal/pkg/httputil"
"sourcecraft.dev/bigbes/lethe/internal/platform/health"
@@ 58,6 59,7 @@ type Server struct {
Ingest *ingest.Handler `inject:""`
Sessions *session.Handler `inject:""`
Projects *project.Handler `inject:""`
+ Stats *stats.Handler `inject:""`
router *chi.Mux
httpSrv *http.Server
@@ 100,6 102,7 @@ func (s *Server) Init(_ context.Context) error {
s.Ingest.Mount(r)
s.Sessions.Mount(r)
s.Projects.Mount(r)
+ s.Stats.Mount(r)
})
// SPA catch-all: serves the embedded React app for all non-API GET paths.