6ba50ba51e
* 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>
199 lines
5.8 KiB
TypeScript
199 lines
5.8 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,
|
|
};
|
|
|
|
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");
|
|
});
|
|
});
|