Files
kandev/apps/web/app/stats/stats-page-client.tsx
T
José Almeida 68c94dfa3a feat: refine workbench chrome (#761)
* docs: define product and design direction

* style: establish restrained workbench primitives

* refactor: declutter board and task topbars

* style: align dockview panel chrome

* fix: align task topbar vcs control sizing

* refactor: surface integrations navigation

* fix: repair integrations and task creation workflow

* refactor: include linear in integrations navigation

* style: restore familiar workbench tones

* fix: add dockview tab breathing room

* fix: show configured integration shortcuts

* fix: hover open integrations menu

* fix: stabilize integrations hover menu

* fix: align home and dockview topbars

* fix: normalize home topbar controls

* fix: compact home topbar height

* fix: simplify home chrome and split buttons

* fix: move integrations to left chrome

* fix: restore home stats shortcut

* fix: add dockview tab breathing room

* fix: border home stats button

* fix: streamline dockview topbar actions

* fix: align task split button seam

* fix: address review feedback

* fix: restore topbar e2e affordances

* fix: stabilize workflow e2e isolation

* fix: add task topbar action overflow

* fix: address PR review feedback
2026-05-01 21:02:41 +01:00

368 lines
12 KiB
TypeScript

"use client";
import { Card, CardContent, CardHeader, CardTitle } from "@kandev/ui/card";
import { Button } from "@kandev/ui/button";
import { PageTopbar } from "@/components/page-topbar";
import { ToggleGroup, ToggleGroupItem } from "@kandev/ui/toggle-group";
import type { StatsResponse } from "@/lib/types/http";
import { useMemo, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { IconChartBar } from "@tabler/icons-react";
import {
OverviewCards,
WorkloadSection,
RepositoryStatsGrid,
TopRepositories,
RepoLeaders,
} from "./stats-sections";
import {
ActivityHeatmap,
AgentUsageList,
CompletedTasksChart,
MostProductiveSummary,
} from "./stats-charts";
import { PRStatsPanel } from "@/components/github/pr-stats";
interface StatsPageClientProps {
stats: StatsResponse | null;
error: string | null;
workspaceId?: string;
activeRange?: RangeKey;
}
const EMPTY_STATS: 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,
},
task_stats: [],
daily_activity: [],
completed_activity: [],
agent_usage: [],
repository_stats: [],
git_stats: { total_commits: 0, total_files_changed: 0, total_insertions: 0, total_deletions: 0 },
};
function formatDuration(ms: number): string {
if (ms === 0) return "—";
const seconds = Math.floor(ms / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
if (hours > 0) return `${hours}h ${minutes % 60}m`;
if (minutes > 0) return `${minutes}m ${seconds % 60}s`;
return `${seconds}s`;
}
type RangeKey = "week" | "month" | "all";
function getRangeLabel(range: RangeKey): string {
switch (range) {
case "week":
return "Last Week";
case "month":
return "Last Month";
case "all":
return "All Time";
default:
return "Last Month";
}
}
function StatsEmptyState({ message }: { message: string }) {
return (
<div className="h-screen w-full flex flex-col bg-background">
<PageTopbar title="Statistics" icon={<IconChartBar className="h-4 w-4" />} />
<div className="flex-1 flex items-center justify-center">
<p className="text-muted-foreground">{message}</p>
</div>
</div>
);
}
type StatsHeaderProps = {
global: StatsResponse["global"];
range: RangeKey;
copied: boolean;
onRangeChange: (r: RangeKey) => void;
onCopy: () => void;
};
function StatsHeader({ global, range, copied, onRangeChange, onCopy }: StatsHeaderProps) {
return (
<PageTopbar
title="Statistics"
icon={<IconChartBar className="h-4 w-4" />}
subtitle={`${global.total_tasks} tasks · ${global.total_sessions} sessions · ${formatDuration(global.total_duration_ms)}`}
actions={
<>
<ToggleGroup
type="single"
value={range}
onValueChange={(v) => {
if (v) onRangeChange(v as RangeKey);
}}
variant="outline"
className="h-7"
>
{(["week", "month", "all"] as RangeKey[]).map((key) => (
<ToggleGroupItem
key={key}
value={key}
className="cursor-pointer h-7 px-2 text-xs data-[state=on]:bg-muted data-[state=on]:text-foreground"
>
{getRangeLabel(key)}
</ToggleGroupItem>
))}
</ToggleGroup>
<Button
type="button"
variant="outline"
size="sm"
className="h-7 px-2 text-xs cursor-pointer"
onClick={onCopy}
>
{copied ? "Copied" : "Copy Stats"}
</Button>
</>
}
/>
);
}
type StatsContentProps = { resolvedStats: StatsResponse; rangeLabel: string; workspaceId?: string };
function SectionDivider({ id, label }: { id: string; label: string }) {
return (
<div id={id} className="flex items-center gap-3 pt-2 scroll-mt-24">
<div className="text-[11px] uppercase tracking-wider text-muted-foreground">{label}</div>
<div className="h-px flex-1 bg-border/60" />
</div>
);
}
function TelemetrySection({
completedActivity,
dailyActivity,
agentUsage,
rangeLabel,
}: {
completedActivity: StatsResponse["completed_activity"];
dailyActivity: StatsResponse["daily_activity"];
agentUsage: StatsResponse["agent_usage"];
rangeLabel: string;
}) {
return (
<>
<div id="completed" className="scroll-mt-24">
<div className="grid gap-4 lg:grid-cols-3">
<Card className="rounded-sm lg:col-span-2">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
Completed Tasks Over Time
</CardTitle>
</CardHeader>
<CardContent>
<CompletedTasksChart completedActivity={completedActivity} />
</CardContent>
</Card>
<Card className="rounded-sm">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
Most Productive
</CardTitle>
</CardHeader>
<CardContent>
<MostProductiveSummary completedActivity={completedActivity} />
</CardContent>
</Card>
</div>
</div>
<div id="activity" className="grid gap-4 lg:grid-cols-2 scroll-mt-24">
<Card className="rounded-sm">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
Activity ({rangeLabel.toLowerCase()})
</CardTitle>
</CardHeader>
<CardContent>
<ActivityHeatmap dailyActivity={dailyActivity} />
</CardContent>
</Card>
<Card className="rounded-sm">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Top Agents</CardTitle>
</CardHeader>
<CardContent>
<AgentUsageList agentUsage={agentUsage} />
</CardContent>
</Card>
</div>
</>
);
}
function StatsContent({ resolvedStats, rangeLabel, workspaceId }: StatsContentProps) {
const {
global,
task_stats,
completed_activity,
daily_activity,
agent_usage,
repository_stats,
git_stats,
} = resolvedStats;
return (
<div className="flex-1 overflow-auto">
<div className="max-w-7xl mx-auto p-6">
<div className="space-y-5">
<OverviewCards global={global} git_stats={git_stats} />
<SectionDivider id="telemetry" label="Telemetry" />
<TelemetrySection
completedActivity={completed_activity}
dailyActivity={daily_activity}
agentUsage={agent_usage}
rangeLabel={rangeLabel}
/>
<Card id="repositories" className="rounded-sm scroll-mt-24">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
Repository Activity
</CardTitle>
</CardHeader>
<CardContent>
<RepositoryStatsGrid repositoryStats={repository_stats} />
</CardContent>
</Card>
<Card className="rounded-sm">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
Top Repositories
</CardTitle>
</CardHeader>
<CardContent>
<TopRepositories repositoryStats={repository_stats} />
</CardContent>
</Card>
<Card className="rounded-sm">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
Repo Leaders
</CardTitle>
</CardHeader>
<CardContent>
<RepoLeaders repositoryStats={repository_stats} />
</CardContent>
</Card>
<SectionDivider id="github" label="GitHub" />
<PRStatsPanel workspaceId={workspaceId ?? null} />
<SectionDivider id="workload" label="Workload" />
<WorkloadSection task_stats={task_stats} />
</div>
</div>
</div>
);
}
function buildStatsSummary(
resolvedStats: StatsResponse,
rangeLabel: string,
completedInRange: number,
): string {
const { global, repository_stats, git_stats } = resolvedStats;
const completion =
global.total_tasks > 0
? `${Math.round((global.completed_tasks / global.total_tasks) * 100)}%`
: "—";
const topRepo = repository_stats
.filter((r) => r.total_tasks > 0)
.sort((a, b) => b.total_tasks - a.total_tasks)[0];
const topRepoLabel = topRepo ? `${topRepo.repository_name} (${topRepo.total_tasks} tasks)` : "—";
const hasGitStats =
git_stats && (git_stats.total_commits > 0 || git_stats.total_files_changed > 0);
const gitLine = hasGitStats
? `${git_stats.total_commits} commits, +${git_stats.total_insertions.toLocaleString()}/-${git_stats.total_deletions.toLocaleString()}`
: "no git activity";
return [
`*KanDev Stats — ${rangeLabel}*`,
`- Tasks: ${global.total_tasks} total (${global.completed_tasks} done, ${global.in_progress_tasks} in progress) · ${completion} completion`,
`- Completed (${rangeLabel}): ${completedInRange}`,
`- Time: ${formatDuration(global.total_duration_ms)} total · ${formatDuration(global.avg_duration_ms_per_task)} avg/task`,
`- Repos: ${repository_stats.length} tracked · Top repo: ${topRepoLabel}`,
`- Git: ${gitLine}`,
].join("\n");
}
export function StatsPageClient({ stats, error, workspaceId, activeRange }: StatsPageClientProps) {
const router = useRouter();
const searchParams = useSearchParams();
const [copied, setCopied] = useState(false);
const range = (activeRange ?? "month") as RangeKey;
const rangeLabel = getRangeLabel(range);
const resolvedStats = stats ?? EMPTY_STATS;
const completedInRange = useMemo(
() =>
(resolvedStats.completed_activity ?? []).reduce((sum, item) => sum + item.completed_tasks, 0),
[resolvedStats.completed_activity],
);
const statsSummary = useMemo(
() => buildStatsSummary(resolvedStats, rangeLabel, completedInRange),
[resolvedStats, rangeLabel, completedInRange],
);
if (!workspaceId) return <StatsEmptyState message="Select a workspace to view statistics." />;
if (error)
return (
<div className="h-screen w-full flex flex-col bg-background">
<PageTopbar title="Statistics" icon={<IconChartBar className="h-4 w-4" />} />
<div className="flex-1 flex items-center justify-center">
<p className="text-destructive">Error loading stats: {error}</p>
</div>
</div>
);
if (!stats) return <StatsEmptyState message="No stats available." />;
const handleCopyStats = async () => {
try {
await navigator.clipboard.writeText(statsSummary);
setCopied(true);
window.setTimeout(() => setCopied(false), 2000);
} catch (err) {
console.error("Failed to copy stats summary", err);
}
};
const handleRangeChange = (nextRange: RangeKey) => {
const params = new URLSearchParams(searchParams?.toString() ?? "");
params.set("range", nextRange);
router.push(`/stats?${params.toString()}`);
};
return (
<div className="h-screen w-full flex flex-col bg-background">
<StatsHeader
global={resolvedStats.global}
range={range}
copied={copied}
onRangeChange={handleRangeChange}
onCopy={handleCopyStats}
/>
<StatsContent
resolvedStats={resolvedStats}
rangeLabel={rangeLabel}
workspaceId={workspaceId}
/>
</div>
);
}