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,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})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
+1
-10
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user