b1c9a42810
- 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>
305 lines
9.5 KiB
Go
305 lines
9.5 KiB
Go
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
|
|
}
|