Files
kandev/apps/web/app/stats/stats-page-client.tsx
T
2026-02-19 22:18:01 +00:00

365 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import Link from "next/link";
import { IconArrowLeft } from "@tabler/icons-react";
import { Card, CardContent, CardHeader, CardTitle } from "@kandev/ui/card";
import { Button } from "@kandev/ui/button";
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 {
OverviewCards,
WorkloadSection,
RepositoryStatsGrid,
TopRepositories,
RepoLeaders,
} from "./stats-sections";
import {
ActivityHeatmap,
AgentUsageList,
CompletedTasksChart,
MostProductiveSummary,
} from "./stats-charts";
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">
<header className="flex items-center gap-3 p-4 pb-3">
<Link href="/" className="text-2xl font-bold hover:opacity-80 cursor-pointer">
KanDev
</Link>
<span className="text-muted-foreground">/</span>
<span className="text-muted-foreground">Statistics</span>
</header>
<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 (
<header className="flex items-center gap-3 p-4 pb-3 shrink-0">
<Button variant="ghost" size="sm" asChild className="cursor-pointer">
<Link href="/">
<IconArrowLeft className="h-4 w-4" />
Back
</Link>
</Button>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Link href="/" className="font-semibold text-foreground hover:opacity-80 cursor-pointer">
KanDev
</Link>
<span>›</span>
<span>Statistics</span>
<span className="text-muted-foreground/60">·</span>
<span className="font-mono text-xs">
{global.total_tasks} tasks · {global.total_sessions} sessions ·{" "}
{formatDuration(global.total_duration_ms)}
</span>
</div>
<div className="ml-auto flex items-center gap-2">
<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 font-mono text-[11px] 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 font-mono text-[11px] cursor-pointer"
onClick={onCopy}
>
{copied ? "Copied" : "Copy Stats"}
</Button>
</div>
</header>
);
}
type StatsContentProps = { resolvedStats: StatsResponse; rangeLabel: 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 StatsContent({ resolvedStats, rangeLabel }: StatsContentProps) {
const {
global,
task_stats,
daily_activity,
completed_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" />
<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={completed_activity} />
</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={completed_activity} />
</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={daily_activity} />
</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={agent_usage} />
</CardContent>
</Card>
</div>
<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="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">
<header className="flex items-center gap-3 p-4 pb-3">
<Link href="/" className="text-2xl font-bold hover:opacity-80 cursor-pointer">
KanDev
</Link>
<span className="text-muted-foreground">/</span>
<span className="text-muted-foreground">Statistics</span>
</header>
<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} />
</div>
);
}