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 }