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() }