68c94dfa3a
* 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
368 lines
12 KiB
TypeScript
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>
|
|
);
|
|
}
|