Files
kanban_cpp/backend/metrics.go
T
Egutierrez a76ec74338 feat: initial scaffold kanban_cpp v0.1.0
C++ ImGui kanban for steering LLM agents. Six panels (Board, Calendar,
Dashboard, Agent runs, Worktrees, DoD inspector) wired to registry
functions http_request, kpi_card, sparkline, agent_runs_timeline,
dod_evidence_panel. Backend Go on :8403 (independent operations.db from
kanban_web).
2026-05-18 18:46:09 +02:00

604 lines
16 KiB
Go

package main
import (
"net/http"
"sort"
"strings"
"time"
"fn-registry/functions/core"
"fn-registry/functions/datascience"
"fn-registry/functions/infra"
)
type DurationStats = datascience.DurationStats
type Metrics struct {
Range DateRange `json:"range"`
Totals Totals `json:"totals"`
ByColumn []ColumnCount `json:"by_column"`
ThroughputDaily []DailyCount `json:"throughput_daily"`
CreatedDaily []DailyCount `json:"created_daily"`
LeadTime DurationStats `json:"lead_time"`
CycleTimeColumn []ColumnDuration `json:"cycle_time_per_column"`
TopAssignees []AssigneeStat `json:"top_assignees"`
TopRequesters []RequesterStat `json:"top_requesters"`
MovementsByUser []MovementStat `json:"movements_by_user"`
LockTotalMs int64 `json:"lock_total_ms"`
LockActiveCount int `json:"lock_active_count"`
CumulativeFlow []CumulativePoint `json:"cumulative_flow"`
}
type CumulativePoint struct {
Date string `json:"date"`
Total int `json:"total"`
Done int `json:"done"`
}
type DateRange struct {
From string `json:"from"`
To string `json:"to"`
}
type Totals struct {
Cards int `json:"cards"`
CardsCompleted int `json:"cards_completed_in_range"`
CardsCreated int `json:"cards_created_in_range"`
CardsActive int `json:"cards_active"`
CardsDone int `json:"cards_done"`
Columns int `json:"columns"`
Users int `json:"users"`
ActiveLocks int `json:"active_locks"`
}
type ColumnCount struct {
ColumnID string `json:"column_id"`
Name string `json:"name"`
IsDone bool `json:"is_done"`
Count int `json:"count"`
}
type DailyCount struct {
Date string `json:"date"`
Count int `json:"count"`
}
type ColumnDuration struct {
ColumnID string `json:"column_id"`
Name string `json:"name"`
IsDone bool `json:"is_done"`
Stats DurationStats `json:"stats"`
}
type AssigneeStat struct {
UserID string `json:"user_id"`
Username string `json:"username"`
DisplayName string `json:"display_name"`
Active int `json:"active"`
Completed int `json:"completed_in_range"`
}
type RequesterStat struct {
Requester string `json:"requester"`
Total int `json:"total"`
Active int `json:"active"`
Completed int `json:"completed_in_range"`
}
type MovementStat struct {
UserID string `json:"user_id"`
Username string `json:"username"`
DisplayName string `json:"display_name"`
Moves int `json:"moves"`
}
func computeStats(durations []int64) DurationStats {
return datascience.DurationStatsFrom(durations)
}
func parseDateOrDefault(s string, dflt time.Time) time.Time {
return core.ParseDateOrDefault(s, dflt, false)
}
func parseEndDateOrDefault(s string, dflt time.Time) time.Time {
return core.ParseDateOrDefault(s, dflt, true)
}
// GET /api/metrics?from=YYYY-MM-DD&to=YYYY-MM-DD&assignee_id=...&requester=...
func handleMetrics(db *DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
now := time.Now().UTC()
from := parseDateOrDefault(r.URL.Query().Get("from"), now.AddDate(0, 0, -30))
to := parseEndDateOrDefault(r.URL.Query().Get("to"), now)
assignee := r.URL.Query().Get("assignee_id")
requester := r.URL.Query().Get("requester")
tagsRaw := r.URL.Query().Get("tags")
var tags []string
if tagsRaw != "" {
for _, t := range strings.Split(tagsRaw, ",") {
if t = strings.TrimSpace(t); t != "" {
tags = append(tags, t)
}
}
}
m, err := computeMetrics(db, from, to, assignee, requester, tags)
if err != nil {
serverError(w, err)
return
}
infra.HTTPJSONResponse(w, http.StatusOK, m)
}
}
func computeMetrics(db *DB, from, to time.Time, assignee, requester string, tags []string) (*Metrics, error) {
fromStr := from.Format(time.RFC3339Nano)
toStr := to.Format(time.RFC3339Nano)
m := &Metrics{
Range: DateRange{From: from.Format("2006-01-02"), To: to.Format("2006-01-02")},
ByColumn: []ColumnCount{},
ThroughputDaily: []DailyCount{},
CreatedDaily: []DailyCount{},
CycleTimeColumn: []ColumnDuration{},
TopAssignees: []AssigneeStat{},
TopRequesters: []RequesterStat{},
MovementsByUser: []MovementStat{},
CumulativeFlow: []CumulativePoint{},
}
cardWhere := "WHERE deleted_at IS NULL"
args := []any{}
if assignee != "" {
cardWhere += " AND assignee_id=?"
args = append(args, assignee)
}
if requester != "" {
cardWhere += " AND requester=?"
args = append(args, requester)
}
for _, t := range tags {
cardWhere += " AND tags LIKE ?"
args = append(args, `%"`+t+`"%`)
}
if err := db.conn.QueryRow(`SELECT COUNT(*) FROM cards `+cardWhere, args...).Scan(&m.Totals.Cards); err != nil {
return nil, err
}
completedArgs := append([]any{fromStr, toStr}, args...)
if err := db.conn.QueryRow(
`SELECT COUNT(*) FROM cards `+cardWhere+` AND completed_at IS NOT NULL AND completed_at>=? AND completed_at<=?`,
append(args, fromStr, toStr)...,
).Scan(&m.Totals.CardsCompleted); err != nil {
return nil, err
}
if err := db.conn.QueryRow(
`SELECT COUNT(*) FROM cards `+cardWhere+` AND created_at>=? AND created_at<=?`,
append(args, fromStr, toStr)...,
).Scan(&m.Totals.CardsCreated); err != nil {
return nil, err
}
if err := db.conn.QueryRow(
`SELECT COUNT(*) FROM cards `+cardWhere+` AND (completed_at IS NULL OR completed_at='')`,
args...,
).Scan(&m.Totals.CardsActive); err != nil {
return nil, err
}
if err := db.conn.QueryRow(
`SELECT COUNT(*) FROM cards `+cardWhere+` AND completed_at IS NOT NULL AND completed_at!=''`,
args...,
).Scan(&m.Totals.CardsDone); err != nil {
return nil, err
}
_ = completedArgs
if err := db.conn.QueryRow(`SELECT COUNT(*) FROM columns`).Scan(&m.Totals.Columns); err != nil {
return nil, err
}
if err := db.conn.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&m.Totals.Users); err != nil {
return nil, err
}
lockActiveQ := `SELECT COUNT(*) FROM card_lock_history h JOIN cards c ON c.id=h.card_id WHERE h.unlocked_at IS NULL AND c.deleted_at IS NULL`
if assignee != "" {
lockActiveQ += ` AND c.assignee_id=?`
}
if requester != "" {
lockActiveQ += ` AND c.requester=?`
}
if err := db.conn.QueryRow(lockActiveQ, args...).Scan(&m.Totals.ActiveLocks); err != nil {
return nil, err
}
// By column.
rows, err := db.conn.Query(
`SELECT col.id, col.name, col.is_done, COUNT(c.id)
FROM columns col
LEFT JOIN cards c ON c.column_id=col.id`+
condFromCard(assignee, requester, "c", "WHERE")+
` GROUP BY col.id ORDER BY col.position`,
colArgs(assignee, requester)...,
)
if err != nil {
return nil, err
}
for rows.Next() {
var cc ColumnCount
var isDone int
if err := rows.Scan(&cc.ColumnID, &cc.Name, &isDone, &cc.Count); err != nil {
rows.Close()
return nil, err
}
cc.IsDone = isDone != 0
m.ByColumn = append(m.ByColumn, cc)
}
rows.Close()
// Throughput daily (completed_at within range).
m.ThroughputDaily, err = dailyBucket(db, "completed_at", fromStr, toStr, assignee, requester, true)
if err != nil {
return nil, err
}
m.CreatedDaily, err = dailyBucket(db, "created_at", fromStr, toStr, assignee, requester, false)
if err != nil {
return nil, err
}
// Lead time (cards completed in range, completed_at - created_at).
leadDurs, err := collectDurations(db,
`SELECT (julianday(completed_at) - julianday(created_at)) * 86400000 FROM cards `+
cardWhere+` AND completed_at IS NOT NULL AND completed_at>=? AND completed_at<=?`,
append(args, fromStr, toStr)...,
)
if err != nil {
return nil, err
}
m.LeadTime = computeStats(leadDurs)
// Cycle time per column.
colRows, err := db.conn.Query(`SELECT id, name, is_done FROM columns ORDER BY position`)
if err != nil {
return nil, err
}
type colInfo struct {
id, name string
isDone bool
}
var cols []colInfo
for colRows.Next() {
var ci colInfo
var d int
if err := colRows.Scan(&ci.id, &ci.name, &d); err != nil {
colRows.Close()
return nil, err
}
ci.isDone = d != 0
cols = append(cols, ci)
}
colRows.Close()
now := time.Now().UTC()
cap := to
if now.Before(cap) {
cap = now
}
capStr := cap.Format(time.RFC3339Nano)
for _, ci := range cols {
histArgs := []any{ci.id, fromStr, toStr}
histQ := `SELECT (julianday(COALESCE(h.exited_at, ?)) - julianday(h.entered_at)) * 86400000
FROM card_column_history h JOIN cards c ON c.id=h.card_id
WHERE h.column_id=? AND h.entered_at>=? AND h.entered_at<=?`
histArgs = append([]any{capStr}, histArgs...)
if assignee != "" {
histQ += ` AND c.assignee_id=?`
histArgs = append(histArgs, assignee)
}
if requester != "" {
histQ += ` AND c.requester=?`
histArgs = append(histArgs, requester)
}
durs, err := collectDurations(db, histQ, histArgs...)
if err != nil {
return nil, err
}
m.CycleTimeColumn = append(m.CycleTimeColumn, ColumnDuration{
ColumnID: ci.id, Name: ci.name, IsDone: ci.isDone,
Stats: computeStats(durs),
})
}
// Top assignees.
asRows, err := db.conn.Query(
`SELECT u.id, u.username, u.display_name,
SUM(CASE WHEN c.completed_at IS NULL OR c.completed_at='' THEN 1 ELSE 0 END) as active,
SUM(CASE WHEN c.completed_at IS NOT NULL AND c.completed_at>=? AND c.completed_at<=? THEN 1 ELSE 0 END) as completed
FROM users u
LEFT JOIN cards c ON c.assignee_id=u.id` + cardJoinFilter(requester) +
` GROUP BY u.id ORDER BY completed DESC, active DESC`,
topAssigneeArgs(fromStr, toStr, requester)...,
)
if err != nil {
return nil, err
}
for asRows.Next() {
var s AssigneeStat
if err := asRows.Scan(&s.UserID, &s.Username, &s.DisplayName, &s.Active, &s.Completed); err != nil {
asRows.Close()
return nil, err
}
m.TopAssignees = append(m.TopAssignees, s)
}
asRows.Close()
// Top requesters.
reqRows, err := db.conn.Query(
`SELECT requester,
COUNT(*) as total,
SUM(CASE WHEN completed_at IS NULL OR completed_at='' THEN 1 ELSE 0 END) as active,
SUM(CASE WHEN completed_at IS NOT NULL AND completed_at>=? AND completed_at<=? THEN 1 ELSE 0 END) as completed
FROM cards
WHERE deleted_at IS NULL AND requester != ''`+
condFromCard(assignee, "", "", "AND")+
` GROUP BY requester ORDER BY total DESC LIMIT 10`,
topReqArgs(fromStr, toStr, assignee)...,
)
if err != nil {
return nil, err
}
for reqRows.Next() {
var s RequesterStat
if err := reqRows.Scan(&s.Requester, &s.Total, &s.Active, &s.Completed); err != nil {
reqRows.Close()
return nil, err
}
m.TopRequesters = append(m.TopRequesters, s)
}
reqRows.Close()
// Movements by user.
mvRows, err := db.conn.Query(
`SELECT u.id, u.username, u.display_name, COUNT(h.id) as moves
FROM users u
LEFT JOIN card_column_history h ON h.actor_id=u.id AND h.entered_at>=? AND h.entered_at<=?
GROUP BY u.id ORDER BY moves DESC`,
fromStr, toStr,
)
if err != nil {
return nil, err
}
for mvRows.Next() {
var s MovementStat
if err := mvRows.Scan(&s.UserID, &s.Username, &s.DisplayName, &s.Moves); err != nil {
mvRows.Close()
return nil, err
}
m.MovementsByUser = append(m.MovementsByUser, s)
}
mvRows.Close()
// Lock total in range.
var lockMs float64
if err := db.conn.QueryRow(
`SELECT COALESCE(SUM(
(julianday(COALESCE(h.unlocked_at, ?)) - julianday(h.locked_at)) * 86400000
), 0) FROM card_lock_history h JOIN cards c ON c.id=h.card_id
WHERE h.locked_at>=? AND h.locked_at<=?`+condFromCard(assignee, requester, "c", "AND"),
append([]any{toStr, fromStr, toStr}, colArgs(assignee, requester)...)...,
).Scan(&lockMs); err != nil {
return nil, err
}
m.LockTotalMs = int64(lockMs)
// Cumulative flow: walk daily from→to, count cards created<=day and done<=day.
cfd, err := computeCumulativeFlow(db, from, to, assignee, requester)
if err != nil {
return nil, err
}
m.CumulativeFlow = cfd
return m, nil
}
func computeCumulativeFlow(db *DB, from, to time.Time, assignee, requester string) ([]CumulativePoint, error) {
creates := map[string]int{}
dones := map[string]int{}
cardWhere := "WHERE deleted_at IS NULL"
args := []any{}
if assignee != "" {
cardWhere += " AND assignee_id=?"
args = append(args, assignee)
}
if requester != "" {
cardWhere += " AND requester=?"
args = append(args, requester)
}
rows, err := db.conn.Query(`SELECT substr(created_at,1,10), COUNT(*) FROM cards `+cardWhere+` GROUP BY substr(created_at,1,10)`, args...)
if err != nil {
return nil, err
}
for rows.Next() {
var d string
var n int
if err := rows.Scan(&d, &n); err != nil {
rows.Close()
return nil, err
}
creates[d] = n
}
rows.Close()
rows, err = db.conn.Query(`SELECT substr(completed_at,1,10), COUNT(*) FROM cards `+cardWhere+` AND completed_at IS NOT NULL AND completed_at != '' GROUP BY substr(completed_at,1,10)`, args...)
if err != nil {
return nil, err
}
for rows.Next() {
var d string
var n int
if err := rows.Scan(&d, &n); err != nil {
rows.Close()
return nil, err
}
dones[d] = n
}
rows.Close()
out := []CumulativePoint{}
totalAcc := 0
doneAcc := 0
day := from
end := to
if end.Before(day) {
return out, nil
}
for d := day; !d.After(end); d = d.AddDate(0, 0, 1) {
ds := d.Format("2006-01-02")
// Sum all creates with key <= ds, all dones with key <= ds.
// Optimize: track keys already accounted; here we just do once per loop using map sums.
_ = ds
}
// Simpler: collect and sort all create/done dates, sweep.
type ev struct {
date string
creates int
dones int
}
all := map[string]*ev{}
for d, n := range creates {
all[d] = &ev{date: d, creates: n}
}
for d, n := range dones {
if e, ok := all[d]; ok {
e.dones = n
} else {
all[d] = &ev{date: d, dones: n}
}
}
dates := make([]string, 0, len(all))
for d := range all {
dates = append(dates, d)
}
sort.Strings(dates)
// Accumulate up to `from` first.
fromS := from.Format("2006-01-02")
idx := 0
for idx < len(dates) && dates[idx] < fromS {
totalAcc += all[dates[idx]].creates
doneAcc += all[dates[idx]].dones
idx++
}
for d := from; !d.After(to); d = d.AddDate(0, 0, 1) {
ds := d.Format("2006-01-02")
for idx < len(dates) && dates[idx] <= ds {
totalAcc += all[dates[idx]].creates
doneAcc += all[dates[idx]].dones
idx++
}
out = append(out, CumulativePoint{Date: ds, Total: totalAcc, Done: doneAcc})
}
return out, nil
}
func condFromCard(assignee, requester, alias, leadKw string) string {
pref := alias
if pref != "" {
pref += "."
}
out := ""
if assignee != "" {
out += " " + leadKw + " " + pref + "assignee_id=?"
leadKw = "AND"
}
if requester != "" {
out += " " + leadKw + " " + pref + "requester=?"
}
return out
}
func colArgs(assignee, requester string) []any {
args := []any{}
if assignee != "" {
args = append(args, assignee)
}
if requester != "" {
args = append(args, requester)
}
return args
}
func cardJoinFilter(requester string) string {
if requester != "" {
return " AND c.requester=?"
}
return ""
}
func topAssigneeArgs(fromStr, toStr, requester string) []any {
args := []any{fromStr, toStr}
if requester != "" {
args = append(args, requester)
}
return args
}
func topReqArgs(fromStr, toStr, assignee string) []any {
args := []any{fromStr, toStr}
if assignee != "" {
args = append(args, assignee)
}
return args
}
func collectDurations(db *DB, query string, args ...any) ([]int64, error) {
rows, err := db.conn.Query(query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
out := []int64{}
for rows.Next() {
var v float64
if err := rows.Scan(&v); err != nil {
return nil, err
}
if v < 0 {
v = 0
}
out = append(out, int64(v))
}
return out, rows.Err()
}
func dailyBucket(db *DB, dateCol, fromStr, toStr, assignee, requester string, requireNonNull bool) ([]DailyCount, error) {
cardWhere := "deleted_at IS NULL"
if requireNonNull {
cardWhere += " AND " + dateCol + " IS NOT NULL AND " + dateCol + " != ''"
}
cardWhere += " AND " + dateCol + ">=? AND " + dateCol + "<=?"
args := []any{fromStr, toStr}
if assignee != "" {
cardWhere += " AND assignee_id=?"
args = append(args, assignee)
}
if requester != "" {
cardWhere += " AND requester=?"
args = append(args, requester)
}
q := `SELECT substr(` + dateCol + `, 1, 10) as d, COUNT(*) FROM cards WHERE ` + cardWhere + ` GROUP BY d ORDER BY d`
rows, err := db.conn.Query(q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
out := []DailyCount{}
for rows.Next() {
var dc DailyCount
if err := rows.Scan(&dc.Date, &dc.Count); err != nil {
return nil, err
}
out = append(out, dc)
}
return out, rows.Err()
}