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).
This commit is contained in:
@@ -0,0 +1,603 @@
|
||||
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()
|
||||
}
|
||||
Reference in New Issue
Block a user