feat: metrics dashboards with SVG charts, CSV export, leaderboard (phase 10)

- aggregations over the immutable bountyAwards ledger: totals, weekly
  buckets ($dateTrunc), per-developer and per-customer groupings
- timeline-derived: approval rate, time logged, assigned→approved lead
  time, imported→published atomization lead time, open board depth
- developer + consultant dashboards (admin = global consultant view),
  date-range filters, CSV export of the ledger
- leaderboard (top 10 by bounty) honoring the new leaderboardOptOut
  profile setting
- hand-rolled SVG line and bar charts (~150 lines, no chart library)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
etalon
2026-06-12 20:19:57 +02:00
parent 7d3140334e
commit b1c9a42810
11 changed files with 938 additions and 14 deletions
+3 -2
View File
@@ -53,8 +53,9 @@ type NotificationPrefs struct {
}
type UserSettings struct {
Theme string `bson:"theme" json:"theme"`
Notifications NotificationPrefs `bson:"notifications" json:"notifications"`
Theme string `bson:"theme" json:"theme"`
Notifications NotificationPrefs `bson:"notifications" json:"notifications"`
LeaderboardOptOut bool `bson:"leaderboardOptOut" json:"leaderboardOptOut"`
}
type User struct {
+235
View File
@@ -0,0 +1,235 @@
package httpx
import (
"encoding/csv"
"net/http"
"strconv"
"time"
)
func (s *Server) routesMetrics(mux *http.ServeMux) {
mux.Handle("GET /api/v1/developer/metrics",
s.requireAuth(s.requireRole("developer", http.HandlerFunc(s.handleDeveloperMetrics))))
mux.Handle("GET /api/v1/consultant/metrics",
s.requireAuth(s.requireRole("consultant", http.HandlerFunc(s.handleConsultantMetrics))))
mux.Handle("GET /api/v1/leaderboard", s.requireAuth(http.HandlerFunc(s.handleLeaderboard)))
}
func metricsWindow(r *http.Request) (time.Time, time.Time) {
parse := func(s string, def time.Time) time.Time {
if t, err := time.Parse("2006-01-02", s); err == nil {
return t
}
return def
}
now := time.Now().UTC()
from := parse(r.URL.Query().Get("from"), now.AddDate(0, -3, 0))
to := parse(r.URL.Query().Get("to"), now).Add(24*time.Hour - time.Nanosecond)
return from, to
}
func (s *Server) nameOf(r *http.Request, id string) string {
if u, err := s.store.UserByID(r.Context(), id); err == nil {
return u.Name
}
if c, err := s.store.CustomerByID(r.Context(), id); err == nil {
return c.Name
}
return id
}
func (s *Server) handleDeveloperMetrics(w http.ResponseWriter, r *http.Request) {
me := CurrentUser(r.Context()).ID
from, to := metricsWindow(r)
if r.URL.Query().Get("format") == "csv" {
s.writeAwardsCSV(w, r, me, "", "", from, to)
return
}
total, tasks, err := s.store.AwardTotals(r.Context(), me, "", "", from, to)
if err != nil {
s.internalError(w, r, "award totals", err)
return
}
weekly, err := s.store.WeeklyAwards(r.Context(), me, "", "", from, to)
if err != nil {
s.internalError(w, r, "weekly", err)
return
}
perCustomer, err := s.store.AwardsGroupedBy(r.Context(), "customerId", me, "", "", from, to)
if err != nil {
s.internalError(w, r, "per customer", err)
return
}
for i := range perCustomer {
perCustomer[i].Key = s.nameOf(r, perCustomer[i].Key)
}
approved, changes, err := s.store.ReviewOutcomes(r.Context(), me, from, to)
if err != nil {
s.internalError(w, r, "outcomes", err)
return
}
rate := 0.0
if approved+changes > 0 {
rate = float64(approved) / float64(approved+changes)
}
minutes, err := s.store.TimeLogged(r.Context(), me, from, to)
if err != nil {
s.internalError(w, r, "time logged", err)
return
}
avgHours, err := s.store.AvgAssignToApproveHours(r.Context(), me, from, to)
if err != nil {
s.internalError(w, r, "lead time", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{
"totalBounty": total, "tasksCompleted": tasks,
"approvalRate": rate, "approved": approved, "changesRequested": changes,
"timeLoggedMinutes": minutes, "avgAssignToApproveHours": avgHours,
"weekly": weekly, "perCustomer": perCustomer,
"from": from, "to": to,
})
}
func (s *Server) handleConsultantMetrics(w http.ResponseWriter, r *http.Request) {
me := CurrentUser(r.Context())
from, to := metricsWindow(r)
consultantID := me.ID
if me.Roles.Admin {
consultantID = "" // global view (§3)
}
customerID := r.URL.Query().Get("customerId")
developerID := r.URL.Query().Get("developerId")
if r.URL.Query().Get("format") == "csv" {
s.writeAwardsCSV(w, r, developerID, consultantID, customerID, from, to)
return
}
total, tasks, err := s.store.AwardTotals(r.Context(), developerID, consultantID, customerID, from, to)
if err != nil {
s.internalError(w, r, "totals", err)
return
}
weekly, err := s.store.WeeklyAwards(r.Context(), developerID, consultantID, customerID, from, to)
if err != nil {
s.internalError(w, r, "weekly", err)
return
}
perDeveloper, err := s.store.AwardsGroupedBy(r.Context(), "developerId", developerID, consultantID, customerID, from, to)
if err != nil {
s.internalError(w, r, "per developer", err)
return
}
perCustomer, err := s.store.AwardsGroupedBy(r.Context(), "customerId", developerID, consultantID, customerID, from, to)
if err != nil {
s.internalError(w, r, "per customer", err)
return
}
for i := range perDeveloper {
perDeveloper[i].Key = s.nameOf(r, perDeveloper[i].Key)
}
for i := range perCustomer {
perCustomer[i].Key = s.nameOf(r, perCustomer[i].Key)
}
// scope for board depth + lead time: my customers (or one)
listFor := consultantID
customers, err := s.store.ListCustomers(r.Context(), listFor, false)
if err != nil {
s.internalError(w, r, "customers", err)
return
}
ids := []string{}
depthNames := map[string]string{}
for _, c := range customers {
if customerID == "" || c.ID == customerID {
ids = append(ids, c.ID)
depthNames[c.ID] = c.Name
}
}
leadHours, err := s.store.AtomizationLeadTimeHours(r.Context(), ids, from, to)
if err != nil {
s.internalError(w, r, "lead time", err)
return
}
depth, err := s.store.OpenBoardDepth(r.Context(), ids)
if err != nil {
s.internalError(w, r, "board depth", err)
return
}
depthOut := []map[string]any{}
var depthTotal int64
for id, n := range depth {
depthTotal += n
depthOut = append(depthOut, map[string]any{"customer": depthNames[id], "open": n})
}
writeJSON(w, http.StatusOK, map[string]any{
"totalBounty": total, "tasksCompleted": tasks,
"weekly": weekly, "perDeveloper": perDeveloper, "perCustomer": perCustomer,
"atomizationLeadHours": leadHours,
"openBoardDepth": depthTotal, "openBoardByCustomer": depthOut,
"customers": func() []map[string]any {
out := make([]map[string]any, len(customers))
for i, c := range customers {
out[i] = map[string]any{"id": c.ID, "name": c.Name}
}
return out
}(),
"from": from, "to": to,
})
}
// writeAwardsCSV implements §11.12 export.
func (s *Server) writeAwardsCSV(w http.ResponseWriter, r *http.Request,
developerID, consultantID, customerID string, from, to time.Time) {
awards, err := s.store.ListAwards(r.Context(), developerID, consultantID, customerID, from, to)
if err != nil {
s.internalError(w, r, "list awards", err)
return
}
w.Header().Set("Content-Type", "text/csv; charset=utf-8")
w.Header().Set("Content-Disposition", `attachment; filename="bounty-awards.csv"`)
cw := csv.NewWriter(w)
cw.Write([]string{"awardedAt", "taskId", "developer", "consultant", "customer", "amount", "coefficient"})
for _, a := range awards {
cw.Write([]string{
a.AwardedAt.Format(time.RFC3339), a.TaskID,
s.nameOf(r, a.DeveloperID), s.nameOf(r, a.ConsultantID), s.nameOf(r, a.CustomerID),
strconv.FormatFloat(a.Amount, 'f', 2, 64),
strconv.FormatFloat(a.Coefficient, 'f', 4, 64),
})
}
cw.Flush()
}
// handleLeaderboard: top developers by bounty, honoring opt-out (§11.8).
func (s *Server) handleLeaderboard(w http.ResponseWriter, r *http.Request) {
from, to := metricsWindow(r)
rows, err := s.store.Leaderboard(r.Context(), from, to, 10)
if err != nil {
s.internalError(w, r, "leaderboard", err)
return
}
out := []map[string]any{}
for _, row := range rows {
if len(out) >= 10 {
break
}
u, err := s.store.UserByID(r.Context(), row.Key)
if err != nil {
continue
}
if u.Settings.LeaderboardOptOut {
continue
}
out = append(out, map[string]any{
"developerId": u.ID, "name": u.Name, "avatarFileId": u.AvatarFileID,
"amount": row.Amount, "tasks": row.Tasks,
})
}
writeJSON(w, http.StatusOK, map[string]any{"leaderboard": out, "from": from, "to": to})
}
+137
View File
@@ -0,0 +1,137 @@
//go:build integration
package httpx
import (
"io"
"net/http"
"strings"
"testing"
"bountyboard/internal/store"
)
func seedAwards(t *testing.T, st *store.Store, devID, consID, custID string, amounts []float64) {
t.Helper()
for i, amt := range amounts {
a := &store.BountyAward{
TaskID: "01JTASK" + strings.Repeat("0", 18-len(string(rune(i)))) + string(rune('A'+i)),
DeveloperID: devID, ConsultantID: consID, CustomerID: custID,
Amount: amt, Coefficient: 0.25,
}
if err := st.InsertAward(t.Context(), a); err != nil {
t.Fatal(err)
}
}
}
func TestDeveloperMetricsAndCSV(t *testing.T) {
ts, _, st, _ := newAuthStack(t, nil)
dc := newClient(t)
dev := registerUser(t, ts.URL, dc, "metrics-dev@example.com", "Metrics Dev")
seedAwards(t, st, dev.ID, "01JCONS", "01JCUST", []float64{250, 100, 50.5})
resp, err := dc.Get(ts.URL + "/api/v1/developer/metrics")
if err != nil {
t.Fatal(err)
}
var out struct {
TotalBounty float64 `json:"totalBounty"`
TasksCompleted int64 `json:"tasksCompleted"`
Weekly []struct {
Amount float64 `json:"amount"`
} `json:"weekly"`
}
bodyJSON(t, resp, &out)
if out.TotalBounty != 400.5 || out.TasksCompleted != 3 {
t.Fatalf("totals: %+v", out)
}
if len(out.Weekly) == 0 {
t.Fatal("weekly series empty")
}
// CSV export
resp, err = dc.Get(ts.URL + "/api/v1/developer/metrics?format=csv")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/csv") {
t.Fatalf("csv content type: %q", ct)
}
body, _ := io.ReadAll(resp.Body)
lines := strings.Split(strings.TrimSpace(string(body)), "\n")
if len(lines) != 4 { // header + 3 rows
t.Fatalf("csv lines = %d: %s", len(lines), body)
}
if !strings.HasPrefix(lines[0], "awardedAt,taskId,developer") {
t.Fatalf("csv header: %s", lines[0])
}
}
func TestLeaderboardOptOut(t *testing.T) {
ts, _, st, _ := newAuthStack(t, nil)
c1 := newClient(t)
dev1 := registerUser(t, ts.URL, c1, "lb1@example.com", "LB One")
csrf1 := csrfFrom(t, c1, ts.URL)
c2 := newClient(t)
dev2 := registerUser(t, ts.URL, c2, "lb2@example.com", "LB Two")
seedAwards(t, st, dev1.ID, "01JCONS", "01JCUST", []float64{500})
seedAwards(t, st, dev2.ID, "01JCONS", "01JCUST", []float64{300})
resp, err := c1.Get(ts.URL + "/api/v1/leaderboard")
if err != nil {
t.Fatal(err)
}
var out struct {
Leaderboard []struct {
DeveloperID string `json:"developerId"`
Amount float64 `json:"amount"`
} `json:"leaderboard"`
}
bodyJSON(t, resp, &out)
if len(out.Leaderboard) != 2 || out.Leaderboard[0].DeveloperID != dev1.ID {
t.Fatalf("leaderboard: %+v", out.Leaderboard)
}
// dev1 opts out → only dev2 remains
resp = patchJSON(t, c1, ts.URL+"/api/v1/profile",
map[string]any{"settings": map[string]any{"leaderboardOptOut": true}}, csrf1)
if resp.StatusCode != http.StatusOK {
t.Fatalf("opt out: %d", resp.StatusCode)
}
resp.Body.Close()
resp, _ = c1.Get(ts.URL + "/api/v1/leaderboard")
bodyJSON(t, resp, &out)
if len(out.Leaderboard) != 1 || out.Leaderboard[0].DeveloperID != dev2.ID {
t.Fatalf("leaderboard after opt-out: %+v", out.Leaderboard)
}
}
func TestConsultantMetricsScope(t *testing.T) {
ts, _, st, _ := newAuthStack(t, nil)
cc := newClient(t)
cons := registerUser(t, ts.URL, cc, "m-cons@example.com", "M Cons")
promote(t, st, cons.ID, map[string]bool{"consultant": true})
otherCons := "01JOTHERCONSULTANT0000000X"
seedAwards(t, st, "01JDEVA", cons.ID, "01JCUSTA", []float64{100, 200})
seedAwards(t, st, "01JDEVB", otherCons, "01JCUSTB", []float64{999})
resp, err := cc.Get(ts.URL + "/api/v1/consultant/metrics")
if err != nil {
t.Fatal(err)
}
var out struct {
TotalBounty float64 `json:"totalBounty"`
PerDeveloper []struct {
Amount float64 `json:"amount"`
} `json:"perDeveloper"`
}
bodyJSON(t, resp, &out)
// only own-consultant awards counted (999 from the other consultant excluded)
if out.TotalBounty != 300 {
t.Fatalf("consultant total = %v, want 300", out.TotalBounty)
}
}
+6 -2
View File
@@ -38,8 +38,9 @@ func (s *Server) handlePatchProfile(w http.ResponseWriter, r *http.Request) {
Contact *domain.Contact `json:"contact"`
Extra *map[string]any `json:"extra"`
Settings *struct {
Theme *string `json:"theme"`
Notifications *domain.NotificationPrefs `json:"notifications"`
Theme *string `json:"theme"`
Notifications *domain.NotificationPrefs `json:"notifications"`
LeaderboardOptOut *bool `json:"leaderboardOptOut"`
} `json:"settings"`
}
if !decodeJSON(w, r, &req) {
@@ -79,6 +80,9 @@ func (s *Server) handlePatchProfile(w http.ResponseWriter, r *http.Request) {
if req.Settings.Notifications != nil {
set["settings.notifications"] = *req.Settings.Notifications
}
if req.Settings.LeaderboardOptOut != nil {
set["settings.leaderboardOptOut"] = *req.Settings.LeaderboardOptOut
}
}
if len(set) == 0 {
writeError(w, http.StatusBadRequest, "empty_update", "no recognized fields to update")
+1
View File
@@ -88,6 +88,7 @@ func New(cfg *config.Config, log *slog.Logger, reg *metrics.Registry, st *store.
s.routesConsultant(mux)
s.routesWorkResults(mux)
s.routesMessages(mux)
s.routesMetrics(mux)
mux.HandleFunc("GET /ws", s.handleWS)
s.routesWeb(mux)
+1 -10
View File
@@ -220,16 +220,7 @@ func (s *Server) routesWeb(mux *http.ServeMux) {
}))
rolePage("/messages", "messages.html", "Messages", "messages", "/static/js/messages.js", nil)
// Placeholders for areas built in later phases; replaced as they land.
placeholders := map[string]string{
"/metrics": "Metrics",
}
for path, title := range placeholders {
mux.HandleFunc("GET "+path, s.page(func(w http.ResponseWriter, r *http.Request, u *domain.User) {
s.render(w, r, "placeholder.html", &pageData{Title: title, User: u})
}))
}
rolePage("/metrics", "metrics.html", "Metrics", "metrics", "/static/js/metrics.js", nil)
}
func loginErrorMessage(code string) string {
+304
View File
@@ -0,0 +1,304 @@
package store
import (
"context"
"fmt"
"time"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"bountyboard/internal/domain"
)
// Metrics aggregations (§6.2) — always over the immutable bountyAwards
// ledger plus task timelines, never recomputed from mutable task fields.
type WeeklyPoint struct {
Week time.Time `bson:"_id" json:"week"`
Amount float64 `bson:"amount" json:"amount"`
Tasks int64 `bson:"tasks" json:"tasks"`
}
type GroupTotal struct {
Key string `bson:"_id" json:"key"`
Amount float64 `bson:"amount" json:"amount"`
Tasks int64 `bson:"tasks" json:"tasks"`
}
func awardMatch(developerID, consultantID, customerID string, from, to time.Time) bson.M {
m := bson.M{"awardedAt": bson.M{"$gte": from, "$lte": to}}
if developerID != "" {
m["developerId"] = developerID
}
if consultantID != "" {
m["consultantId"] = consultantID
}
if customerID != "" {
m["customerId"] = customerID
}
return m
}
// AwardTotals returns sum + count for the filter.
func (s *Store) AwardTotals(ctx context.Context, developerID, consultantID, customerID string, from, to time.Time) (float64, int64, error) {
cur, err := s.DB.Collection("bountyAwards").Aggregate(ctx, mongo.Pipeline{
{{Key: "$match", Value: awardMatch(developerID, consultantID, customerID, from, to)}},
{{Key: "$group", Value: bson.M{"_id": nil,
"amount": bson.M{"$sum": "$amount"}, "tasks": bson.M{"$sum": 1}}}},
})
if err != nil {
return 0, 0, fmt.Errorf("award totals: %w", err)
}
var rows []struct {
Amount float64 `bson:"amount"`
Tasks int64 `bson:"tasks"`
}
if err := cur.All(ctx, &rows); err != nil {
return 0, 0, fmt.Errorf("decode totals: %w", err)
}
if len(rows) == 0 {
return 0, 0, nil
}
return rows[0].Amount, rows[0].Tasks, nil
}
// WeeklyAwards buckets earnings into ISO weeks (§6.2 earnings-over-time).
func (s *Store) WeeklyAwards(ctx context.Context, developerID, consultantID, customerID string, from, to time.Time) ([]WeeklyPoint, error) {
cur, err := s.DB.Collection("bountyAwards").Aggregate(ctx, mongo.Pipeline{
{{Key: "$match", Value: awardMatch(developerID, consultantID, customerID, from, to)}},
{{Key: "$group", Value: bson.M{
"_id": bson.M{"$dateTrunc": bson.M{"date": "$awardedAt", "unit": "week"}},
"amount": bson.M{"$sum": "$amount"},
"tasks": bson.M{"$sum": 1},
}}},
{{Key: "$sort", Value: bson.M{"_id": 1}}},
})
if err != nil {
return nil, fmt.Errorf("weekly awards: %w", err)
}
out := []WeeklyPoint{}
if err := cur.All(ctx, &out); err != nil {
return nil, fmt.Errorf("decode weekly: %w", err)
}
return out, nil
}
// AwardsGroupedBy aggregates totals per developerId or customerId.
func (s *Store) AwardsGroupedBy(ctx context.Context, field, developerID, consultantID, customerID string, from, to time.Time) ([]GroupTotal, error) {
cur, err := s.DB.Collection("bountyAwards").Aggregate(ctx, mongo.Pipeline{
{{Key: "$match", Value: awardMatch(developerID, consultantID, customerID, from, to)}},
{{Key: "$group", Value: bson.M{"_id": "$" + field,
"amount": bson.M{"$sum": "$amount"}, "tasks": bson.M{"$sum": 1}}}},
{{Key: "$sort", Value: bson.M{"amount": -1}}},
})
if err != nil {
return nil, fmt.Errorf("grouped awards: %w", err)
}
out := []GroupTotal{}
if err := cur.All(ctx, &out); err != nil {
return nil, fmt.Errorf("decode grouped: %w", err)
}
return out, nil
}
// ListAwards returns the raw ledger rows for CSV export.
func (s *Store) ListAwards(ctx context.Context, developerID, consultantID, customerID string, from, to time.Time) ([]BountyAward, error) {
cur, err := s.DB.Collection("bountyAwards").Find(ctx,
awardMatch(developerID, consultantID, customerID, from, to))
if err != nil {
return nil, fmt.Errorf("list awards: %w", err)
}
out := []BountyAward{}
if err := cur.All(ctx, &out); err != nil {
return nil, fmt.Errorf("decode awards: %w", err)
}
return out, nil
}
// ReviewOutcomes counts approved vs changes_requested submissions for a
// developer (approval rate, §6.2).
func (s *Store) ReviewOutcomes(ctx context.Context, developerID string, from, to time.Time) (approved, changes int64, err error) {
cur, err := s.tasks().Aggregate(ctx, mongo.Pipeline{
{{Key: "$match", Value: bson.M{"assignee.userId": developerID}}},
{{Key: "$unwind", Value: "$timeline"}},
{{Key: "$match", Value: bson.M{
"timeline.event": bson.M{"$in": []string{"approved", "changes_requested"}},
"timeline.at": bson.M{"$gte": from, "$lte": to},
}}},
{{Key: "$group", Value: bson.M{"_id": "$timeline.event", "n": bson.M{"$sum": 1}}}},
})
if err != nil {
return 0, 0, fmt.Errorf("review outcomes: %w", err)
}
var rows []struct {
ID string `bson:"_id"`
N int64 `bson:"n"`
}
if err := cur.All(ctx, &rows); err != nil {
return 0, 0, fmt.Errorf("decode outcomes: %w", err)
}
for _, r := range rows {
if r.ID == "approved" {
approved = r.N
} else {
changes = r.N
}
}
return approved, changes, nil
}
// TimeLogged sums the developer's logged minutes.
func (s *Store) TimeLogged(ctx context.Context, developerID string, from, to time.Time) (int64, error) {
cur, err := s.tasks().Aggregate(ctx, mongo.Pipeline{
{{Key: "$unwind", Value: "$timeLog"}},
{{Key: "$match", Value: bson.M{
"timeLog.developerId": developerID,
"timeLog.at": bson.M{"$gte": from, "$lte": to},
}}},
{{Key: "$group", Value: bson.M{"_id": nil, "minutes": bson.M{"$sum": "$timeLog.minutes"}}}},
})
if err != nil {
return 0, fmt.Errorf("time logged: %w", err)
}
var rows []struct {
Minutes int64 `bson:"minutes"`
}
if err := cur.All(ctx, &rows); err != nil {
return 0, fmt.Errorf("decode time: %w", err)
}
if len(rows) == 0 {
return 0, nil
}
return rows[0].Minutes, nil
}
// AvgAssignToApproveHours computes the mean lead time from assignment to
// approval over approved tasks (timeline-derived).
func (s *Store) AvgAssignToApproveHours(ctx context.Context, developerID string, from, to time.Time) (float64, error) {
cur, err := s.tasks().Find(ctx, bson.M{
"assignee.userId": developerID,
"status": domain.StatusApproved,
"updatedAt": bson.M{"$gte": from, "$lte": to},
})
if err != nil {
return 0, fmt.Errorf("approved tasks: %w", err)
}
var tasks []domain.Task
if err := cur.All(ctx, &tasks); err != nil {
return 0, fmt.Errorf("decode approved: %w", err)
}
var total time.Duration
var n int
for _, t := range tasks {
var assignedAt, approvedAt time.Time
for _, e := range t.Timeline {
if (e.Event == "claim_approved" || e.Event == "assigned_to_ai") && assignedAt.IsZero() {
assignedAt = e.At
}
if e.Event == "approved" {
approvedAt = e.At
}
}
if !assignedAt.IsZero() && approvedAt.After(assignedAt) {
total += approvedAt.Sub(assignedAt)
n++
}
}
if n == 0 {
return 0, nil
}
return total.Hours() / float64(n), nil
}
// AtomizationLeadTimeHours: mean imported→published lead time per §6.2,
// measured on published tasks against their root's creation.
func (s *Store) AtomizationLeadTimeHours(ctx context.Context, customerIDs []string, from, to time.Time) (float64, error) {
if len(customerIDs) == 0 {
return 0, nil
}
cur, err := s.tasks().Find(ctx, bson.M{
"customerId": bson.M{"$in": customerIDs},
"publishedAt": bson.M{"$gte": from, "$lte": to},
})
if err != nil {
return 0, fmt.Errorf("published tasks: %w", err)
}
var tasks []domain.Task
if err := cur.All(ctx, &tasks); err != nil {
return 0, fmt.Errorf("decode published: %w", err)
}
rootCreated := map[string]time.Time{}
var total time.Duration
var n int
for _, t := range tasks {
created, ok := rootCreated[t.RootID]
if !ok {
root, err := s.TaskByID(ctx, t.RootID)
if err != nil {
continue
}
created = root.CreatedAt
rootCreated[t.RootID] = created
}
if t.PublishedAt.After(created) {
total += t.PublishedAt.Sub(created)
n++
}
}
if n == 0 {
return 0, nil
}
return total.Hours() / float64(n), nil
}
// OpenBoardDepth counts currently published tasks per customer (§6.2).
func (s *Store) OpenBoardDepth(ctx context.Context, customerIDs []string) (map[string]int64, error) {
out := map[string]int64{}
if len(customerIDs) == 0 {
return out, nil
}
cur, err := s.tasks().Aggregate(ctx, mongo.Pipeline{
{{Key: "$match", Value: bson.M{
"customerId": bson.M{"$in": customerIDs},
"status": bson.M{"$in": []domain.TaskStatus{domain.StatusPublished, domain.StatusClaimRequested}},
}}},
{{Key: "$group", Value: bson.M{"_id": "$customerId", "n": bson.M{"$sum": 1}}}},
})
if err != nil {
return nil, fmt.Errorf("board depth: %w", err)
}
var rows []struct {
ID string `bson:"_id"`
N int64 `bson:"n"`
}
if err := cur.All(ctx, &rows); err != nil {
return nil, fmt.Errorf("decode depth: %w", err)
}
for _, r := range rows {
out[r.ID] = r.N
}
return out, nil
}
// Leaderboard: top developers by total bounty (opt-outs filtered by caller).
func (s *Store) Leaderboard(ctx context.Context, from, to time.Time, limit int) ([]GroupTotal, error) {
if limit <= 0 || limit > 50 {
limit = 10
}
cur, err := s.DB.Collection("bountyAwards").Aggregate(ctx, mongo.Pipeline{
{{Key: "$match", Value: bson.M{"awardedAt": bson.M{"$gte": from, "$lte": to}}}},
{{Key: "$group", Value: bson.M{"_id": "$developerId",
"amount": bson.M{"$sum": "$amount"}, "tasks": bson.M{"$sum": 1}}}},
{{Key: "$sort", Value: bson.M{"amount": -1}}},
{{Key: "$limit", Value: limit * 2}}, // headroom for opt-out filtering
})
if err != nil {
return nil, fmt.Errorf("leaderboard: %w", err)
}
out := []GroupTotal{}
if err := cur.All(ctx, &out); err != nil {
return nil, fmt.Errorf("decode leaderboard: %w", err)
}
return out, nil
}