~bigbes/lethe

321125b29b8dcde4046cf019b588f2bfe4e96d2a — Eugene Blikh a month ago 2285c57
stats: add /api/v1/stats aggregate endpoint

Implements Phase 2 of lethe-web-ui-aggregates: a single-round-trip
/api/v1/stats endpoint that bundles per-tool rollup, daily time-series,
84-cell activity heatmap, top-cwd ranking, hour-of-day distribution, and
host split. All six aggregations run fresh per request; no server cache.

- internal/domain/stats/buckets.go: pure helpers DailyWindow, FillDaily,
  HeatmapWindow, HourWindow — fully deterministic, tested independently.
- internal/domain/stats/repository.go: Filter/Stats types and Repository.Stats
  — six SQL queries joined via sessions+turns, owner scope via OwnerScope
  (AllOwners / SpecificOwner / default), sparkline capped at 60 buckets.
- internal/domain/stats/handler.go: Handler.List — ?range=7d|30d|90d|all
  (default 30d); ?range=<other> → 400 INVALID; ?owner= non-admin → 403.
- internal/server/server.go: register *stats.Handler alongside project/sessions.
- cmd/lethe/main.go: add statsRepo/statsHnd to var block and steward graph.
- cmd/lethe/main_e2e_test.go: add stats.Repository/Handler to e2e graph.
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.