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>
236 lines
7.1 KiB
Go
236 lines
7.1 KiB
Go
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})
|
|
}
|