* feat(stats): add avg turn duration and messages per turn to averages card
Adds two new metrics to the workspace stats Averages card: mean turn
duration and mean message count per turn. Both exclude outliers — turns
shorter than 1s (no-op/aborted), longer than 1h (zombie turns where
completed_at was backfilled across an agent restart), and turns with no
messages — so the displayed means reflect typical work rather than tail
skew. On a ~4k-turn sample the filter drops avg duration from ~1062s to
~289s.
* fix(stats): exclude exact 1h turns and use Tooltip for averages help text
Address PR feedback: the BETWEEN bound was inclusive so turns exactly at
the 1h ceiling were retained, contradicting the documented half-open
range. Also swap the title attribute on the new Averages rows for the
shared Tooltip component so the help text is reachable via keyboard and
screen readers.
* fix(stats): align turn-row zero state with em-dash convention
Address PR feedback: avg_messages_per_turn rendered "0.0" while
avg_turn_duration_ms rendered "—" under the same empty-data condition.
Also corrects the GetGlobalStats docstring to acknowledge that
clean_turn_agg adds one correlated per-turn lookup on top of the four
single-scan CTEs.
* test(stats): cover exact 1h turn boundary in clean-turn exclusion test
Add a turn of exactly 3,600,000ms (cleanTurnMaxDurationMs) to
TestGetGlobalStats_CleanTurnExcludesOutliers so the exclusive upper
bound is exercised directly, not just via the 2h zombie turn that
sits well above the boundary.
---------
Co-authored-by: Kandev Agent <agent@kandev.dev>
* perf(stats): split stats endpoint per-section and rewrite GetGlobalStats
Stats page used to wait on a single endpoint that ran 7 SQL queries in parallel
and rendered nothing until the slowest one returned. Cold first-paint was the
bottleneck users actually hit.
- Backend: split /api/v1/workspaces/:id/stats into 7 per-section endpoints
(global, tasks, daily-activity, completed-activity, agent-usage,
repositories, git). Each handler runs one repo call and returns its DTO.
- Backend: rewrote GetGlobalStats from 9 independent correlated subqueries
into 4 CTEs (task / session / turn / message). Each underlying table is now
scanned at most once per request instead of 1-3 times.
- Frontend: replaced the monolithic fetchStats + single PanelState with
useStatsSections hook (7 parallel fetches, per-section SectionStatus).
Each panel renders skeleton -> data -> error independently; light queries
(overview, agent usage, git) paint immediately, heavy ones stream in.
- Tests: added TestGetGlobalStats_AggregatesAcrossTables to lock the
CTE-based query against regressions for turns, user/tool messages, and
total duration.
* fix(stats): address PR review feedback
- Restore cache: "no-store" on section fetches; Gin emits no Cache-Control
headers so the browser could otherwise serve stale stats on revisit
(Greptile P1, Cubic).
- Stop blanking the Overview panel when /git fails: render OverviewCards as
soon as `global` is ready and treat `git` as optional, falling back to the
Averages card. Updates GitOrAveragesCard / OverviewCards types (Claude).
- Fix TaskStatsHasMore off-by-one at exactly taskStatsLimit rows: probe
taskStatsLimit+1, slice back to the page size, only flag hasMore on a
confirmed extra row (Claude, Cubic).
- Rename anyError → firstError with a comment documenting the deterministic
insertion-order iteration; clearer signal that callers should use it as
"any section failed?" and surface per-section errors in their own panels
(Claude).
---------
Co-authored-by: Kandev Agent <agent@kandev.dev>
* perf(web): render stats page shell immediately with per-panel skeletons
The stats route previously blocked the full SSR response on the workspace
stats query, leaving the user staring at a blank loading screen for the
entire round-trip. Drop the server-side stats fetch (server still resolves
the workspace id, which is cheap) and move the stats request to the client.
The header, range toggle, and every panel render their shell on mount, with
each panel showing its own skeleton until data arrives. Range changes also
re-show the skeletons during the refetch instead of staling the previous
data.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(web): address PR review feedback on stats page lazy-loading
- Drop unused `loading` flag from stats reducer (Claude)
- Show "Failed to load stats" subtitle on fetch error instead of stale
"Loading stats…" text (Claude, cubic)
- Read live `range` from useSearchParams() and validate against the
RangeKey union so skeletons reset immediately on toggle and invalid
?range values fall back to "month" (Claude, CodeRabbit)
- Render an error panel per section instead of perpetual skeletons when
the stats fetch fails (Greptile)
- Use router.replace with scroll:false on range toggle to avoid
re-running the server component for a URL-only change
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* test(web): extract stats pure helpers into stats-utils + unit tests
- Move statsReducer, isRangeKey, getRangeLabel, formatDuration,
getSubtitle, toPanelState, and buildStatsSummary into
apps/web/app/stats/stats-utils.ts so the testable units are reachable
outside the client component
- Add apps/web/app/stats/stats-utils.test.ts covering reducer
transitions, range-key validation, subtitle branching, panel-state
derivation, and the summary builder
- Switch useStatsData from a cancellation flag to AbortController so
fast range toggles actually abort the in-flight fetchStats request
instead of letting stale requests run to completion
Addresses Claude review blocker + suggestion on PR #1022.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* refactor(web): tighten stats lazy-loading per Claude suggestions
- loading.tsx now includes TopRepositoriesSkeleton, RepoLeadersSkeleton,
and WorkloadSkeleton so a cold navigation to /stats shows the full
page layout, not a layout that ends at Repository Activity
- Reuse the existing StatsRange union for RangeKey in stats-utils.ts
(one type alias, no risk of drift)
- Drop the unreachable default arm in getRangeLabel so a future
RangeKey addition triggers a TypeScript exhaustiveness error
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
The Copy Stats button called navigator.clipboard.writeText directly,
which is undefined over HTTP on non-localhost origins (insecure context).
Switch to the existing useCopyToClipboard hook, which falls back to
document.execCommand("copy") when the modern API is unavailable.
* fix(web): rank longest/quickest tasks by active duration
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* refactor(web): drop redundant sorted alias in TaskDurationList
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
* feat(stats): show active time and elapsed span per task
* fix(stats): address review feedback on task duration metrics
---------
Co-authored-by: Kandev Agent <agent@kandev.dev>