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:
@@ -10,3 +10,4 @@
|
|||||||
- Phase 7 (atomization): circuit breaker (3 fails→open, 60s half-open probe) + retrying bearer JSON client shared by both external services, atomizer client w/ §5.1 coefficient normalization (±0.001 ok, ≤0.05 renormalized, else 502-class), persisted jobs queue (panic-safe, backoff ×3, stale requeue, restart-safe), subdivide/extend endpoints (202 async, re-run replaces unpublished children after confirm), task editing w/ bounty recompute + optional budget cascade, publish single/bulk, WS hub (origin check, 30s heartbeats) + live board, consultant atomization board UI (tree, sliders, sum indicator, modals, shimmer, health gating) — unit + integration green. Fixed tasks unique index: sparse→partial (sparse compound matched every task via customerId).
|
- Phase 7 (atomization): circuit breaker (3 fails→open, 60s half-open probe) + retrying bearer JSON client shared by both external services, atomizer client w/ §5.1 coefficient normalization (±0.001 ok, ≤0.05 renormalized, else 502-class), persisted jobs queue (panic-safe, backoff ×3, stale requeue, restart-safe), subdivide/extend endpoints (202 async, re-run replaces unpublished children after confirm), task editing w/ bounty recompute + optional budget cascade, publish single/bulk, WS hub (origin check, 30s heartbeats) + live board, consultant atomization board UI (tree, sliders, sum indicator, modals, shimmer, health gating) — unit + integration green. Fixed tasks unique index: sparse→partial (sparse compound matched every task via customerId).
|
||||||
- Phase 8 (bounty board + lifecycle): developer board (pool-scoped, filters/search/sort, stale age badges, hide-competing-claims setting, saved filters), claim/withdraw/decline/approve, assign-to-AI via §5.2 client + HMAC-verified idempotent callback w/ artifact ingestion into GridFS, start/submit/abandon/unassign, comments (sanitized, @mentions→notifications), time log, review queue + per-AC checklist on timeline, immutable bountyAwards on approve (humans only), notifications center + bell + WS toasts, my-tasks kanban, task detail page, pool management UI, server-side HTML sanitizer w/ XSS vector tests — full lifecycle integration tests green.
|
- Phase 8 (bounty board + lifecycle): developer board (pool-scoped, filters/search/sort, stale age badges, hide-competing-claims setting, saved filters), claim/withdraw/decline/approve, assign-to-AI via §5.2 client + HMAC-verified idempotent callback w/ artifact ingestion into GridFS, start/submit/abandon/unassign, comments (sanitized, @mentions→notifications), time log, review queue + per-AC checklist on timeline, immutable bountyAwards on approve (humans only), notifications center + bell + WS toasts, my-tasks kanban, task detail page, pool management UI, server-side HTML sanitizer w/ XSS vector tests — full lifecycle integration tests green.
|
||||||
- Phase 9 (messaging): conversations (dm dedupe/group/project), sanitized rich-text messages, file/image attachments (generic POST /api/v1/files w/ rate limit), per-conversation unread counts + mark-read, chat-file access limited to participants, mentions→notifications, typing indicator over WS inbound relay, two-pane messages UI (contenteditable composer, formatting toolbar, drag&drop upload, inline previews + lightbox, live delivery, polling fallback), user directory search — integration tests green.
|
- Phase 9 (messaging): conversations (dm dedupe/group/project), sanitized rich-text messages, file/image attachments (generic POST /api/v1/files w/ rate limit), per-conversation unread counts + mark-read, chat-file access limited to participants, mentions→notifications, typing indicator over WS inbound relay, two-pane messages UI (contenteditable composer, formatting toolbar, drag&drop upload, inline previews + lightbox, live delivery, polling fallback), user directory search — integration tests green.
|
||||||
|
- Phase 10 (metrics): aggregation pipelines over bountyAwards + timelines (totals, weekly $dateTrunc buckets, per-developer/per-customer, approval rate, time logged, assign→approve lead time, atomization lead time, open board depth), CSV export, leaderboard w/ opt-out (profile setting), dependency-free SVG line+bar charts, developer/consultant/admin dashboards — integration tests green.
|
||||||
|
|||||||
@@ -53,8 +53,9 @@ type NotificationPrefs struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type UserSettings struct {
|
type UserSettings struct {
|
||||||
Theme string `bson:"theme" json:"theme"`
|
Theme string `bson:"theme" json:"theme"`
|
||||||
Notifications NotificationPrefs `bson:"notifications" json:"notifications"`
|
Notifications NotificationPrefs `bson:"notifications" json:"notifications"`
|
||||||
|
LeaderboardOptOut bool `bson:"leaderboardOptOut" json:"leaderboardOptOut"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type User struct {
|
type User struct {
|
||||||
|
|||||||
@@ -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"`
|
Contact *domain.Contact `json:"contact"`
|
||||||
Extra *map[string]any `json:"extra"`
|
Extra *map[string]any `json:"extra"`
|
||||||
Settings *struct {
|
Settings *struct {
|
||||||
Theme *string `json:"theme"`
|
Theme *string `json:"theme"`
|
||||||
Notifications *domain.NotificationPrefs `json:"notifications"`
|
Notifications *domain.NotificationPrefs `json:"notifications"`
|
||||||
|
LeaderboardOptOut *bool `json:"leaderboardOptOut"`
|
||||||
} `json:"settings"`
|
} `json:"settings"`
|
||||||
}
|
}
|
||||||
if !decodeJSON(w, r, &req) {
|
if !decodeJSON(w, r, &req) {
|
||||||
@@ -79,6 +80,9 @@ func (s *Server) handlePatchProfile(w http.ResponseWriter, r *http.Request) {
|
|||||||
if req.Settings.Notifications != nil {
|
if req.Settings.Notifications != nil {
|
||||||
set["settings.notifications"] = *req.Settings.Notifications
|
set["settings.notifications"] = *req.Settings.Notifications
|
||||||
}
|
}
|
||||||
|
if req.Settings.LeaderboardOptOut != nil {
|
||||||
|
set["settings.leaderboardOptOut"] = *req.Settings.LeaderboardOptOut
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if len(set) == 0 {
|
if len(set) == 0 {
|
||||||
writeError(w, http.StatusBadRequest, "empty_update", "no recognized fields to update")
|
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.routesConsultant(mux)
|
||||||
s.routesWorkResults(mux)
|
s.routesWorkResults(mux)
|
||||||
s.routesMessages(mux)
|
s.routesMessages(mux)
|
||||||
|
s.routesMetrics(mux)
|
||||||
mux.HandleFunc("GET /ws", s.handleWS)
|
mux.HandleFunc("GET /ws", s.handleWS)
|
||||||
s.routesWeb(mux)
|
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)
|
rolePage("/messages", "messages.html", "Messages", "messages", "/static/js/messages.js", nil)
|
||||||
|
rolePage("/metrics", "metrics.html", "Metrics", "metrics", "/static/js/metrics.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})
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func loginErrorMessage(code string) string {
|
func loginErrorMessage(code string) string {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
// Dependency-free SVG charts (§6.2): a line chart for time series and a
|
||||||
|
// horizontal bar chart for grouped totals. ~150 lines, no library.
|
||||||
|
const NS = 'http://www.w3.org/2000/svg';
|
||||||
|
|
||||||
|
function el(name, attrs, parent) {
|
||||||
|
const node = document.createElementNS(NS, name);
|
||||||
|
Object.entries(attrs || {}).forEach(([k, v]) => node.setAttribute(k, v));
|
||||||
|
if (parent) parent.appendChild(node);
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fmt = (v) => (Math.abs(v) >= 1000 ? (v / 1000).toFixed(1) + 'k' : String(Math.round(v * 100) / 100));
|
||||||
|
|
||||||
|
// lineChart(host, points) — points: [{label: string, value: number}]
|
||||||
|
export function lineChart(host, points, opts = {}) {
|
||||||
|
host.replaceChildren();
|
||||||
|
const W = opts.width || host.clientWidth || 560;
|
||||||
|
const H = opts.height || 220;
|
||||||
|
const pad = { l: 48, r: 12, t: 12, b: 28 };
|
||||||
|
const svg = el('svg', { viewBox: `0 0 ${W} ${H}`, width: '100%', role: 'img',
|
||||||
|
'aria-label': opts.label || 'line chart' }, host);
|
||||||
|
if (!points.length) {
|
||||||
|
const t = el('text', { x: W / 2, y: H / 2, 'text-anchor': 'middle', fill: 'var(--muted)' }, svg);
|
||||||
|
t.textContent = 'No data in this period';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const max = Math.max(...points.map((p) => p.value), 1);
|
||||||
|
const x = (i) => pad.l + (i * (W - pad.l - pad.r)) / Math.max(points.length - 1, 1);
|
||||||
|
const y = (v) => H - pad.b - (v / max) * (H - pad.t - pad.b);
|
||||||
|
|
||||||
|
// gridlines + y labels
|
||||||
|
for (let g = 0; g <= 4; g++) {
|
||||||
|
const v = (max * g) / 4;
|
||||||
|
el('line', { x1: pad.l, x2: W - pad.r, y1: y(v), y2: y(v),
|
||||||
|
stroke: 'var(--border)', 'stroke-width': 1 }, svg);
|
||||||
|
const t = el('text', { x: pad.l - 6, y: y(v) + 4, 'text-anchor': 'end',
|
||||||
|
'font-size': 11, fill: 'var(--muted)' }, svg);
|
||||||
|
t.textContent = fmt(v);
|
||||||
|
}
|
||||||
|
// x labels (sparse)
|
||||||
|
const step = Math.ceil(points.length / 8);
|
||||||
|
points.forEach((p, i) => {
|
||||||
|
if (i % step !== 0 && i !== points.length - 1) return;
|
||||||
|
const t = el('text', { x: x(i), y: H - 8, 'text-anchor': 'middle',
|
||||||
|
'font-size': 11, fill: 'var(--muted)' }, svg);
|
||||||
|
t.textContent = p.label;
|
||||||
|
});
|
||||||
|
|
||||||
|
const d = points.map((p, i) => `${i ? 'L' : 'M'}${x(i).toFixed(1)},${y(p.value).toFixed(1)}`).join(' ');
|
||||||
|
// area fill
|
||||||
|
el('path', {
|
||||||
|
d: `${d} L${x(points.length - 1)},${y(0)} L${x(0)},${y(0)} Z`,
|
||||||
|
fill: 'var(--accent)', opacity: 0.15,
|
||||||
|
}, svg);
|
||||||
|
el('path', { d, fill: 'none', stroke: 'var(--accent)', 'stroke-width': 2 }, svg);
|
||||||
|
points.forEach((p, i) => {
|
||||||
|
const c = el('circle', { cx: x(i), cy: y(p.value), r: 3, fill: 'var(--accent)' }, svg);
|
||||||
|
const title = el('title', {}, c);
|
||||||
|
title.textContent = `${p.label}: ${p.value}`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// barChart(host, rows) — rows: [{label, value}], horizontal bars
|
||||||
|
export function barChart(host, rows, opts = {}) {
|
||||||
|
host.replaceChildren();
|
||||||
|
const W = opts.width || host.clientWidth || 560;
|
||||||
|
const rowH = 26;
|
||||||
|
const pad = { l: 140, r: 48, t: 6, b: 6 };
|
||||||
|
const H = pad.t + pad.b + Math.max(rows.length, 1) * rowH;
|
||||||
|
const svg = el('svg', { viewBox: `0 0 ${W} ${H}`, width: '100%', role: 'img',
|
||||||
|
'aria-label': opts.label || 'bar chart' }, host);
|
||||||
|
if (!rows.length) {
|
||||||
|
const t = el('text', { x: W / 2, y: H / 2 + 4, 'text-anchor': 'middle', fill: 'var(--muted)' }, svg);
|
||||||
|
t.textContent = 'No data in this period';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const max = Math.max(...rows.map((r) => r.value), 1);
|
||||||
|
rows.forEach((r, i) => {
|
||||||
|
const yPos = pad.t + i * rowH;
|
||||||
|
const label = el('text', { x: pad.l - 8, y: yPos + rowH / 2 + 4,
|
||||||
|
'text-anchor': 'end', 'font-size': 12, fill: 'var(--text)' }, svg);
|
||||||
|
label.textContent = r.label.length > 18 ? r.label.slice(0, 17) + '…' : r.label;
|
||||||
|
const wBar = ((W - pad.l - pad.r) * r.value) / max;
|
||||||
|
const rect = el('rect', { x: pad.l, y: yPos + 4, width: Math.max(wBar, 2),
|
||||||
|
height: rowH - 8, fill: 'var(--accent)', rx: 2 }, svg);
|
||||||
|
const title = el('title', {}, rect);
|
||||||
|
title.textContent = `${r.label}: ${r.value}`;
|
||||||
|
const val = el('text', { x: pad.l + Math.max(wBar, 2) + 6, y: yPos + rowH / 2 + 4,
|
||||||
|
'font-size': 12, fill: 'var(--muted)' }, svg);
|
||||||
|
val.textContent = fmt(r.value);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import { api, toast } from '/static/js/api.js';
|
||||||
|
import { lineChart, barChart } from '/static/js/charts.js';
|
||||||
|
|
||||||
|
const errorBox = document.getElementById('error');
|
||||||
|
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({
|
||||||
|
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
||||||
|
}[c]));
|
||||||
|
|
||||||
|
let me = null;
|
||||||
|
|
||||||
|
function rangeQS() {
|
||||||
|
const from = document.getElementById('m-from').value;
|
||||||
|
const to = document.getElementById('m-to').value;
|
||||||
|
const p = new URLSearchParams();
|
||||||
|
if (from) p.set('from', from);
|
||||||
|
if (to) p.set('to', to);
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
function card(label, value, hint) {
|
||||||
|
return `<div class="card"><p class="muted" style="margin:0">${esc(label)}</p>
|
||||||
|
<p style="font-size:1.6rem;font-weight:700;margin:4px 0">${esc(value)}</p>
|
||||||
|
${hint ? `<p class="hint">${esc(hint)}</p>` : ''}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const weekLabel = (iso) => {
|
||||||
|
const d = new Date(iso);
|
||||||
|
return `${d.getMonth() + 1}/${d.getDate()}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function loadDeveloper() {
|
||||||
|
const res = await api('GET', '/api/v1/developer/metrics?' + rangeQS());
|
||||||
|
document.getElementById('dev-metrics').hidden = false;
|
||||||
|
document.getElementById('dev-cards').innerHTML =
|
||||||
|
card('Total bounty earned', res.totalBounty) +
|
||||||
|
card('Tasks completed', res.tasksCompleted) +
|
||||||
|
card('Approval rate', `${Math.round(res.approvalRate * 100)}%`,
|
||||||
|
`${res.approved} approved · ${res.changesRequested} change requests`) +
|
||||||
|
card('Avg assigned → approved', `${res.avgAssignToApproveHours.toFixed(1)} h`) +
|
||||||
|
card('Time logged', `${Math.floor(res.timeLoggedMinutes / 60)}h ${res.timeLoggedMinutes % 60}m`);
|
||||||
|
lineChart(document.getElementById('dev-weekly'),
|
||||||
|
res.weekly.map((p) => ({ label: weekLabel(p.week), value: p.amount })),
|
||||||
|
{ label: 'weekly earnings' });
|
||||||
|
barChart(document.getElementById('dev-customers'),
|
||||||
|
res.perCustomer.map((g) => ({ label: g.key, value: g.amount })),
|
||||||
|
{ label: 'earnings per customer' });
|
||||||
|
document.getElementById('m-csv').href = '/api/v1/developer/metrics?format=csv&' + rangeQS();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadConsultant() {
|
||||||
|
const qs = rangeQS();
|
||||||
|
const cust = document.getElementById('m-customer').value;
|
||||||
|
if (cust) qs.set('customerId', cust);
|
||||||
|
const res = await api('GET', '/api/v1/consultant/metrics?' + qs);
|
||||||
|
document.getElementById('cons-metrics').hidden = false;
|
||||||
|
const sel = document.getElementById('m-customer');
|
||||||
|
if (sel.options.length === 1) {
|
||||||
|
res.customers.forEach((c) => sel.add(new Option(c.name, c.id)));
|
||||||
|
}
|
||||||
|
document.getElementById('cons-cards').innerHTML =
|
||||||
|
card('Bounty awarded', res.totalBounty) +
|
||||||
|
card('Tasks completed', res.tasksCompleted) +
|
||||||
|
card('Atomization lead time', `${res.atomizationLeadHours.toFixed(1)} h`, 'imported → published') +
|
||||||
|
card('Open board depth', res.openBoardDepth, 'published, unclaimed tasks');
|
||||||
|
lineChart(document.getElementById('cons-weekly'),
|
||||||
|
res.weekly.map((p) => ({ label: weekLabel(p.week), value: p.amount })),
|
||||||
|
{ label: 'weekly awards' });
|
||||||
|
barChart(document.getElementById('cons-devs'),
|
||||||
|
res.perDeveloper.map((g) => ({ label: g.key, value: g.amount })),
|
||||||
|
{ label: 'per developer' });
|
||||||
|
barChart(document.getElementById('cons-customers'),
|
||||||
|
res.perCustomer.map((g) => ({ label: g.key, value: g.amount })),
|
||||||
|
{ label: 'per customer' });
|
||||||
|
document.getElementById('m-csv').href = '/api/v1/consultant/metrics?format=csv&' + qs;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadLeaderboard() {
|
||||||
|
const res = await api('GET', '/api/v1/leaderboard?' + rangeQS());
|
||||||
|
const tbody = document.querySelector('#leaderboard tbody');
|
||||||
|
tbody.replaceChildren(...res.leaderboard.map((row, i) => {
|
||||||
|
const tr = document.createElement('tr');
|
||||||
|
tr.innerHTML = `<td>${i + 1}</td><td>${esc(row.name)}</td><td>${row.tasks}</td><td>◈ ${row.amount}</td>`;
|
||||||
|
return tr;
|
||||||
|
}));
|
||||||
|
if (!res.leaderboard.length) {
|
||||||
|
tbody.innerHTML = '<tr><td colspan="4" class="muted">No bounties awarded yet.</td></tr>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAll() {
|
||||||
|
errorBox.textContent = '';
|
||||||
|
try {
|
||||||
|
if (me.user.roles.developer) await loadDeveloper();
|
||||||
|
if (me.user.roles.consultant || me.user.roles.admin) await loadConsultant();
|
||||||
|
await loadLeaderboard();
|
||||||
|
} catch (e) { errorBox.textContent = e.message; }
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('m-apply').addEventListener('click', loadAll);
|
||||||
|
document.getElementById('m-customer').addEventListener('change', loadConsultant);
|
||||||
|
document.getElementById('lb-optout').addEventListener('change', async (e) => {
|
||||||
|
try {
|
||||||
|
await api('PATCH', '/api/v1/profile', { settings: { leaderboardOptOut: e.target.checked } });
|
||||||
|
toast(e.target.checked ? 'You are hidden from the leaderboard.' : 'You are visible on the leaderboard.', 'ok');
|
||||||
|
loadLeaderboard();
|
||||||
|
} catch (err) { toast(err.message, 'err'); }
|
||||||
|
});
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
me = await api('GET', '/api/v1/auth/me');
|
||||||
|
document.getElementById('lb-optout').checked = !!me.user.settings.leaderboardOptOut;
|
||||||
|
loadAll();
|
||||||
|
})();
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
{{define "content"}}
|
||||||
|
<div class="spread">
|
||||||
|
<h1>Metrics</h1>
|
||||||
|
<span>
|
||||||
|
<label class="muted" style="display:inline">From <input type="date" id="m-from" style="width:auto"></label>
|
||||||
|
<label class="muted" style="display:inline">To <input type="date" id="m-to" style="width:auto"></label>
|
||||||
|
<button class="btn small" id="m-apply">Apply</button>
|
||||||
|
<a class="btn small" id="m-csv" href="#">Export CSV</a>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="error-box" id="error" role="alert"></div>
|
||||||
|
|
||||||
|
<section id="dev-metrics" hidden>
|
||||||
|
<div class="grid mt" id="dev-cards"></div>
|
||||||
|
<div class="card mt"><h2>Earnings over time (weekly)</h2><div id="dev-weekly"></div></div>
|
||||||
|
<div class="card mt"><h2>Per customer</h2><div id="dev-customers"></div></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="cons-metrics" hidden>
|
||||||
|
<div class="spread mt">
|
||||||
|
<span>
|
||||||
|
<label class="muted" style="display:inline">Customer
|
||||||
|
<select id="m-customer" style="width:auto"><option value="">All</option></select>
|
||||||
|
</label>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="grid mt" id="cons-cards"></div>
|
||||||
|
<div class="card mt"><h2>Awarded over time (weekly)</h2><div id="cons-weekly"></div></div>
|
||||||
|
<div class="row mt">
|
||||||
|
<div class="card"><h2>Per developer</h2><div id="cons-devs"></div></div>
|
||||||
|
<div class="card"><h2>Per customer</h2><div id="cons-customers"></div></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="card mt">
|
||||||
|
<div class="spread">
|
||||||
|
<h2>Leaderboard — top developers</h2>
|
||||||
|
<label class="muted"><input type="checkbox" id="lb-optout"> Hide me from the leaderboard</label>
|
||||||
|
</div>
|
||||||
|
<table class="list" id="leaderboard">
|
||||||
|
<thead><tr><th>#</th><th>Developer</th><th>Tasks</th><th>Bounty</th></tr></thead>
|
||||||
|
<tbody></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
Reference in New Issue
Block a user