Files
kandev/apps/web/app/stats/stats-page-client.tsx
T
2026-02-08 11:56:23 +00:00

1168 lines
44 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, IconGitCommit } 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 { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@kandev/ui/tooltip';
import type {
StatsResponse,
DailyActivityDTO,
AgentUsageDTO,
RepositoryStatsDTO,
CompletedTaskActivityDTO,
} from '@/lib/types/http';
import { useMemo, useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
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`;
}
function formatPercent(value: number): string {
return `${Math.round(value)}%`;
}
type RangeKey = 'week' | 'month';
function getRangeLabel(range: RangeKey): string {
switch (range) {
case 'week':
return 'Last Week';
case 'month':
return 'Last Month';
default:
return 'Last Month';
}
}
function formatMonthLabel(date: Date): string {
return date.toLocaleDateString('en-US', { month: 'short', year: '2-digit' });
}
function formatWeekLabel(date: Date): string {
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
}
function getHeatmapColor(intensity: number): string {
if (intensity === 0) return 'bg-muted';
if (intensity < 0.25) return 'bg-emerald-500/30';
if (intensity < 0.5) return 'bg-emerald-500/50';
if (intensity < 0.75) return 'bg-emerald-500/70';
return 'bg-emerald-500/90';
}
function formatDate(dateStr: string): string {
const date = new Date(dateStr);
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
}
interface HeatmapProps {
dailyActivity: DailyActivityDTO[];
}
function ActivityHeatmap({ dailyActivity }: HeatmapProps) {
const { weeks, maxActivity, monthLabels } = useMemo(() => {
if (!dailyActivity || dailyActivity.length === 0) {
return { weeks: [] as DailyActivityDTO[][], maxActivity: 1, monthLabels: [] as { index: number; label: string }[] };
}
const max = Math.max(...dailyActivity.map(d => d.turn_count + d.message_count), 1);
const startDate = new Date(`${dailyActivity[0].date}T00:00:00`);
const startDay = startDate.getDay();
const padded: DailyActivityDTO[] = [];
for (let i = 0; i < startDay; i++) {
padded.push({ date: '', turn_count: 0, message_count: 0, task_count: 0 });
}
padded.push(...dailyActivity);
const weeksMap: DailyActivityDTO[][] = [];
for (let i = 0; i < padded.length; i += 7) {
weeksMap.push(padded.slice(i, i + 7));
}
const monthMarkers: { index: number; label: string }[] = [];
let lastMonth = -1;
weeksMap.forEach((week, index) => {
const firstDay = week.find((d) => d.date)?.date;
if (!firstDay) return;
const date = new Date(`${firstDay}T00:00:00`);
const month = date.getMonth();
if (month !== lastMonth) {
monthMarkers.push({ index, label: date.toLocaleDateString('en-US', { month: 'short' }) });
lastMonth = month;
}
});
return { weeks: weeksMap, maxActivity: max, monthLabels: monthMarkers };
}, [dailyActivity]);
const dayLabels = ['', 'Mon', '', 'Wed', '', 'Fri', ''];
return (
<div className="overflow-x-auto">
<div className="min-w-max">
<div className="ml-8 h-4 flex items-end gap-3 text-[10px] text-muted-foreground">
{monthLabels.map((label) => (
<div key={`${label.label}-${label.index}`} style={{ marginLeft: `${label.index * 14}px` }}>
{label.label}
</div>
))}
</div>
<div className="flex gap-[3px]">
<div className="flex flex-col gap-[3px] pr-2">
{dayLabels.map((label, i) => (
<div key={i} className="h-[10px] w-6 text-[10px] text-muted-foreground flex items-center">
{label}
</div>
))}
</div>
<TooltipProvider delayDuration={100}>
<div className="flex gap-[3px]">
{weeks.map((week, weekIndex) => (
<div key={weekIndex} className="flex flex-col gap-[3px]">
{Array.from({ length: 7 }).map((_, dayIndex) => {
const day = week[dayIndex] ?? { date: '', turn_count: 0, message_count: 0, task_count: 0 };
const activity = day.turn_count + day.message_count;
const intensity = activity / maxActivity;
if (!day.date) {
return <div key={dayIndex} className="h-[10px] w-[10px]" />;
}
return (
<Tooltip key={day.date}>
<TooltipTrigger asChild>
<div className={`h-[10px] w-[10px] rounded-[2px] ${getHeatmapColor(intensity)}`} />
</TooltipTrigger>
<TooltipContent side="top" className="text-xs">
<div className="font-medium">{formatDate(day.date)}</div>
<div className="text-muted-foreground">
{day.turn_count} turns, {day.message_count} messages
</div>
</TooltipContent>
</Tooltip>
);
})}
</div>
))}
</div>
</TooltipProvider>
</div>
</div>
<div className="flex items-center gap-1 mt-2 text-[10px] text-muted-foreground">
<span>Less</span>
<div className="flex gap-[2px]">
<div className="h-[10px] w-[10px] rounded-[2px] bg-muted" />
<div className="h-[10px] w-[10px] rounded-[2px] bg-emerald-500/30" />
<div className="h-[10px] w-[10px] rounded-[2px] bg-emerald-500/50" />
<div className="h-[10px] w-[10px] rounded-[2px] bg-emerald-500/70" />
<div className="h-[10px] w-[10px] rounded-[2px] bg-emerald-500/90" />
</div>
<span>More</span>
</div>
</div>
);
}
function AgentUsageList({ agentUsage }: { agentUsage: AgentUsageDTO[] }) {
if (!agentUsage || agentUsage.length === 0) {
return <div className="text-sm text-muted-foreground py-4">No agent usage data yet.</div>;
}
const maxSessions = Math.max(...agentUsage.map((a) => a.session_count));
return (
<div className="space-y-3">
{agentUsage.map((agent) => (
<div key={agent.agent_profile_id}>
<div className="flex items-center justify-between mb-1">
<div className="min-w-0">
<div className="text-sm truncate">{agent.agent_profile_name}</div>
{agent.agent_model && (
<div className="text-[11px] text-muted-foreground font-mono truncate">
{agent.agent_model}
</div>
)}
</div>
<span className="text-xs text-muted-foreground tabular-nums ml-2">
{agent.session_count}
</span>
</div>
<div className="h-1.5 bg-muted rounded-full overflow-hidden">
<div
className="h-full bg-primary/60 rounded-full"
style={{ width: `${(agent.session_count / maxSessions) * 100}%` }}
/>
</div>
</div>
))}
</div>
);
}
function RepositoryStatsGrid({ repositoryStats }: { repositoryStats: RepositoryStatsDTO[] }) {
if (!repositoryStats || repositoryStats.length === 0) {
return <div className="text-sm text-muted-foreground py-4">No repository stats yet.</div>;
}
return (
<div className="grid gap-3 md:grid-cols-2">
{repositoryStats.map((repo) => {
const completionRate = repo.total_tasks > 0
? (repo.completed_tasks / repo.total_tasks) * 100
: 0;
const hasGit = repo.total_commits > 0 || repo.total_files_changed > 0;
return (
<div key={repo.repository_id} className="rounded-sm border bg-muted/20 p-3">
<div className="flex items-center justify-between gap-3">
<div className="text-sm font-medium truncate" title={repo.repository_name}>
{repo.repository_name}
</div>
<div className="text-xs text-muted-foreground tabular-nums font-mono">
{formatDuration(repo.total_duration_ms)}
</div>
</div>
<div className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-[11px] text-muted-foreground font-mono">
<span>{repo.total_tasks} tasks</span>
<span>{repo.session_count} sessions</span>
<span>{repo.turn_count} turns</span>
<span>{repo.message_count} msgs</span>
</div>
<div className="mt-3">
<div className="flex items-center justify-between text-[10px] text-muted-foreground">
<span>Completion</span>
<span className="tabular-nums font-mono">
{formatPercent(completionRate)} · {repo.completed_tasks}/{repo.total_tasks}
</span>
</div>
</div>
<div className="mt-2 pt-2 border-t text-[11px] text-muted-foreground">
{hasGit ? (
<div className="flex items-center justify-between">
<span className="font-mono">{repo.total_commits} commits</span>
<span className="font-mono tabular-nums">
<span className="text-emerald-600 dark:text-emerald-400">
+{repo.total_insertions.toLocaleString()}
</span>{' '}
<span className="text-red-600 dark:text-red-400">
−{repo.total_deletions.toLocaleString()}
</span>
</span>
</div>
) : (
<div className="text-[11px] text-muted-foreground">No git activity yet.</div>
)}
</div>
</div>
);
})}
</div>
);
}
function TopRepositories({ repositoryStats }: { repositoryStats: RepositoryStatsDTO[] }) {
if (!repositoryStats || repositoryStats.length === 0) {
return <div className="text-sm text-muted-foreground py-4">No repository stats yet.</div>;
}
const topByTurns = [...repositoryStats]
.filter((repo) => repo.turn_count > 0)
.sort((a, b) => b.turn_count - a.turn_count)
.slice(0, 3);
const topByMessages = [...repositoryStats]
.filter((repo) => repo.message_count > 0)
.sort((a, b) => b.message_count - a.message_count)
.slice(0, 3);
return (
<div className="grid gap-4 md:grid-cols-2">
<div>
<div className="text-[11px] uppercase tracking-wider text-muted-foreground mb-2">
Top By Turns
</div>
<div className="space-y-2">
{topByTurns.length === 0 && (
<div className="text-sm text-muted-foreground">No turn activity yet.</div>
)}
{topByTurns.map((repo, idx) => (
<div key={repo.repository_id} className="flex items-center gap-3">
<span className="text-xs text-muted-foreground w-4">{idx + 1}.</span>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate" title={repo.repository_name}>
{repo.repository_name}
</div>
</div>
<div className="text-sm font-medium tabular-nums font-mono">
{repo.turn_count}
</div>
</div>
))}
</div>
</div>
<div>
<div className="text-[11px] uppercase tracking-wider text-muted-foreground mb-2">
Top By Messages
</div>
<div className="space-y-2">
{topByMessages.length === 0 && (
<div className="text-sm text-muted-foreground">No message activity yet.</div>
)}
{topByMessages.map((repo, idx) => (
<div key={repo.repository_id} className="flex items-center gap-3">
<span className="text-xs text-muted-foreground w-4">{idx + 1}.</span>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate" title={repo.repository_name}>
{repo.repository_name}
</div>
</div>
<div className="text-sm font-medium tabular-nums font-mono">
{repo.message_count}
</div>
</div>
))}
</div>
</div>
</div>
);
}
function RepoLeaders({ repositoryStats }: { repositoryStats: RepositoryStatsDTO[] }) {
if (!repositoryStats || repositoryStats.length === 0) {
return <div className="text-sm text-muted-foreground py-4">No repository stats yet.</div>;
}
const topByTasks = [...repositoryStats]
.filter((repo) => repo.total_tasks > 0)
.sort((a, b) => b.total_tasks - a.total_tasks)
.slice(0, 3);
const topByTime = [...repositoryStats]
.filter((repo) => repo.total_duration_ms > 0)
.sort((a, b) => b.total_duration_ms - a.total_duration_ms)
.slice(0, 3);
const topByCommits = [...repositoryStats]
.filter((repo) => repo.total_commits > 0)
.sort((a, b) => b.total_commits - a.total_commits)
.slice(0, 3);
return (
<div className="grid gap-4 md:grid-cols-3">
<div>
<div className="text-[11px] uppercase tracking-wider text-muted-foreground mb-2">
Most Tasks
</div>
<div className="space-y-2">
{topByTasks.length === 0 && (
<div className="text-sm text-muted-foreground">No tasks yet.</div>
)}
{topByTasks.map((repo, idx) => (
<div key={repo.repository_id} className="flex items-center gap-3">
<span className="text-xs text-muted-foreground w-4">{idx + 1}.</span>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate" title={repo.repository_name}>
{repo.repository_name}
</div>
</div>
<div className="text-sm font-medium tabular-nums font-mono">
{repo.total_tasks}
</div>
</div>
))}
</div>
</div>
<div>
<div className="text-[11px] uppercase tracking-wider text-muted-foreground mb-2">
Most Time
</div>
<div className="space-y-2">
{topByTime.length === 0 && (
<div className="text-sm text-muted-foreground">No time logged yet.</div>
)}
{topByTime.map((repo, idx) => (
<div key={repo.repository_id} className="flex items-center gap-3">
<span className="text-xs text-muted-foreground w-4">{idx + 1}.</span>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate" title={repo.repository_name}>
{repo.repository_name}
</div>
</div>
<div className="text-sm font-medium tabular-nums font-mono">
{formatDuration(repo.total_duration_ms)}
</div>
</div>
))}
</div>
</div>
<div>
<div className="text-[11px] uppercase tracking-wider text-muted-foreground mb-2">
Most Commits
</div>
<div className="space-y-2">
{topByCommits.length === 0 && (
<div className="text-sm text-muted-foreground">No commits yet.</div>
)}
{topByCommits.map((repo, idx) => (
<div key={repo.repository_id} className="flex items-center gap-3">
<span className="text-xs text-muted-foreground w-4">{idx + 1}.</span>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate" title={repo.repository_name}>
{repo.repository_name}
</div>
</div>
<div className="text-sm font-medium tabular-nums font-mono">
{repo.total_commits}
</div>
</div>
))}
</div>
</div>
</div>
);
}
type CompletionBucket = 'day' | 'week' | 'month';
function CompletedTasksChart({ completedActivity }: { completedActivity: CompletedTaskActivityDTO[] }) {
const [bucket, setBucket] = useState<CompletionBucket>('day');
const safeCompleted = useMemo(() => completedActivity ?? [], [completedActivity]);
const series = useMemo(() => {
if (safeCompleted.length === 0) {
return [] as { label: string; count: number; date: Date }[];
}
const toDate = (dateStr: string) => {
const [year, month, day] = dateStr.split('-').map(Number);
return new Date(Date.UTC(year, month - 1, day));
};
const data = safeCompleted
.map((item) => ({ date: toDate(item.date), count: item.completed_tasks }))
.filter((item) => Number.isFinite(item.date.getTime()));
const buckets = new Map<string, { label: string; count: number; date: Date }>();
data.forEach((item) => {
if (bucket === 'day') {
const key = item.date.toISOString().slice(0, 10);
buckets.set(key, { label: formatDate(key), count: item.count, date: item.date });
return;
}
if (bucket === 'week') {
const d = new Date(item.date);
const day = d.getUTCDay();
const diff = (day + 6) % 7;
d.setUTCDate(d.getUTCDate() - diff);
d.setUTCHours(0, 0, 0, 0);
const key = d.toISOString().slice(0, 10);
const existing = buckets.get(key);
const nextCount = (existing?.count ?? 0) + item.count;
buckets.set(key, { label: formatWeekLabel(d), count: nextCount, date: d });
return;
}
const d = new Date(Date.UTC(item.date.getUTCFullYear(), item.date.getUTCMonth(), 1));
const key = `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, '0')}`;
const existing = buckets.get(key);
const nextCount = (existing?.count ?? 0) + item.count;
buckets.set(key, { label: formatMonthLabel(d), count: nextCount, date: d });
});
return Array.from(buckets.values()).sort((a, b) => a.date.getTime() - b.date.getTime());
}, [bucket, safeCompleted]);
const maxCount = Math.max(...series.map((item) => item.count), 1);
if (safeCompleted.length === 0) {
return <div className="text-sm text-muted-foreground py-4">No completed task data yet.</div>;
}
return (
<div className="space-y-3">
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<span className="uppercase tracking-wider text-[10px]">Bucket</span>
<Button
type="button"
size="sm"
variant={bucket === 'day' ? 'secondary' : 'outline'}
className="h-7 px-2 font-mono text-[11px] cursor-pointer"
onClick={() => setBucket('day')}
>
Day
</Button>
<Button
type="button"
size="sm"
variant={bucket === 'week' ? 'secondary' : 'outline'}
className="h-7 px-2 font-mono text-[11px] cursor-pointer"
onClick={() => setBucket('week')}
>
Week
</Button>
<Button
type="button"
size="sm"
variant={bucket === 'month' ? 'secondary' : 'outline'}
className="h-7 px-2 font-mono text-[11px] cursor-pointer"
onClick={() => setBucket('month')}
>
Month
</Button>
</div>
<div className="h-32 flex items-end gap-1">
<TooltipProvider delayDuration={100}>
{series.map((item, index) => {
const height = Math.max(6, Math.round((item.count / maxCount) * 100));
return (
<Tooltip key={`${item.label}-${index}`}>
<TooltipTrigger asChild>
<div
className="flex-1 rounded-[2px] bg-emerald-500/70 hover:bg-emerald-500/90 transition-colors"
style={{ height: `${height}%` }}
/>
</TooltipTrigger>
<TooltipContent side="top" className="text-xs">
<div className="font-medium">{item.label}</div>
<div className="text-muted-foreground">{item.count} completed</div>
</TooltipContent>
</Tooltip>
);
})}
</TooltipProvider>
</div>
<div className="flex items-center justify-between text-[10px] text-muted-foreground font-mono">
<span>{series[0]?.label ?? ''}</span>
<span>{series[series.length - 1]?.label ?? ''}</span>
</div>
</div>
);
}
function MostProductiveSummary({ completedActivity }: { completedActivity: CompletedTaskActivityDTO[] }) {
const safeCompleted = useMemo(() => completedActivity ?? [], [completedActivity]);
const stats = useMemo(() => {
if (safeCompleted.length === 0) {
return {
maxWeekday: { idx: 0, value: 0 },
maxMonth: { idx: 0, value: 0 },
maxYear: { year: 0, value: 0 },
};
}
const weekdayTotals = Array(7).fill(0);
const monthTotals = Array(12).fill(0);
const yearTotals = new Map<number, number>();
safeCompleted.forEach((item) => {
const [year, month, day] = item.date.split('-').map(Number);
const date = new Date(Date.UTC(year, month - 1, day));
weekdayTotals[date.getUTCDay()] += item.completed_tasks;
monthTotals[date.getUTCMonth()] += item.completed_tasks;
yearTotals.set(year, (yearTotals.get(year) ?? 0) + item.completed_tasks);
});
const maxWeekday = weekdayTotals.reduce((best, value, idx) => (
value > best.value ? { idx, value } : best
), { idx: 0, value: weekdayTotals[0] ?? 0 });
const maxMonth = monthTotals.reduce((best, value, idx) => (
value > best.value ? { idx, value } : best
), { idx: 0, value: monthTotals[0] ?? 0 });
let maxYear = { year: 0, value: 0 };
yearTotals.forEach((value, year) => {
if (value > maxYear.value) {
maxYear = { year, value };
}
});
return { maxWeekday, maxMonth, maxYear };
}, [safeCompleted]);
if (safeCompleted.length === 0) {
return <div className="text-sm text-muted-foreground py-4">No completed task data yet.</div>;
}
const weekdayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
const monthNames = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
return (
<div className="space-y-3">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Best weekday</span>
<span className="font-mono tabular-nums">
{weekdayNames[stats.maxWeekday.idx]} · {stats.maxWeekday.value}
</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Best month</span>
<span className="font-mono tabular-nums">
{monthNames[stats.maxMonth.idx]} · {stats.maxMonth.value}
</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Best year</span>
<span className="font-mono tabular-nums">
{stats.maxYear.year || '—'} · {stats.maxYear.value}
</span>
</div>
</div>
);
}
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(() => {
const repoCount = resolvedStats.repository_stats.length;
const completed = resolvedStats.global.completed_tasks;
const inProgress = resolvedStats.global.in_progress_tasks;
const total = resolvedStats.global.total_tasks;
const completion = total > 0 ? `${Math.round((completed / total) * 100)}%` : '—';
const time = formatDuration(resolvedStats.global.total_duration_ms);
const avgTask = formatDuration(resolvedStats.global.avg_duration_ms_per_task);
const topRepo = resolvedStats.repository_stats
.filter((repo) => repo.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 = resolvedStats.git_stats
&& (resolvedStats.git_stats.total_commits > 0 || resolvedStats.git_stats.total_files_changed > 0);
const gitLine = hasGitStats
? `${resolvedStats.git_stats.total_commits} commits, +${resolvedStats.git_stats.total_insertions.toLocaleString()}/-${resolvedStats.git_stats.total_deletions.toLocaleString()}`
: 'no git activity';
return [
`*KanDev Stats — ${rangeLabel}*`,
`- Tasks: ${total} total (${completed} done, ${inProgress} in progress) · ${completion} completion`,
`- Completed (${rangeLabel}): ${completedInRange}`,
`- Time: ${time} total · ${avgTask} avg/task`,
`- Repos: ${repoCount} tracked · Top repo: ${topRepoLabel}`,
`- Git: ${gitLine}`,
].join('\n');
}, [completedInRange, rangeLabel, resolvedStats]);
// No workspace selected
if (!workspaceId) {
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">Select a workspace to view statistics.</p>
</div>
</div>
);
}
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 (
<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">No stats available.</p>
</div>
</div>
);
}
const {
global,
task_stats,
daily_activity,
completed_activity,
agent_usage,
repository_stats,
git_stats,
} = resolvedStats;
const completionRate = global.total_tasks > 0
? Math.round((global.completed_tasks / global.total_tasks) * 100)
: 0;
const hasGitStats = git_stats && (git_stats.total_commits > 0 || git_stats.total_files_changed > 0);
const toolShare = global.total_messages > 0
? Math.round((global.total_tool_calls / global.total_messages) * 100)
: 0;
const userShare = global.total_messages > 0
? Math.round((global.total_user_messages / global.total_messages) * 100)
: 0;
const avgTurnsPerSession = global.total_sessions > 0
? global.total_turns / global.total_sessions
: 0;
const avgMessagesPerSession = global.total_sessions > 0
? global.total_messages / global.total_sessions
: 0;
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">
{/* Header matching task session top bar */}
<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={(value) => {
if (value) handleRangeChange(value as RangeKey);
}}
variant="outline"
className="h-7"
>
{(['week', 'month'] 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={handleCopyStats}
>
{copied ? 'Copied' : 'Copy Stats'}
</Button>
</div>
</header>
{/* Scrollable content area */}
<div className="flex-1 overflow-auto">
<div className="max-w-7xl mx-auto p-6">
<div className="space-y-5">
{/* Overview Cards */}
<div id="overview" className="grid gap-4 md:grid-cols-2 lg:grid-cols-4 scroll-mt-24">
{/* Tasks Overview */}
<Card className="rounded-sm">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Tasks</CardTitle>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold tabular-nums">{global.total_tasks}</div>
<div className="flex items-center gap-4 mt-2 text-sm text-muted-foreground">
<span>{global.completed_tasks} completed</span>
<span>{global.in_progress_tasks} in progress</span>
</div>
{global.total_tasks > 0 && (
<div className="mt-3">
<div className="flex justify-between text-xs mb-1">
<span className="text-muted-foreground">Completion rate</span>
<span className="tabular-nums">{completionRate}%</span>
</div>
<div className="h-1.5 bg-muted rounded-full overflow-hidden">
<div
className="h-full bg-emerald-500/70 rounded-full"
style={{ width: `${completionRate}%` }}
/>
</div>
</div>
)}
</CardContent>
</Card>
{/* Time Spent */}
<Card className="rounded-sm">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Time Spent</CardTitle>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold tabular-nums">{formatDuration(global.total_duration_ms)}</div>
<div className="mt-2 text-sm text-muted-foreground">
{formatDuration(global.avg_duration_ms_per_task)} avg per task
</div>
<div className="mt-3 grid grid-cols-2 gap-4 pt-3 border-t">
<div>
<div className="text-lg font-semibold tabular-nums">{global.total_turns}</div>
<div className="text-xs text-muted-foreground">Total turns</div>
</div>
<div>
<div className="text-lg font-semibold tabular-nums">{global.total_messages}</div>
<div className="text-xs text-muted-foreground">Total messages</div>
</div>
</div>
</CardContent>
</Card>
{/* Git Stats or Averages */}
{hasGitStats ? (
<Card className="rounded-sm">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground flex items-center gap-2">
<IconGitCommit className="h-4 w-4" />
Git Activity
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold tabular-nums">{git_stats.total_commits}</div>
<div className="mt-2 text-sm text-muted-foreground">
{git_stats.total_files_changed} files changed
</div>
<div className="mt-3 flex items-center gap-4 pt-3 border-t text-sm">
<span className="text-emerald-600 dark:text-emerald-400 tabular-nums">
+{git_stats.total_insertions.toLocaleString()}
</span>
<span className="text-red-600 dark:text-red-400 tabular-nums">
−{git_stats.total_deletions.toLocaleString()}
</span>
</div>
</CardContent>
</Card>
) : (
<Card className="rounded-sm">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Averages</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3">
<div className="flex justify-between">
<span className="text-sm text-muted-foreground">Turns per task</span>
<span className="font-medium tabular-nums">{global.avg_turns_per_task.toFixed(1)}</span>
</div>
<div className="flex justify-between">
<span className="text-sm text-muted-foreground">Messages per task</span>
<span className="font-medium tabular-nums">{global.avg_messages_per_task.toFixed(1)}</span>
</div>
<div className="flex justify-between">
<span className="text-sm text-muted-foreground">Sessions</span>
<span className="font-medium tabular-nums">{global.total_sessions}</span>
</div>
</div>
</CardContent>
</Card>
)}
{/* Signal */}
<Card className="rounded-sm">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Signal</CardTitle>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold tabular-nums">
{global.total_sessions}
</div>
<div className="mt-2 text-sm text-muted-foreground">
{avgTurnsPerSession.toFixed(1)} turns · {avgMessagesPerSession.toFixed(1)} messages per session
</div>
<div className="mt-3 grid grid-cols-2 gap-4 pt-3 border-t text-xs text-muted-foreground">
<div className="space-y-1">
<div className="flex justify-between">
<span>User msgs</span>
<span className="tabular-nums font-mono">{global.total_user_messages}</span>
</div>
<div className="flex justify-between">
<span>User share</span>
<span className="tabular-nums font-mono">{formatPercent(userShare)}</span>
</div>
</div>
<div className="space-y-1">
<div className="flex justify-between">
<span>Tool calls</span>
<span className="tabular-nums font-mono">{global.total_tool_calls}</span>
</div>
<div className="flex justify-between">
<span>Tool share</span>
<span className="tabular-nums font-mono">{formatPercent(toolShare)}</span>
</div>
</div>
</div>
</CardContent>
</Card>
</div>
<div id="telemetry" className="flex items-center gap-3 pt-2 scroll-mt-24">
<div className="text-[11px] uppercase tracking-wider text-muted-foreground">Telemetry</div>
<div className="h-px flex-1 bg-border/60" />
</div>
<div id="completed" className="scroll-mt-24">
{/* Completed Tasks and Productivity */}
<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>
{/* Activity and Agents Row */}
<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>
{/* Repositories */}
<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>
<div id="workload" className="flex items-center gap-3 pt-2 scroll-mt-24">
<div className="text-[11px] uppercase tracking-wider text-muted-foreground">Workload</div>
<div className="h-px flex-1 bg-border/60" />
</div>
{/* Top Tasks by Duration */}
{task_stats.length > 0 && (
<div className="grid gap-4 lg:grid-cols-2">
{/* Longest Tasks (Most Complex) */}
<Card className="rounded-sm">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
Longest Tasks
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3">
{[...task_stats]
.filter(t => t.total_duration_ms > 0)
.sort((a, b) => b.total_duration_ms - a.total_duration_ms)
.slice(0, 3)
.map((task, idx) => (
<div key={task.task_id} className="flex items-center gap-3">
<span className="text-xs text-muted-foreground w-4">{idx + 1}.</span>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate" title={task.task_title}>
{task.task_title}
</div>
<div className="text-xs text-muted-foreground">
{task.turn_count} turns · {task.message_count} messages
</div>
</div>
<div className="text-sm font-medium tabular-nums text-right">
{formatDuration(task.total_duration_ms)}
</div>
</div>
))}
{task_stats.filter(t => t.total_duration_ms > 0).length === 0 && (
<div className="text-sm text-muted-foreground py-2">No completed tasks yet.</div>
)}
</div>
</CardContent>
</Card>
{/* Quickest Tasks */}
<Card className="rounded-sm">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
Quickest Tasks
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3">
{[...task_stats]
.filter(t => t.total_duration_ms > 0)
.sort((a, b) => a.total_duration_ms - b.total_duration_ms)
.slice(0, 3)
.map((task, idx) => (
<div key={task.task_id} className="flex items-center gap-3">
<span className="text-xs text-muted-foreground w-4">{idx + 1}.</span>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate" title={task.task_title}>
{task.task_title}
</div>
<div className="text-xs text-muted-foreground">
{task.turn_count} turns · {task.message_count} messages
</div>
</div>
<div className="text-sm font-medium tabular-nums text-right">
{formatDuration(task.total_duration_ms)}
</div>
</div>
))}
{task_stats.filter(t => t.total_duration_ms > 0).length === 0 && (
<div className="text-sm text-muted-foreground py-2">No completed tasks yet.</div>
)}
</div>
</CardContent>
</Card>
</div>
)}
</div>
</div>
</div>
</div>
);
}