Files
pad/internal/store/reports.go
T
杨成锴 22c5a858a1 fix(server): stop counting disabled conventions as completed work (#1152)
Merged after two codex review rounds (converged) on top of the community-loop supply-chain/static review. Review found two narrow follow-ups — the guest item-grant leg of the grouped terminal query keeps pre-PR over-matching semantics, and the standup/changelog display layer hardcodes `status` — both pre-existing edges, filed internally as follow-up work. Thanks @asjdf for a well-tested fix, and for honoring the per-collection terminal_options contract on both the CLI and server paths.
2026-08-18 10:44:50 -04:00

986 lines
34 KiB
Go

package store
import (
"database/sql"
"encoding/json"
"fmt"
"math"
"sort"
"strings"
"time"
"github.com/PerpetualSoftware/pad/internal/models"
)
// Report aggregation (PLAN-1628 / TASK-1630).
//
// GetReport produces a time-windowed project report: created-vs-completed
// throughput bucketed over a window, net flow, completed-by-collection, and a
// current status-distribution snapshot. "Completed" is a status_transitions
// row INTO a *positive* terminal value (a terminal option that isn't a
// negative outcome like rejected/cancelled — see models.NegativeTerminals),
// counted per the collection's done field. Created counts items.created_at.
//
// Everything routes date math through dialect.DateBucket so the same query
// runs on SQLite and Postgres.
// ReportOptions parameterizes GetReport.
type ReportOptions struct {
// Window is one of "day", "week", "2wk", "month". Invalid/empty defaults
// to "week".
Window string
// Collections optionally restricts the report to these collection slugs.
// Empty means all of the workspace's collections.
Collections []string
// Now is the reference end of the window; zero means time.Now().UTC().
// Injectable so tests are deterministic.
Now time.Time
// Offset steps the window back by whole window-lengths (0 = current period,
// 1 = the period immediately before, …). Negative is clamped to 0.
Offset int
// IncludeItems, when true, populates CompletedItems (the "what shipped"
// list) — opt-in because the interactive dashboard only needs counts; the
// print/export report requests it.
IncludeItems bool
// ScopeToVisible, when true, restricts the report to VisibleCollectionIDs
// (the caller's visible collection set). Aggregate counts for collections
// the caller can't see are never computed — preventing a member/guest from
// inferring hidden collections' throughput or status distribution. The
// HTTP handler sets this from visibleCollectionIDs(); internal/CLI callers
// that already run as an authorized principal leave it false for the full
// workspace report.
ScopeToVisible bool
VisibleCollectionIDs []string
}
// completedItemsCap bounds the "what shipped" list so a pathological window
// can't produce an unbounded payload; the rest is reported via the overflow
// count.
const completedItemsCap = 500
// ReportBucket is one time-series point: items created and completed within
// the bucket. Bucket is a sortable UTC label ("YYYY-MM-DD", or
// "YYYY-MM-DDTHH" for the hourly window).
type ReportBucket struct {
Bucket string `json:"bucket"`
Created int `json:"created"`
Completed int `json:"completed"`
}
// ReportCollectionCount is a per-collection tally (collection slug + count).
type ReportCollectionCount struct {
Collection string `json:"collection"`
Count int `json:"count"`
}
// ReportStatusCount is a current status-distribution entry.
type ReportStatusCount struct {
Collection string `json:"collection"`
Status string `json:"status"`
Count int `json:"count"`
}
// ReportTotals are the window roll-ups.
type ReportTotals struct {
Created int `json:"created"`
Completed int `json:"completed"`
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
}
// ReportCompletedItem is one item that reached a positive terminal within the
// window — the "what shipped" list for the exec report (TASK-1641).
type ReportCompletedItem struct {
Ref string `json:"ref"` // e.g. TASK-5
Title string `json:"title"`
Collection string `json:"collection"` // slug
CompletedAt string `json:"completed_at"` // RFC3339, latest completion in-window
}
// 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 {
Window string `json:"window"`
Offset int `json:"offset"` // periods back from now (0 = current)
Granularity string `json:"granularity"` // "hour" | "day"
RangeStart string `json:"range_start"` // RFC3339 UTC
RangeEnd string `json:"range_end"` // RFC3339 UTC
Collections []string `json:"collections"` // slugs included
Buckets []ReportBucket `json:"buckets"` // chronological, zero-filled
Totals ReportTotals `json:"totals"`
CompletedByCollection []ReportCollectionCount `json:"completed_by_collection"`
StatusDistribution []ReportStatusCount `json:"status_distribution"`
CycleTime ReportCycleTime `json:"cycle_time"`
WIP ReportWIP `json:"wip"`
// CompletedItems is the "what shipped" list, populated only when
// IncludeItems was requested (nil/omitted otherwise). Deduped by item,
// newest completion first, capped at completedItemsCap with the remainder
// reported in CompletedItemsOverflowCount.
CompletedItems []ReportCompletedItem `json:"completed_items,omitempty"`
CompletedItemsOverflow int `json:"completed_items_overflow_count,omitempty"`
}
// windowSpec maps a window to its lookback duration and bucket granularity.
func windowSpec(window string) (lookback time.Duration, granularity string) {
switch window {
case "day":
return 24 * time.Hour, "hour"
case "2wk":
return 14 * 24 * time.Hour, "day"
case "month":
return 30 * 24 * time.Hour, "day"
default: // "week"
return 7 * 24 * time.Hour, "day"
}
}
// reportCollection is a resolved collection with its done-field + the positive
// terminal values that count as a completion.
type reportCollection struct {
id string
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.
func (s *Store) GetReport(workspaceID string, opts ReportOptions) (*ReportData, error) {
window := opts.Window
switch window {
case "day", "week", "2wk", "month":
default:
window = "week"
}
lookback, gran := windowSpec(window)
now := opts.Now
if now.IsZero() {
now = time.Now().UTC()
}
now = now.UTC()
// offset steps the window back by whole window-lengths (0 = current,
// 1 = the window immediately before, …). Negative is clamped to 0 (no
// future). end is the window's right edge; start its left edge.
offset := opts.Offset
if offset < 0 {
offset = 0
}
end := now.Add(-time.Duration(offset) * lookback)
start := end.Add(-lookback)
startStr := start.Format(time.RFC3339)
endStr := end.Format(time.RFC3339)
// Resolve the collections in scope (filtered by slug if requested, and by
// the caller's visible set when scoping is enabled).
colls, err := s.resolveReportCollections(workspaceID, opts)
if err != nil {
return nil, err
}
data := &ReportData{
Window: window,
Offset: offset,
Granularity: gran,
RangeStart: startStr,
RangeEnd: endStr,
Collections: make([]string, 0, len(colls)),
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))
for _, c := range colls {
data.Collections = append(data.Collections, c.slug)
collIDs = append(collIDs, c.id)
slugByID[c.id] = c.slug
}
// No collections in scope → empty (but well-formed) report.
if len(collIDs) == 0 {
data.Buckets = s.zeroFilledBuckets(start, end, gran, nil, nil)
return data, nil
}
createdByBucket, err := s.reportCreatedBuckets(workspaceID, collIDs, startStr, endStr, gran)
if err != nil {
return nil, err
}
completedByBucket, completedByColl, err := s.reportCompletedBuckets(workspaceID, colls, startStr, endStr, gran)
if err != nil {
return nil, err
}
data.Buckets = s.zeroFilledBuckets(start, end, gran, createdByBucket, completedByBucket)
for _, b := range data.Buckets {
data.Totals.Created += b.Created
data.Totals.Completed += b.Completed
}
data.Totals.NetFlow = data.Totals.Created - data.Totals.Completed
// completed_by_collection (whole window), ordered by count desc then slug.
for id, n := range completedByColl {
data.CompletedByCollection = append(data.CompletedByCollection, ReportCollectionCount{
Collection: slugByID[id], Count: n,
})
}
sort.Slice(data.CompletedByCollection, func(i, j int) bool {
a, b := data.CompletedByCollection[i], data.CompletedByCollection[j]
if a.Count != b.Count {
return a.Count > b.Count
}
return a.Collection < b.Collection
})
cycle, err := s.reportCycleTime(workspaceID, colls, startStr, endStr, slugByID)
if err != nil {
return nil, err
}
data.CycleTime = cycle
// Snapshot metrics (status distribution + WIP). At offset 0 they reflect
// "now" from the live items table (ground truth). For a PAST period
// (offset > 0) we reconstruct them as-of the window end from the
// status-transition history, so they reflect the selected period rather
// than the current moment (PLAN-1628 / TASK-1640).
if offset > 0 {
dist, wip, rerr := s.reportSnapshotAsOf(workspaceID, colls, end)
if rerr != nil {
return nil, rerr
}
data.StatusDistribution = dist
data.WIP = wip
} else {
dist, derr := s.reportStatusDistribution(workspaceID, colls)
if derr != nil {
return nil, derr
}
data.StatusDistribution = dist
wip, werr := s.reportWIP(workspaceID, colls, now)
if werr != nil {
return nil, werr
}
data.WIP = wip
}
if opts.IncludeItems {
items, overflow, ierr := s.reportCompletedItems(workspaceID, colls, startStr, endStr)
if ierr != nil {
return nil, ierr
}
data.CompletedItems = items
data.CompletedItemsOverflow = overflow
}
return data, nil
}
// reportCompletedItems returns the items that reached a positive terminal in
// the window (the "what shipped" list, TASK-1641): deduped by item (newest
// completion first), capped at completedItemsCap with the remainder in the
// returned overflow count. Same positive-terminal source as totals.completed,
// so the list reconciles with the count (modulo the dedupe + cap). Joins live
// items (deleted_at IS NULL), consistent with the completed counts.
func (s *Store) reportCompletedItems(workspaceID string, colls []reportCollection, startStr, endStr string) ([]ReportCompletedItem, int, error) {
posExpr, posArgs := s.positiveTerminalExpr(colls)
if posExpr == "" {
return []ReportCompletedItem{}, 0, nil
}
// Restrict item-level rows to items whose CURRENT collection is in scope.
// posExpr scopes by st.collection_id (the collection at completion time),
// but this query returns the item's live title/ref/collection — so an item
// completed while in a visible collection and since moved to a hidden one
// must NOT leak its current (hidden) details to a restricted caller.
curScope := make([]string, 0, len(colls))
curArgs := make([]any, 0, len(colls))
for _, c := range colls {
curScope = append(curScope, "?")
curArgs = append(curArgs, c.id)
}
curExpr := "i.collection_id IN (" + strings.Join(curScope, ",") + ")"
base := []any{workspaceID, startStr, endStr}
// Distinct completed-item count (for the overflow figure).
var total int
cArgs := append(append([]any{}, base...), posArgs...)
cArgs = append(cArgs, curArgs...)
if err := s.db.QueryRow(s.q(fmt.Sprintf(`
SELECT COUNT(DISTINCT st.item_id)
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 AND %s
`, posExpr, curExpr)), cArgs...).Scan(&total); err != nil {
return nil, 0, fmt.Errorf("count completed items: %w", err)
}
// The list: one row per item (latest in-window completion), newest first.
lArgs := append(append([]any{}, base...), posArgs...)
lArgs = append(lArgs, curArgs...)
lArgs = append(lArgs, completedItemsCap)
rows, err := s.db.Query(s.q(fmt.Sprintf(`
SELECT c.prefix, i.item_number, i.title, c.slug, MAX(st.created_at) AS completed_at
FROM status_transitions st
JOIN items i ON i.id = st.item_id AND i.deleted_at IS NULL
JOIN collections c ON c.id = i.collection_id
WHERE st.workspace_id = ? AND st.created_at >= ? AND st.created_at <= ?
AND %s AND %s
GROUP BY i.id, c.prefix, i.item_number, i.title, c.slug
ORDER BY completed_at DESC
LIMIT ?
`, posExpr, curExpr)), lArgs...)
if err != nil {
return nil, 0, fmt.Errorf("list completed items: %w", err)
}
defer rows.Close()
out := []ReportCompletedItem{}
for rows.Next() {
var prefix, title, slug, completedAt string
var itemNumber int
if err := rows.Scan(&prefix, &itemNumber, &title, &slug, &completedAt); err != nil {
return nil, 0, fmt.Errorf("scan completed item: %w", err)
}
out = append(out, ReportCompletedItem{
Ref: fmt.Sprintf("%s-%d", prefix, itemNumber),
Title: title,
Collection: slug,
CompletedAt: completedAt,
})
}
if err := rows.Err(); err != nil {
return nil, 0, err
}
overflow := total - len(out)
if overflow < 0 {
overflow = 0
}
return out, overflow, nil
}
// reportSnapshotAsOf reconstructs the status-distribution and WIP snapshot as
// they stood at time t, from the status-transition history (PLAN-1628 /
// TASK-1640). An item's status as-of-t is the to_status of its latest
// transition on the collection's done field with created_at <= t. Per
// collection (the done field varies), it scans items currently in that
// collection that existed at t (created_at <= t) and weren't deleted by then
// (deleted_at IS NULL OR > t), resolving each one's as-of-t value via a
// "no later transition <= t" NOT EXISTS — dual-dialect, no window functions.
//
// Accuracy (historical periods are best-effort, matching the rest of the
// feature's historical posture):
// - exact for post-launch history (the live write path records every
// status change, including clears); approximate for pre-launch history,
// since the backfill parsed the debounce-coalesced activity log.
// - collection attribution uses the item's CURRENT collection, not the one
// it belonged to at t. An item moved between collections is attributed to
// its present collection for past periods too. Reconstructing historical
// collection membership would require replaying move history (and
// status-preserving moves record no transition), so this is a deliberate
// limitation — the same current-collection attribution the backfill and
// completed-by-collection use. Items rarely change collection.
//
// Same-second multi-hops (an item changing status 2+ times within one second)
// are resolved deterministically via the monotonic `seq` tiebreak (TASK-1643):
// "latest transition <= t" orders by created_at, then seq (insertion order),
// then id — so the chronologically-last same-second hop wins.
func (s *Store) reportSnapshotAsOf(workspaceID string, colls []reportCollection, t time.Time) ([]ReportStatusCount, ReportWIP, error) {
tStr := t.Format(time.RFC3339)
dist := []ReportStatusCount{}
wip := ReportWIP{AgingBuckets: []ReportAgingBucket{}, ByCollection: []ReportDuration{}}
var openAges []float64
perColl := map[string][]float64{}
slugByID := map[string]string{}
for _, c := range colls {
slugByID[c.id] = c.slug
terminals := map[string]bool{}
for _, v := range c.allTerminals {
terminals[strings.ToLower(v)] = true
}
// Start from items (not transitions) so an item that existed at t but
// had no done-field transition yet is still counted — matching the
// live path, which treats a missing/empty done value as open. For each
// in-scope item that existed at t and wasn't deleted by t, the
// correlated subquery resolves its as-of-t value: the to_status of the
// latest transition on the done field at or before t (NULL if none →
// treated as empty/open). LIMIT 1 ORDER BY created_at,id is valid on
// both dialects (no window functions).
query := `
SELECT i.created_at,
(SELECT st.to_status FROM status_transitions st
WHERE st.item_id = i.id AND st.field_key = ? AND st.created_at <= ?
ORDER BY st.created_at DESC, st.seq DESC, st.id DESC LIMIT 1) AS asof_status
FROM items i
WHERE i.workspace_id = ? AND i.collection_id = ?
AND i.created_at <= ?
AND (i.deleted_at IS NULL OR i.deleted_at > ?)
`
rows, err := s.db.Query(s.q(query), c.doneKey, tStr, workspaceID, c.id, tStr, tStr)
if err != nil {
return nil, ReportWIP{}, fmt.Errorf("reconstruct snapshot: %w", err)
}
statusCounts := map[string]int{}
func() {
defer rows.Close()
for rows.Next() {
var createdAt string
var asof sql.NullString
if scanErr := rows.Scan(&createdAt, &asof); scanErr != nil {
err = fmt.Errorf("scan snapshot row: %w", scanErr)
return
}
status := strings.ToLower(asof.String) // "" when NULL
// Status distribution skips items with no done value (matches
// the live path).
if status != "" {
statusCounts[status]++
}
// WIP: open = not a terminal value (a missing/empty value is
// open too); age measured against t.
if !terminals[status] {
if h, ok := durationHours(createdAt, tStr); ok {
openAges = append(openAges, h)
perColl[c.id] = append(perColl[c.id], h)
}
}
}
err = rows.Err()
}()
if err != nil {
return nil, ReportWIP{}, err
}
for status, n := range statusCounts {
dist = append(dist, ReportStatusCount{Collection: c.slug, Status: status, Count: n})
}
}
sort.Slice(dist, func(i, j int) bool {
if dist[i].Collection != dist[j].Collection {
return dist[i].Collection < dist[j].Collection
}
return dist[i].Status < dist[j].Status
})
wip.OpenCount = len(openAges)
wip.MedianAgeHours = percentile(openAges, 0.5)
wip.ByCollection = durationsByCollection(perColl, slugByID)
wip.AgingBuckets = agingBuckets(openAges)
return dist, wip, 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.
func (s *Store) resolveReportCollections(workspaceID string, opts ReportOptions) ([]reportCollection, error) {
all, err := s.ListCollections(workspaceID)
if err != nil {
return nil, fmt.Errorf("list collections for report: %w", err)
}
want := map[string]bool{}
for _, sl := range opts.Collections {
want[strings.ToLower(strings.TrimSpace(sl))] = true
}
// Visibility scope: when enabled, only collections in the caller's visible
// set are eligible (an empty set yields an empty report — correct for a
// guest with no full-collection access).
var visible map[string]bool
if opts.ScopeToVisible {
visible = make(map[string]bool, len(opts.VisibleCollectionIDs))
for _, id := range opts.VisibleCollectionIDs {
visible[id] = true
}
}
var out []reportCollection
for _, c := range all {
if len(want) > 0 && !want[strings.ToLower(c.Slug)] {
continue
}
if visible != nil && !visible[c.ID] {
continue
}
var schema models.CollectionSchema
var settings models.CollectionSettings
// Parse failures fall back to status + default terminals, matching
// scanCollectionDoneFilters' tolerance.
if c.Schema != "" {
_ = json.Unmarshal([]byte(c.Schema), &schema)
}
if c.Settings != "" {
_ = json.Unmarshal([]byte(c.Settings), &settings)
}
doneKey, terminals := models.TerminalValuesForDoneField(schema, settings)
var positives []string
for _, v := range terminals {
if models.IsNegativeTerminal(v) {
continue
}
positives = append(positives, v)
}
out = append(out, reportCollection{
id: c.ID,
slug: c.Slug,
doneKey: doneKey,
positiveTerminals: positives,
allTerminals: terminals,
})
}
return out, nil
}
// reportCreatedBuckets returns created-item counts keyed by bucket label.
func (s *Store) reportCreatedBuckets(workspaceID string, collIDs []string, startStr, endStr, gran string) (map[string]int, error) {
bucketExpr := s.dialect.DateBucket("created_at", gran)
placeholders := make([]string, len(collIDs))
args := []any{workspaceID, startStr, endStr}
for i, id := range collIDs {
placeholders[i] = "?"
args = append(args, id)
}
query := fmt.Sprintf(`
SELECT %s AS bucket, COUNT(*)
FROM items
WHERE workspace_id = ? AND deleted_at IS NULL
AND created_at >= ? AND created_at <= ?
AND collection_id IN (%s)
GROUP BY bucket
`, bucketExpr, strings.Join(placeholders, ","))
return s.scanBucketCounts(query, args)
}
// reportCompletedBuckets returns completion counts keyed by bucket label AND
// the per-collection completion totals over the whole window. A completion is
// a status_transitions row into a positive terminal for that collection's done
// field.
func (s *Store) reportCompletedBuckets(workspaceID string, colls []reportCollection, startStr, endStr, gran string) (byBucket map[string]int, byColl map[string]int, err error) {
posExpr, posArgs := s.positiveTerminalExpr(colls)
byBucket = map[string]int{}
byColl = map[string]int{}
if posExpr == "" {
// No collection has positive terminals → nothing completes.
return byBucket, byColl, nil
}
base := []any{workspaceID, startStr, endStr}
bucketExpr := s.dialect.DateBucket("st.created_at", gran)
// Join live items so a completed-then-soft-deleted item is excluded from
// the completion counts too — keeping them consistent with the created and
// status-distribution queries (which already filter deleted_at IS NULL).
// status_transitions rows survive a soft delete (only a HARD delete
// cascades them away).
const liveJoin = "JOIN items i ON i.id = st.item_id AND i.deleted_at IS NULL"
// Buckets.
bArgs := append(append([]any{}, base...), posArgs...)
bQuery := fmt.Sprintf(`
SELECT %s AS bucket, COUNT(*)
FROM status_transitions st
%s
WHERE st.workspace_id = ? AND st.created_at >= ? AND st.created_at <= ?
AND %s
GROUP BY bucket
`, bucketExpr, liveJoin, posExpr)
byBucket, err = s.scanBucketCounts(bQuery, bArgs)
if err != nil {
return nil, nil, err
}
// Per-collection totals.
cArgs := append(append([]any{}, base...), posArgs...)
cQuery := fmt.Sprintf(`
SELECT st.collection_id, COUNT(*)
FROM status_transitions st
%s
WHERE st.workspace_id = ? AND st.created_at >= ? AND st.created_at <= ?
AND %s
GROUP BY st.collection_id
`, liveJoin, posExpr)
rows, err := s.db.Query(s.q(cQuery), cArgs...)
if err != nil {
return nil, nil, fmt.Errorf("report completed-by-collection: %w", err)
}
defer rows.Close()
for rows.Next() {
var id string
var n int
if err := rows.Scan(&id, &n); err != nil {
return nil, nil, fmt.Errorf("scan completed-by-collection: %w", err)
}
byColl[id] = n
}
return byBucket, byColl, rows.Err()
}
// positiveTerminalExpr builds the "this transition is a positive completion"
// boolean: OR over collections of
// (st.collection_id = ? AND st.field_key = ? AND LOWER(st.to_status) IN (...)).
// Returns "" (no args) when no collection has any positive terminal value.
func (s *Store) positiveTerminalExpr(colls []reportCollection) (string, []any) {
var clauses []string
var args []any
for _, c := range colls {
if len(c.positiveTerminals) == 0 {
continue
}
ph := make([]string, len(c.positiveTerminals))
args = append(args, c.id, c.doneKey)
for i, v := range c.positiveTerminals {
ph[i] = "?"
args = append(args, strings.ToLower(v))
}
clauses = append(clauses, fmt.Sprintf(
"(st.collection_id = ? AND st.field_key = ? AND LOWER(st.to_status) IN (%s))",
strings.Join(ph, ","),
))
}
if len(clauses) == 0 {
return "", nil
}
return "(" + strings.Join(clauses, " OR ") + ")", args
}
// reportStatusDistribution returns the current per-collection breakdown of
// items by their done-field value. One small grouped query per collection
// (the done field differs per collection, so a single GROUP BY can't span
// them).
func (s *Store) reportStatusDistribution(workspaceID string, colls []reportCollection) ([]ReportStatusCount, error) {
out := []ReportStatusCount{}
for _, c := range colls {
fieldExpr := s.dialect.JSONExtractText("fields", c.doneKey)
query := fmt.Sprintf(`
SELECT LOWER(COALESCE(%s, '')) AS st, COUNT(*)
FROM items
WHERE workspace_id = ? AND collection_id = ? AND deleted_at IS NULL
GROUP BY st
`, fieldExpr)
rows, err := s.db.Query(s.q(query), workspaceID, c.id)
if err != nil {
return nil, fmt.Errorf("report status distribution: %w", err)
}
func() {
defer rows.Close()
for rows.Next() {
var status string
var n int
if scanErr := rows.Scan(&status, &n); scanErr != nil {
err = fmt.Errorf("scan status distribution: %w", scanErr)
return
}
if status == "" {
continue // items with no done-field value set
}
out = append(out, ReportStatusCount{Collection: c.slug, Status: status, Count: n})
}
err = rows.Err()
}()
if err != nil {
return nil, err
}
}
return out, nil
}
// scanBucketCounts runs a "SELECT <bucket>, COUNT(*) ... GROUP BY <bucket>"
// query and returns a label→count map.
func (s *Store) scanBucketCounts(query string, args []any) (map[string]int, error) {
rows, err := s.db.Query(s.q(query), args...)
if err != nil {
return nil, fmt.Errorf("report bucket query: %w", err)
}
defer rows.Close()
out := map[string]int{}
for rows.Next() {
var bucket string
var n int
if err := rows.Scan(&bucket, &n); err != nil {
return nil, fmt.Errorf("scan report bucket: %w", err)
}
out[bucket] = n
}
return out, rows.Err()
}
// zeroFilledBuckets produces the full chronological bucket series between start
// and end (inclusive) at the given granularity, merging in created/completed
// counts (nil maps are treated as empty). Continuous buckets make the series
// chart-ready without client-side gap-filling.
func (s *Store) zeroFilledBuckets(start, end time.Time, gran string, created, completed map[string]int) []ReportBucket {
var step time.Duration
var layout string
if gran == "hour" {
step, layout = time.Hour, "2006-01-02T15"
start = start.Truncate(time.Hour)
} else {
step, layout = 24*time.Hour, "2006-01-02"
start = time.Date(start.Year(), start.Month(), start.Day(), 0, 0, 0, 0, time.UTC)
}
out := []ReportBucket{}
for t := start; !t.After(end); t = t.Add(step) {
label := t.UTC().Format(layout)
out = append(out, ReportBucket{
Bucket: label,
Created: created[label],
Completed: completed[label],
})
}
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
}