feat(report): cycle-time + WIP/aging metrics (TASK-1631) (#642)

* feat(report): cycle-time + WIP/aging metrics (TASK-1631)

Extend GET /workspaces/{ws}/report with two metric blocks:
- cycle_time: created→positive-terminal duration for completions in the
  window — overall median + p90 + per-collection medians.
- wip: point-in-time open items (done field NOT a terminal value), open count,
  median age, fixed aging bands (<1d/1-7d/7-30d/>30d), per-collection median age.

Medians/percentiles computed in Go from raw durations (dual-dialect: neither
SQLite nor Postgres has a portable percentile). Completed/WIP queries join live
items (deleted_at IS NULL), consistent with the rest of the report.

Wires through web ReportData TS types + `pad project report` rendering.
Tests: cycle-time median (backdated 48h), WIP open-count + aging bands,
percentile helper. Parent: PLAN-1628.

* fix(report): well-formed cycle_time/wip arrays on empty-scope path per Codex review (round 1)

The no-visible-collections early return left cycle_time.by_collection,
wip.aging_buckets, and wip.by_collection as nil → marshaled null, violating
the TS array contract for guests/restricted callers. Initialize those nested
slices in the ReportData literal so every path (including the early return) is
well-formed. Adds a JSON-shape regression test for the empty-scope case.
This commit is contained in:
xarmian
2026-05-29 09:26:39 -04:00
committed by GitHub
parent 949ae03c88
commit d9fab3ea02
4 changed files with 447 additions and 0 deletions
+36
View File
@@ -5296,6 +5296,19 @@ net flow, completed-by-collection, and a current status-distribution snapshot.
Status string `json:"status"`
Count int `json:"count"`
} `json:"status_distribution"`
CycleTime struct {
SampleSize int `json:"sample_size"`
MedianHours float64 `json:"median_hours"`
P90Hours float64 `json:"p90_hours"`
} `json:"cycle_time"`
WIP struct {
OpenCount int `json:"open_count"`
MedianAgeHours float64 `json:"median_age_hours"`
AgingBuckets []struct {
Label string `json:"label"`
Count int `json:"count"`
} `json:"aging_buckets"`
} `json:"wip"`
}
if err := json.Unmarshal(repJSON, &rep); err != nil {
fmt.Println(string(repJSON))
@@ -5362,6 +5375,29 @@ net flow, completed-by-collection, and a current status-distribution snapshot.
fmt.Printf(" %-16s %-14s %d\n", s.Collection, s.Status, s.Count)
}
}
fmtHours := func(h float64) string {
if h >= 48 {
return fmt.Sprintf("%.1fd", h/24)
}
return fmt.Sprintf("%.1fh", h)
}
if rep.CycleTime.SampleSize > 0 {
fmt.Println()
bold.Println(" Cycle time (created → completed)")
fmt.Printf(" median %s p90 %s (n=%d)\n",
fmtHours(rep.CycleTime.MedianHours), fmtHours(rep.CycleTime.P90Hours), rep.CycleTime.SampleSize)
}
if rep.WIP.OpenCount > 0 {
fmt.Println()
bold.Printf(" Work in progress: %d open", rep.WIP.OpenCount)
dim.Printf(" (median age %s)\n", fmtHours(rep.WIP.MedianAgeHours))
for _, b := range rep.WIP.AgingBuckets {
if b.Count > 0 {
fmt.Printf(" %-7s %d\n", b.Label, b.Count)
}
}
}
return nil
},
}
+266
View File
@@ -3,6 +3,7 @@ package store
import (
"encoding/json"
"fmt"
"math"
"sort"
"strings"
"time"
@@ -90,6 +91,39 @@ type ReportTotals struct {
NetFlow int `json:"net_flow"` // Created - Completed
}
// ReportDuration is a per-collection median duration (hours) over a sample.
type ReportDuration struct {
Collection string `json:"collection"`
Count int `json:"count"`
MedianHours float64 `json:"median_hours"`
}
// ReportCycleTime summarizes time from item creation to a positive-terminal
// transition, for completions within the window. Medians/percentiles are
// computed in Go from raw durations (dual-dialect: no SQL percentile).
type ReportCycleTime struct {
SampleSize int `json:"sample_size"`
MedianHours float64 `json:"median_hours"`
P90Hours float64 `json:"p90_hours"`
ByCollection []ReportDuration `json:"by_collection"`
}
// ReportAgingBucket is a count of currently-open items in an age band.
type ReportAgingBucket struct {
Label string `json:"label"` // "<1d" | "1-7d" | "7-30d" | ">30d"
Count int `json:"count"`
}
// ReportWIP is a point-in-time snapshot of work-in-progress: items whose done
// field is NOT a terminal value (open), how long they've sat, and an age
// distribution. Not windowed — reflects the current open set.
type ReportWIP struct {
OpenCount int `json:"open_count"`
MedianAgeHours float64 `json:"median_age_hours"`
AgingBuckets []ReportAgingBucket `json:"aging_buckets"`
ByCollection []ReportDuration `json:"by_collection"` // MedianHours = median open-item age
}
// ReportData is the full report response. This is the stable contract the
// Reports UI (TASK-1633), CLI/MCP (TASK-1635), and charts (TASK-1632) consume.
type ReportData struct {
@@ -102,6 +136,8 @@ type ReportData struct {
Totals ReportTotals `json:"totals"`
CompletedByCollection []ReportCollectionCount `json:"completed_by_collection"`
StatusDistribution []ReportStatusCount `json:"status_distribution"`
CycleTime ReportCycleTime `json:"cycle_time"`
WIP ReportWIP `json:"wip"`
}
// windowSpec maps a window to its lookback duration and bucket granularity.
@@ -125,6 +161,7 @@ type reportCollection struct {
slug string
doneKey string
positiveTerminals []string
allTerminals []string // positive + negative; used to identify still-open (WIP) items
}
// GetReport computes the windowed project report for a workspace.
@@ -162,6 +199,12 @@ func (s *Store) GetReport(workspaceID string, opts ReportOptions) (*ReportData,
Buckets: []ReportBucket{},
CompletedByCollection: []ReportCollectionCount{},
StatusDistribution: []ReportStatusCount{},
// Initialize nested slices so they marshal as [] (not null) on every
// path — including the empty-scope early return below. Matches the TS
// contract (ReportCycleTime.by_collection, ReportWIP.aging_buckets /
// by_collection are always arrays).
CycleTime: ReportCycleTime{ByCollection: []ReportDuration{}},
WIP: ReportWIP{AgingBuckets: []ReportAgingBucket{}, ByCollection: []ReportDuration{}},
}
collIDs := make([]string, 0, len(colls))
slugByID := make(map[string]string, len(colls))
@@ -213,9 +256,138 @@ func (s *Store) GetReport(workspaceID string, opts ReportOptions) (*ReportData,
}
data.StatusDistribution = dist
cycle, err := s.reportCycleTime(workspaceID, colls, startStr, endStr, slugByID)
if err != nil {
return nil, err
}
data.CycleTime = cycle
wip, err := s.reportWIP(workspaceID, colls, now)
if err != nil {
return nil, err
}
data.WIP = wip
return data, nil
}
// reportCycleTime computes created→positive-terminal durations for completions
// within the window, returning overall median/p90 + per-collection medians.
// Each positive-terminal transition in the window is one sample (an item
// completed twice contributes twice — consistent with the completed counts).
func (s *Store) reportCycleTime(workspaceID string, colls []reportCollection, startStr, endStr string, slugByID map[string]string) (ReportCycleTime, error) {
posExpr, posArgs := s.positiveTerminalExpr(colls)
if posExpr == "" {
return ReportCycleTime{ByCollection: []ReportDuration{}}, nil
}
args := append([]any{workspaceID, startStr, endStr}, posArgs...)
query := fmt.Sprintf(`
SELECT st.collection_id, i.created_at, st.created_at
FROM status_transitions st
JOIN items i ON i.id = st.item_id AND i.deleted_at IS NULL
WHERE st.workspace_id = ? AND st.created_at >= ? AND st.created_at <= ?
AND %s
`, posExpr)
rows, err := s.db.Query(s.q(query), args...)
if err != nil {
return ReportCycleTime{}, fmt.Errorf("report cycle-time: %w", err)
}
defer rows.Close()
var overall []float64
perColl := map[string][]float64{}
for rows.Next() {
var collID, createdAt, doneAt string
if err := rows.Scan(&collID, &createdAt, &doneAt); err != nil {
return ReportCycleTime{}, fmt.Errorf("scan cycle-time: %w", err)
}
h, ok := durationHours(createdAt, doneAt)
if !ok {
continue
}
overall = append(overall, h)
perColl[collID] = append(perColl[collID], h)
}
if err := rows.Err(); err != nil {
return ReportCycleTime{}, err
}
ct := ReportCycleTime{
SampleSize: len(overall),
MedianHours: percentile(overall, 0.5),
P90Hours: percentile(overall, 0.9),
ByCollection: durationsByCollection(perColl, slugByID),
}
return ct, nil
}
// reportWIP snapshots currently-open items (done field NOT a terminal value),
// their age (now - created_at), and an age-band distribution. Point-in-time.
func (s *Store) reportWIP(workspaceID string, colls []reportCollection, now time.Time) (ReportWIP, error) {
wip := ReportWIP{
AgingBuckets: []ReportAgingBucket{},
ByCollection: []ReportDuration{},
}
var overall []float64
perColl := map[string][]float64{}
slugByID := map[string]string{}
for _, c := range colls {
slugByID[c.id] = c.slug
fieldExpr := s.dialect.JSONExtractText("fields", c.doneKey)
// Open = done-field value NOT in this collection's terminal set. An
// item with no done-field value (NULL/"") is also open.
var notTerminal string
var args []any
args = append(args, workspaceID, c.id)
if len(c.allTerminals) > 0 {
ph := make([]string, len(c.allTerminals))
for i, v := range c.allTerminals {
ph[i] = "?"
args = append(args, strings.ToLower(v))
}
notTerminal = fmt.Sprintf("LOWER(COALESCE(%s, '')) NOT IN (%s)", fieldExpr, strings.Join(ph, ","))
} else {
notTerminal = "1=1"
}
query := fmt.Sprintf(`
SELECT created_at FROM items
WHERE workspace_id = ? AND collection_id = ? AND deleted_at IS NULL
AND %s
`, notTerminal)
rows, err := s.db.Query(s.q(query), args...)
if err != nil {
return ReportWIP{}, fmt.Errorf("report wip: %w", err)
}
func() {
defer rows.Close()
for rows.Next() {
var createdAt string
if scanErr := rows.Scan(&createdAt); scanErr != nil {
err = fmt.Errorf("scan wip: %w", scanErr)
return
}
h, ok := ageHours(createdAt, now)
if !ok {
continue
}
overall = append(overall, h)
perColl[c.id] = append(perColl[c.id], h)
}
err = rows.Err()
}()
if err != nil {
return ReportWIP{}, err
}
}
wip.OpenCount = len(overall)
wip.MedianAgeHours = percentile(overall, 0.5)
wip.ByCollection = durationsByCollection(perColl, slugByID)
wip.AgingBuckets = agingBuckets(overall)
return wip, nil
}
// resolveReportCollections lists the workspace's collections (optionally
// filtered to the given slugs) and resolves each one's done field + positive
// terminal values.
@@ -271,6 +443,7 @@ func (s *Store) resolveReportCollections(workspaceID string, opts ReportOptions)
slug: c.Slug,
doneKey: doneKey,
positiveTerminals: positives,
allTerminals: terminals,
})
}
return out, nil
@@ -475,3 +648,96 @@ func (s *Store) zeroFilledBuckets(start, end time.Time, gran string, created, co
}
return out
}
// --- cycle-time / WIP helpers (PLAN-1628 / TASK-1631) ---
// durationHours returns hours between two RFC3339 timestamps (end-start),
// clamped at 0. ok is false when either fails to parse.
func durationHours(startStr, endStr string) (float64, bool) {
st, e1 := time.Parse(time.RFC3339, startStr)
en, e2 := time.Parse(time.RFC3339, endStr)
if e1 != nil || e2 != nil {
return 0, false
}
h := en.Sub(st).Hours()
if h < 0 {
h = 0
}
return h, true
}
// ageHours returns hours from an RFC3339 created timestamp to now, clamped at 0.
func ageHours(createdStr string, now time.Time) (float64, bool) {
c, err := time.Parse(time.RFC3339, createdStr)
if err != nil {
return 0, false
}
h := now.Sub(c).Hours()
if h < 0 {
h = 0
}
return h, true
}
// percentile returns the linearly-interpolated p-quantile (p in [0,1]) of vals,
// rounded to 1 decimal. 0 for an empty sample. Computed in Go so the same code
// runs identically on SQLite and Postgres (neither has a portable MEDIAN).
func percentile(vals []float64, p float64) float64 {
if len(vals) == 0 {
return 0
}
s := append([]float64(nil), vals...)
sort.Float64s(s)
if len(s) == 1 {
return round1(s[0])
}
rank := p * float64(len(s)-1)
lo := int(math.Floor(rank))
hi := int(math.Ceil(rank))
if lo == hi {
return round1(s[lo])
}
frac := rank - float64(lo)
return round1(s[lo] + (s[hi]-s[lo])*frac)
}
func round1(x float64) float64 { return math.Round(x*10) / 10 }
// durationsByCollection turns per-collection samples into median+count entries,
// ordered by count desc then slug.
func durationsByCollection(perColl map[string][]float64, slugByID map[string]string) []ReportDuration {
out := []ReportDuration{}
for id, vals := range perColl {
out = append(out, ReportDuration{
Collection: slugByID[id],
Count: len(vals),
MedianHours: percentile(vals, 0.5),
})
}
sort.Slice(out, func(i, j int) bool {
if out[i].Count != out[j].Count {
return out[i].Count > out[j].Count
}
return out[i].Collection < out[j].Collection
})
return out
}
// agingBuckets distributes open-item ages (hours) into fixed bands. Always
// returns all four bands in order (zero counts included) for a stable shape.
func agingBuckets(ages []float64) []ReportAgingBucket {
buckets := []ReportAgingBucket{{Label: "<1d"}, {Label: "1-7d"}, {Label: "7-30d"}, {Label: ">30d"}}
for _, h := range ages {
switch {
case h < 24:
buckets[0].Count++
case h < 24*7:
buckets[1].Count++
case h < 24*30:
buckets[2].Count++
default:
buckets[3].Count++
}
}
return buckets
}
+113
View File
@@ -1,6 +1,8 @@
package store
import (
"encoding/json"
"strings"
"testing"
"time"
@@ -268,3 +270,114 @@ func TestGetReport_DayWindowBucketsByHour(t *testing.T) {
t.Fatalf("hourly bucket label should be 13 chars, got %q", rep.Buckets[0].Bucket)
}
}
func backdateItem(t *testing.T, s *Store, itemID string, hoursAgo float64) {
t.Helper()
ts := time.Now().UTC().Add(-time.Duration(hoursAgo * float64(time.Hour))).Format(time.RFC3339)
if _, err := s.db.Exec(s.dialect.Rebind(`UPDATE items SET created_at = ? WHERE id = ?`), ts, itemID); err != nil {
t.Fatalf("backdate %s: %v", itemID, err)
}
}
func TestGetReport_CycleTime(t *testing.T) {
s := testStore(t)
wsID, colID := newTransitionTestWorkspace(t, s)
item := createTestItem(t, s, wsID, colID, "slow task", "")
backdateItem(t, s, item.ID, 48) // created 48h ago
if _, err := s.UpdateItem(item.ID, models.ItemUpdate{Fields: strPtr(`{"status":"done"}`)}); err != nil {
t.Fatalf("complete: %v", err) // completion transition stamped ~now → cycle ≈ 48h
}
rep, err := s.GetReport(wsID, ReportOptions{Window: "week", Now: time.Now().UTC()})
if err != nil {
t.Fatalf("GetReport: %v", err)
}
if rep.CycleTime.SampleSize != 1 {
t.Fatalf("expected 1 cycle-time sample, got %d", rep.CycleTime.SampleSize)
}
if rep.CycleTime.MedianHours < 47 || rep.CycleTime.MedianHours > 49 {
t.Fatalf("expected median ~48h, got %.1f", rep.CycleTime.MedianHours)
}
if collDurMedian(rep.CycleTime.ByCollection, "tasks") < 47 {
t.Fatalf("expected tasks cycle-time median ~48h, got %+v", rep.CycleTime.ByCollection)
}
}
func TestGetReport_WIPAndAging(t *testing.T) {
s := testStore(t)
wsID, colID := newTransitionTestWorkspace(t, s)
a := createTestItem(t, s, wsID, colID, "old open", "")
backdateItem(t, s, a.ID, 100) // ~4.2d old, still open
createTestItem(t, s, wsID, colID, "fresh open", "") // <1d old, open
done := createTestItem(t, s, wsID, colID, "shipped", "")
if _, err := s.UpdateItem(done.ID, models.ItemUpdate{Fields: strPtr(`{"status":"done"}`)}); err != nil {
t.Fatalf("complete: %v", err)
}
rep, err := s.GetReport(wsID, ReportOptions{Window: "week", Now: time.Now().UTC()})
if err != nil {
t.Fatalf("GetReport: %v", err)
}
if rep.WIP.OpenCount != 2 {
t.Fatalf("expected 2 open items (done excluded), got %d", rep.WIP.OpenCount)
}
// Aging: one <1d, one 1-7d.
band := map[string]int{}
for _, b := range rep.WIP.AgingBuckets {
band[b.Label] = b.Count
}
if band["<1d"] != 1 || band["1-7d"] != 1 {
t.Fatalf("expected aging <1d=1, 1-7d=1, got %+v", rep.WIP.AgingBuckets)
}
if len(rep.WIP.AgingBuckets) != 4 {
t.Fatalf("expected 4 fixed aging bands, got %d", len(rep.WIP.AgingBuckets))
}
}
func collDurMedian(list []ReportDuration, slug string) float64 {
for _, d := range list {
if d.Collection == slug {
return d.MedianHours
}
}
return -1
}
func TestPercentile(t *testing.T) {
if got := percentile(nil, 0.5); got != 0 {
t.Errorf("empty percentile = %v, want 0", got)
}
if got := percentile([]float64{10}, 0.9); got != 10 {
t.Errorf("single percentile = %v, want 10", got)
}
// median of 1..5 = 3; p90 ≈ 4.6
if got := percentile([]float64{1, 2, 3, 4, 5}, 0.5); got != 3 {
t.Errorf("median = %v, want 3", got)
}
if got := percentile([]float64{1, 2, 3, 4, 5}, 0.9); got < 4.5 || got > 4.7 {
t.Errorf("p90 = %v, want ~4.6", got)
}
}
func TestGetReport_EmptyScopeWellFormedJSON(t *testing.T) {
s := testStore(t)
wsID, _ := newTransitionTestWorkspace(t, s)
// Empty visible set → no collections in scope (early-return path).
rep, err := s.GetReport(wsID, ReportOptions{Window: "week", Now: time.Now().UTC(), ScopeToVisible: true, VisibleCollectionIDs: nil})
if err != nil {
t.Fatalf("GetReport: %v", err)
}
b, err := json.Marshal(rep)
if err != nil {
t.Fatalf("marshal: %v", err)
}
js := string(b)
for _, frag := range []string{
`"by_collection":null`, `"aging_buckets":null`, `"buckets":null`,
`"completed_by_collection":null`, `"status_distribution":null`, `"collections":null`,
} {
if strings.Contains(js, frag) {
t.Errorf("empty report has %s — should be []", frag)
}
}
}
+32
View File
@@ -846,6 +846,36 @@ export interface ReportTotals {
net_flow: number;
}
/** Per-collection median duration in hours over a sample. */
export interface ReportDuration {
collection: string;
count: number;
median_hours: number;
}
/** Time from item creation to a positive-terminal transition (completions in window). */
export interface ReportCycleTime {
sample_size: number;
median_hours: number;
p90_hours: number;
by_collection: ReportDuration[];
}
export interface ReportAgingBucket {
/** "<1d" | "1-7d" | "7-30d" | ">30d" */
label: string;
count: number;
}
/** Point-in-time work-in-progress: open items (non-terminal), their age + distribution. */
export interface ReportWIP {
open_count: number;
median_age_hours: number;
aging_buckets: ReportAgingBucket[];
/** median_hours = median open-item age, per collection */
by_collection: ReportDuration[];
}
/**
* Windowed project report. "completed" counts a status change into a positive
* terminal value (terminal options minus negative outcomes like
@@ -862,6 +892,8 @@ export interface ReportData {
totals: ReportTotals;
completed_by_collection: ReportCollectionCount[];
status_distribution: ReportStatusCount[];
cycle_time: ReportCycleTime;
wip: ReportWIP;
}
// ─── Incremental Sync ────────────────────────────────────────────────────────