Files
kandev/apps/web/app/stats/stats-utils.test.ts
T
João Salavisa 672f2b1f26 feat(stats): add avg turn duration and messages per turn (#1328)
* 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>
2026-06-11 13:45:16 +01:00

201 lines
5.9 KiB
TypeScript

import { describe, it, expect } from "vitest";
import type { StatsResponse } from "@/lib/types/http";
import {
buildStatsSummary,
formatDuration,
getRangeLabel,
getSubtitle,
isRangeKey,
statsReducer,
toPanelState,
} from "./stats-utils";
const emptyGlobal: StatsResponse["global"] = {
total_tasks: 0,
completed_tasks: 0,
in_progress_tasks: 0,
total_sessions: 0,
total_turns: 0,
total_messages: 0,
total_user_messages: 0,
total_tool_calls: 0,
total_duration_ms: 0,
avg_turns_per_task: 0,
avg_messages_per_task: 0,
avg_duration_ms_per_task: 0,
avg_turn_duration_ms: 0,
avg_messages_per_turn: 0,
};
const sampleStats: StatsResponse = {
global: {
...emptyGlobal,
total_tasks: 10,
completed_tasks: 4,
in_progress_tasks: 2,
total_sessions: 7,
total_duration_ms: 3_725_000,
avg_duration_ms_per_task: 372_500,
},
task_stats: [],
daily_activity: [],
completed_activity: [],
agent_usage: [],
repository_stats: [
{
repository_id: "r1",
repository_name: "kandev",
total_tasks: 6,
completed_tasks: 3,
in_progress_tasks: 1,
session_count: 5,
turn_count: 30,
message_count: 60,
user_message_count: 25,
tool_call_count: 40,
total_duration_ms: 1_000_000,
total_commits: 12,
total_files_changed: 30,
total_insertions: 1200,
total_deletions: 800,
},
],
git_stats: {
total_commits: 12,
total_files_changed: 30,
total_insertions: 1200,
total_deletions: 800,
},
};
describe("isRangeKey", () => {
it("accepts the three valid keys", () => {
expect(isRangeKey("week")).toBe(true);
expect(isRangeKey("month")).toBe(true);
expect(isRangeKey("all")).toBe(true);
});
it("rejects null, undefined, and unknown strings", () => {
expect(isRangeKey(null)).toBe(false);
expect(isRangeKey(undefined)).toBe(false);
expect(isRangeKey("")).toBe(false);
expect(isRangeKey("day")).toBe(false);
expect(isRangeKey("MONTH")).toBe(false);
});
});
describe("getRangeLabel", () => {
it("returns the friendly label for each range", () => {
expect(getRangeLabel("week")).toBe("Last Week");
expect(getRangeLabel("month")).toBe("Last Month");
expect(getRangeLabel("all")).toBe("All Time");
});
});
describe("formatDuration", () => {
it("renders an em-dash for zero", () => {
expect(formatDuration(0)).toBe("—");
});
it("formats seconds, minutes, and hours", () => {
expect(formatDuration(45_000)).toBe("45s");
expect(formatDuration(125_000)).toBe("2m 5s");
expect(formatDuration(3_725_000)).toBe("1h 2m");
});
});
describe("getSubtitle", () => {
it("renders the summary when stats are available", () => {
expect(getSubtitle(sampleStats.global, false)).toBe("10 tasks · 7 sessions · 1h 2m");
});
it("shows the loading message while the fetch is in flight", () => {
expect(getSubtitle(null, false)).toBe("Loading stats…");
});
it("shows the error message when the fetch has failed", () => {
expect(getSubtitle(null, true)).toBe("Failed to load stats");
});
it("prefers stats over the error flag once data arrives", () => {
expect(getSubtitle(sampleStats.global, true)).toBe("10 tasks · 7 sessions · 1h 2m");
});
});
describe("statsReducer", () => {
const initial = { stats: null, error: null };
it("fetch action clears stats and error", () => {
const stale = { stats: sampleStats, error: "previous" };
expect(statsReducer(stale, { type: "fetch" })).toEqual({ stats: null, error: null });
});
it("success stores stats and clears any prior error", () => {
const errored = { stats: null, error: "boom" };
expect(statsReducer(errored, { type: "success", stats: sampleStats })).toEqual({
stats: sampleStats,
error: null,
});
});
it("failure stores the error and clears stats", () => {
const loaded = { stats: sampleStats, error: null };
expect(statsReducer(loaded, { type: "failure", error: "nope" })).toEqual({
stats: null,
error: "nope",
});
});
it("does not return the same reference as the input", () => {
const next = statsReducer(initial, { type: "fetch" });
expect(next).not.toBe(initial);
});
});
describe("toPanelState", () => {
it("returns loading when neither stats nor error is set", () => {
expect(toPanelState(null, null)).toEqual({ kind: "loading" });
});
it("returns ready when stats are available", () => {
expect(toPanelState(sampleStats, null)).toEqual({ kind: "ready", stats: sampleStats });
});
it("returns error when an error is present", () => {
expect(toPanelState(null, "boom")).toEqual({ kind: "error", message: "boom" });
});
it("prefers error over stale stats so panels never linger in a ready+error mix", () => {
expect(toPanelState(sampleStats, "boom")).toEqual({ kind: "error", message: "boom" });
});
});
describe("buildStatsSummary", () => {
it("includes completion %, top repo, and git line", () => {
const summary = buildStatsSummary(sampleStats, "Last Month", 4);
expect(summary).toContain("*Kandev Stats — Last Month*");
expect(summary).toContain("10 total (4 done, 2 in progress) · 40% completion");
expect(summary).toContain("Completed (Last Month): 4");
expect(summary).toContain("Top repo: kandev (6 tasks)");
expect(summary).toContain("12 commits, +1,200/-800");
});
it("falls back to em-dash and no-git when there is no activity", () => {
const empty: StatsResponse = {
...sampleStats,
global: emptyGlobal,
repository_stats: [],
git_stats: {
total_commits: 0,
total_files_changed: 0,
total_insertions: 0,
total_deletions: 0,
},
};
const summary = buildStatsSummary(empty, "All Time", 0);
expect(summary).toContain("Top repo: —");
expect(summary).toContain("Git: no git activity");
expect(summary).toContain("0 total (0 done, 0 in progress) · — completion");
});
});