diff --git a/cmd/pad/main.go b/cmd/pad/main.go index f01f837c..cb71287b 100644 --- a/cmd/pad/main.go +++ b/cmd/pad/main.go @@ -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 }, } diff --git a/internal/store/reports.go b/internal/store/reports.go index d00e1792..215b24aa 100644 --- a/internal/store/reports.go +++ b/internal/store/reports.go @@ -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 +} diff --git a/internal/store/reports_test.go b/internal/store/reports_test.go index e13d78b2..07e4f218 100644 --- a/internal/store/reports_test.go +++ b/internal/store/reports_test.go @@ -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) + } + } +} diff --git a/web/src/lib/types/index.ts b/web/src/lib/types/index.ts index 944bc386..1ae98020 100644 --- a/web/src/lib/types/index.ts +++ b/web/src/lib/types/index.ts @@ -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 ────────────────────────────────────────────────────────