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:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user