diff --git a/PROGRESS.md b/PROGRESS.md index 73aa979..f35a237 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -10,3 +10,4 @@ - Phase 7 (atomization): circuit breaker (3 fails→open, 60s half-open probe) + retrying bearer JSON client shared by both external services, atomizer client w/ §5.1 coefficient normalization (±0.001 ok, ≤0.05 renormalized, else 502-class), persisted jobs queue (panic-safe, backoff ×3, stale requeue, restart-safe), subdivide/extend endpoints (202 async, re-run replaces unpublished children after confirm), task editing w/ bounty recompute + optional budget cascade, publish single/bulk, WS hub (origin check, 30s heartbeats) + live board, consultant atomization board UI (tree, sliders, sum indicator, modals, shimmer, health gating) — unit + integration green. Fixed tasks unique index: sparse→partial (sparse compound matched every task via customerId). - Phase 8 (bounty board + lifecycle): developer board (pool-scoped, filters/search/sort, stale age badges, hide-competing-claims setting, saved filters), claim/withdraw/decline/approve, assign-to-AI via §5.2 client + HMAC-verified idempotent callback w/ artifact ingestion into GridFS, start/submit/abandon/unassign, comments (sanitized, @mentions→notifications), time log, review queue + per-AC checklist on timeline, immutable bountyAwards on approve (humans only), notifications center + bell + WS toasts, my-tasks kanban, task detail page, pool management UI, server-side HTML sanitizer w/ XSS vector tests — full lifecycle integration tests green. - Phase 9 (messaging): conversations (dm dedupe/group/project), sanitized rich-text messages, file/image attachments (generic POST /api/v1/files w/ rate limit), per-conversation unread counts + mark-read, chat-file access limited to participants, mentions→notifications, typing indicator over WS inbound relay, two-pane messages UI (contenteditable composer, formatting toolbar, drag&drop upload, inline previews + lightbox, live delivery, polling fallback), user directory search — integration tests green. +- Phase 10 (metrics): aggregation pipelines over bountyAwards + timelines (totals, weekly $dateTrunc buckets, per-developer/per-customer, approval rate, time logged, assign→approve lead time, atomization lead time, open board depth), CSV export, leaderboard w/ opt-out (profile setting), dependency-free SVG line+bar charts, developer/consultant/admin dashboards — integration tests green. diff --git a/internal/domain/user.go b/internal/domain/user.go index 4a857fc..0a01442 100644 --- a/internal/domain/user.go +++ b/internal/domain/user.go @@ -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 { diff --git a/internal/http/metrics_api.go b/internal/http/metrics_api.go new file mode 100644 index 0000000..475536e --- /dev/null +++ b/internal/http/metrics_api.go @@ -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}) +} diff --git a/internal/http/metrics_integration_test.go b/internal/http/metrics_integration_test.go new file mode 100644 index 0000000..f673e64 --- /dev/null +++ b/internal/http/metrics_integration_test.go @@ -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) + } +} diff --git a/internal/http/profile.go b/internal/http/profile.go index 1cda297..d9df0df 100644 --- a/internal/http/profile.go +++ b/internal/http/profile.go @@ -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") diff --git a/internal/http/server.go b/internal/http/server.go index cde8b58..c881745 100644 --- a/internal/http/server.go +++ b/internal/http/server.go @@ -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) diff --git a/internal/http/web.go b/internal/http/web.go index a610a28..5ccd908 100644 --- a/internal/http/web.go +++ b/internal/http/web.go @@ -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 { diff --git a/internal/store/metrics.go b/internal/store/metrics.go new file mode 100644 index 0000000..5ead033 --- /dev/null +++ b/internal/store/metrics.go @@ -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 +} diff --git a/web/static/js/charts.js b/web/static/js/charts.js new file mode 100644 index 0000000..ffe4e4c --- /dev/null +++ b/web/static/js/charts.js @@ -0,0 +1,92 @@ +// Dependency-free SVG charts (§6.2): a line chart for time series and a +// horizontal bar chart for grouped totals. ~150 lines, no library. +const NS = 'http://www.w3.org/2000/svg'; + +function el(name, attrs, parent) { + const node = document.createElementNS(NS, name); + Object.entries(attrs || {}).forEach(([k, v]) => node.setAttribute(k, v)); + if (parent) parent.appendChild(node); + return node; +} + +const fmt = (v) => (Math.abs(v) >= 1000 ? (v / 1000).toFixed(1) + 'k' : String(Math.round(v * 100) / 100)); + +// lineChart(host, points) — points: [{label: string, value: number}] +export function lineChart(host, points, opts = {}) { + host.replaceChildren(); + const W = opts.width || host.clientWidth || 560; + const H = opts.height || 220; + const pad = { l: 48, r: 12, t: 12, b: 28 }; + const svg = el('svg', { viewBox: `0 0 ${W} ${H}`, width: '100%', role: 'img', + 'aria-label': opts.label || 'line chart' }, host); + if (!points.length) { + const t = el('text', { x: W / 2, y: H / 2, 'text-anchor': 'middle', fill: 'var(--muted)' }, svg); + t.textContent = 'No data in this period'; + return; + } + const max = Math.max(...points.map((p) => p.value), 1); + const x = (i) => pad.l + (i * (W - pad.l - pad.r)) / Math.max(points.length - 1, 1); + const y = (v) => H - pad.b - (v / max) * (H - pad.t - pad.b); + + // gridlines + y labels + for (let g = 0; g <= 4; g++) { + const v = (max * g) / 4; + el('line', { x1: pad.l, x2: W - pad.r, y1: y(v), y2: y(v), + stroke: 'var(--border)', 'stroke-width': 1 }, svg); + const t = el('text', { x: pad.l - 6, y: y(v) + 4, 'text-anchor': 'end', + 'font-size': 11, fill: 'var(--muted)' }, svg); + t.textContent = fmt(v); + } + // x labels (sparse) + const step = Math.ceil(points.length / 8); + points.forEach((p, i) => { + if (i % step !== 0 && i !== points.length - 1) return; + const t = el('text', { x: x(i), y: H - 8, 'text-anchor': 'middle', + 'font-size': 11, fill: 'var(--muted)' }, svg); + t.textContent = p.label; + }); + + const d = points.map((p, i) => `${i ? 'L' : 'M'}${x(i).toFixed(1)},${y(p.value).toFixed(1)}`).join(' '); + // area fill + el('path', { + d: `${d} L${x(points.length - 1)},${y(0)} L${x(0)},${y(0)} Z`, + fill: 'var(--accent)', opacity: 0.15, + }, svg); + el('path', { d, fill: 'none', stroke: 'var(--accent)', 'stroke-width': 2 }, svg); + points.forEach((p, i) => { + const c = el('circle', { cx: x(i), cy: y(p.value), r: 3, fill: 'var(--accent)' }, svg); + const title = el('title', {}, c); + title.textContent = `${p.label}: ${p.value}`; + }); +} + +// barChart(host, rows) — rows: [{label, value}], horizontal bars +export function barChart(host, rows, opts = {}) { + host.replaceChildren(); + const W = opts.width || host.clientWidth || 560; + const rowH = 26; + const pad = { l: 140, r: 48, t: 6, b: 6 }; + const H = pad.t + pad.b + Math.max(rows.length, 1) * rowH; + const svg = el('svg', { viewBox: `0 0 ${W} ${H}`, width: '100%', role: 'img', + 'aria-label': opts.label || 'bar chart' }, host); + if (!rows.length) { + const t = el('text', { x: W / 2, y: H / 2 + 4, 'text-anchor': 'middle', fill: 'var(--muted)' }, svg); + t.textContent = 'No data in this period'; + return; + } + const max = Math.max(...rows.map((r) => r.value), 1); + rows.forEach((r, i) => { + const yPos = pad.t + i * rowH; + const label = el('text', { x: pad.l - 8, y: yPos + rowH / 2 + 4, + 'text-anchor': 'end', 'font-size': 12, fill: 'var(--text)' }, svg); + label.textContent = r.label.length > 18 ? r.label.slice(0, 17) + '…' : r.label; + const wBar = ((W - pad.l - pad.r) * r.value) / max; + const rect = el('rect', { x: pad.l, y: yPos + 4, width: Math.max(wBar, 2), + height: rowH - 8, fill: 'var(--accent)', rx: 2 }, svg); + const title = el('title', {}, rect); + title.textContent = `${r.label}: ${r.value}`; + const val = el('text', { x: pad.l + Math.max(wBar, 2) + 6, y: yPos + rowH / 2 + 4, + 'font-size': 12, fill: 'var(--muted)' }, svg); + val.textContent = fmt(r.value); + }); +} diff --git a/web/static/js/metrics.js b/web/static/js/metrics.js new file mode 100644 index 0000000..edeef39 --- /dev/null +++ b/web/static/js/metrics.js @@ -0,0 +1,113 @@ +import { api, toast } from '/static/js/api.js'; +import { lineChart, barChart } from '/static/js/charts.js'; + +const errorBox = document.getElementById('error'); +const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ + '&': '&', '<': '<', '>': '>', '"': '"', "'": ''', +}[c])); + +let me = null; + +function rangeQS() { + const from = document.getElementById('m-from').value; + const to = document.getElementById('m-to').value; + const p = new URLSearchParams(); + if (from) p.set('from', from); + if (to) p.set('to', to); + return p; +} + +function card(label, value, hint) { + return `

${esc(label)}

+

${esc(value)}

+ ${hint ? `

${esc(hint)}

` : ''}
`; +} + +const weekLabel = (iso) => { + const d = new Date(iso); + return `${d.getMonth() + 1}/${d.getDate()}`; +}; + +async function loadDeveloper() { + const res = await api('GET', '/api/v1/developer/metrics?' + rangeQS()); + document.getElementById('dev-metrics').hidden = false; + document.getElementById('dev-cards').innerHTML = + card('Total bounty earned', res.totalBounty) + + card('Tasks completed', res.tasksCompleted) + + card('Approval rate', `${Math.round(res.approvalRate * 100)}%`, + `${res.approved} approved · ${res.changesRequested} change requests`) + + card('Avg assigned → approved', `${res.avgAssignToApproveHours.toFixed(1)} h`) + + card('Time logged', `${Math.floor(res.timeLoggedMinutes / 60)}h ${res.timeLoggedMinutes % 60}m`); + lineChart(document.getElementById('dev-weekly'), + res.weekly.map((p) => ({ label: weekLabel(p.week), value: p.amount })), + { label: 'weekly earnings' }); + barChart(document.getElementById('dev-customers'), + res.perCustomer.map((g) => ({ label: g.key, value: g.amount })), + { label: 'earnings per customer' }); + document.getElementById('m-csv').href = '/api/v1/developer/metrics?format=csv&' + rangeQS(); +} + +async function loadConsultant() { + const qs = rangeQS(); + const cust = document.getElementById('m-customer').value; + if (cust) qs.set('customerId', cust); + const res = await api('GET', '/api/v1/consultant/metrics?' + qs); + document.getElementById('cons-metrics').hidden = false; + const sel = document.getElementById('m-customer'); + if (sel.options.length === 1) { + res.customers.forEach((c) => sel.add(new Option(c.name, c.id))); + } + document.getElementById('cons-cards').innerHTML = + card('Bounty awarded', res.totalBounty) + + card('Tasks completed', res.tasksCompleted) + + card('Atomization lead time', `${res.atomizationLeadHours.toFixed(1)} h`, 'imported → published') + + card('Open board depth', res.openBoardDepth, 'published, unclaimed tasks'); + lineChart(document.getElementById('cons-weekly'), + res.weekly.map((p) => ({ label: weekLabel(p.week), value: p.amount })), + { label: 'weekly awards' }); + barChart(document.getElementById('cons-devs'), + res.perDeveloper.map((g) => ({ label: g.key, value: g.amount })), + { label: 'per developer' }); + barChart(document.getElementById('cons-customers'), + res.perCustomer.map((g) => ({ label: g.key, value: g.amount })), + { label: 'per customer' }); + document.getElementById('m-csv').href = '/api/v1/consultant/metrics?format=csv&' + qs; +} + +async function loadLeaderboard() { + const res = await api('GET', '/api/v1/leaderboard?' + rangeQS()); + const tbody = document.querySelector('#leaderboard tbody'); + tbody.replaceChildren(...res.leaderboard.map((row, i) => { + const tr = document.createElement('tr'); + tr.innerHTML = `${i + 1}${esc(row.name)}${row.tasks}◈ ${row.amount}`; + return tr; + })); + if (!res.leaderboard.length) { + tbody.innerHTML = 'No bounties awarded yet.'; + } +} + +async function loadAll() { + errorBox.textContent = ''; + try { + if (me.user.roles.developer) await loadDeveloper(); + if (me.user.roles.consultant || me.user.roles.admin) await loadConsultant(); + await loadLeaderboard(); + } catch (e) { errorBox.textContent = e.message; } +} + +document.getElementById('m-apply').addEventListener('click', loadAll); +document.getElementById('m-customer').addEventListener('change', loadConsultant); +document.getElementById('lb-optout').addEventListener('change', async (e) => { + try { + await api('PATCH', '/api/v1/profile', { settings: { leaderboardOptOut: e.target.checked } }); + toast(e.target.checked ? 'You are hidden from the leaderboard.' : 'You are visible on the leaderboard.', 'ok'); + loadLeaderboard(); + } catch (err) { toast(err.message, 'err'); } +}); + +(async () => { + me = await api('GET', '/api/v1/auth/me'); + document.getElementById('lb-optout').checked = !!me.user.settings.leaderboardOptOut; + loadAll(); +})(); diff --git a/web/templates/metrics.html b/web/templates/metrics.html new file mode 100644 index 0000000..17fd80a --- /dev/null +++ b/web/templates/metrics.html @@ -0,0 +1,45 @@ +{{define "content"}} +
+

Metrics

+ + + + + Export CSV + +
+ + + + + + +
+
+

Leaderboard — top developers

+ +
+ + + +
#DeveloperTasksBounty
+
+{{end}}