mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-11 13:28:57 +00:00
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:
@@ -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
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 ────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user