agentfit 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/.github/workflows/release.yml +111 -0
  2. package/README.md +41 -38
  3. package/app/(dashboard)/daily/page.tsx +1 -1
  4. package/app/(dashboard)/data-management/page.tsx +180 -0
  5. package/app/(dashboard)/flow/page.tsx +17 -0
  6. package/app/(dashboard)/layout.tsx +2 -0
  7. package/app/(dashboard)/page.tsx +24 -5
  8. package/app/(dashboard)/reports/[id]/page.tsx +72 -0
  9. package/app/(dashboard)/reports/page.tsx +132 -0
  10. package/app/(dashboard)/sessions/[id]/page.tsx +167 -0
  11. package/app/api/backup/route.ts +215 -0
  12. package/app/api/check/route.ts +11 -1
  13. package/app/api/command-insights/route.ts +13 -0
  14. package/app/api/commands/route.ts +55 -1
  15. package/app/api/images-analysis/route.ts +3 -4
  16. package/app/api/reports/[id]/route.ts +23 -0
  17. package/app/api/reports/route.ts +50 -0
  18. package/app/api/reset/route.ts +21 -0
  19. package/app/api/session/route.ts +40 -0
  20. package/app/api/usage/route.ts +26 -1
  21. package/app/layout.tsx +1 -1
  22. package/bin/agentfit.mjs +2 -2
  23. package/components/agent-coach.tsx +256 -129
  24. package/components/app-sidebar.tsx +45 -10
  25. package/components/backup-section.tsx +236 -0
  26. package/components/daily-chart.tsx +447 -83
  27. package/components/dashboard-shell.tsx +29 -31
  28. package/components/data-provider.tsx +88 -8
  29. package/components/fitness-score.tsx +95 -54
  30. package/components/overview-cards.tsx +148 -41
  31. package/components/report-view.tsx +307 -0
  32. package/components/screenshots-analysis.tsx +51 -46
  33. package/components/session-chatlog.tsx +124 -0
  34. package/components/session-timeline.tsx +184 -0
  35. package/components/session-workflow.tsx +183 -0
  36. package/components/sessions-table.tsx +9 -1
  37. package/components/tool-flow-graph.tsx +144 -0
  38. package/components/ui/carousel.tsx +242 -0
  39. package/components/ui/sidebar.tsx +1 -1
  40. package/components/ui/sonner.tsx +51 -0
  41. package/electron/entitlements.mac.plist +16 -0
  42. package/electron/init-db.mjs +37 -0
  43. package/electron/main.mjs +203 -0
  44. package/generated/prisma/browser.ts +5 -0
  45. package/generated/prisma/client.ts +5 -0
  46. package/generated/prisma/internal/class.ts +14 -4
  47. package/generated/prisma/internal/prismaNamespace.ts +97 -2
  48. package/generated/prisma/internal/prismaNamespaceBrowser.ts +21 -1
  49. package/generated/prisma/models/Report.ts +1219 -0
  50. package/generated/prisma/models/Session.ts +221 -1
  51. package/generated/prisma/models.ts +1 -0
  52. package/lib/coach.ts +571 -211
  53. package/lib/command-insights.ts +231 -0
  54. package/lib/db.ts +2 -2
  55. package/lib/parse-codex.ts +6 -0
  56. package/lib/parse-logs.ts +80 -1
  57. package/lib/queries-codex.ts +24 -0
  58. package/lib/queries.ts +45 -0
  59. package/lib/report.ts +156 -0
  60. package/lib/session-detail.ts +382 -0
  61. package/lib/sync.ts +87 -0
  62. package/lib/tool-flow.ts +71 -0
  63. package/next.config.mjs +6 -1
  64. package/package.json +17 -2
  65. package/plugins/cost-heatmap/component.tsx +72 -50
  66. package/prisma/migrations/20260401144555_add_system_prompt_edits/migration.sql +80 -0
  67. package/prisma/schema.prisma +18 -0
  68. package/prisma/schema.sql +81 -0
  69. package/.claude/settings.local.json +0 -26
  70. package/CONTRIBUTING.md +0 -209
  71. package/prisma/migrations/20260328152517_init/migration.sql +0 -41
  72. package/prisma/migrations/20260328153801_add_image_model/migration.sql +0 -18
  73. package/prisma.config.ts +0 -14
  74. package/setup.sh +0 -73
@@ -0,0 +1,50 @@
1
+ import { NextResponse } from 'next/server'
2
+ import { prisma } from '@/lib/db'
3
+ import { generateReport } from '@/lib/report'
4
+
5
+ export const dynamic = 'force-dynamic'
6
+
7
+ export async function GET() {
8
+ try {
9
+ const reports = await prisma.report.findMany({
10
+ orderBy: { generatedAt: 'desc' },
11
+ select: { id: true, title: true, generatedAt: true, sessionCount: true },
12
+ })
13
+ return NextResponse.json(reports)
14
+ } catch (error) {
15
+ return NextResponse.json({ error: (error as Error).message }, { status: 500 })
16
+ }
17
+ }
18
+
19
+ export async function POST() {
20
+ try {
21
+ const { title, contentJson, sessionCount } = await generateReport()
22
+
23
+ // Check if data has changed since last report
24
+ const lastReport = await prisma.report.findFirst({
25
+ orderBy: { generatedAt: 'desc' },
26
+ select: { sessionCount: true },
27
+ })
28
+
29
+ if (lastReport && lastReport.sessionCount === sessionCount) {
30
+ return NextResponse.json(
31
+ { error: 'No new data since the last report. Sync new sessions first.' },
32
+ { status: 409 }
33
+ )
34
+ }
35
+
36
+ const report = await prisma.report.create({
37
+ data: {
38
+ title,
39
+ contentJson: JSON.stringify(contentJson),
40
+ sessionCount,
41
+ },
42
+ })
43
+ return NextResponse.json({
44
+ ...report,
45
+ contentJson: JSON.parse(report.contentJson),
46
+ })
47
+ } catch (error) {
48
+ return NextResponse.json({ error: (error as Error).message }, { status: 500 })
49
+ }
50
+ }
@@ -0,0 +1,21 @@
1
+ import { NextResponse } from 'next/server'
2
+ import { prisma } from '@/lib/db'
3
+ import { syncLogs } from '@/lib/sync'
4
+
5
+ export const dynamic = 'force-dynamic'
6
+
7
+ export async function POST() {
8
+ try {
9
+ // Delete all records in order (images reference sessions)
10
+ await prisma.image.deleteMany()
11
+ await prisma.session.deleteMany()
12
+ await prisma.syncLog.deleteMany()
13
+
14
+ // Re-sync from disk
15
+ const result = await syncLogs()
16
+ return NextResponse.json(result)
17
+ } catch (error) {
18
+ console.error('Reset failed:', error)
19
+ return NextResponse.json({ error: 'Reset failed' }, { status: 500 })
20
+ }
21
+ }
@@ -0,0 +1,40 @@
1
+ import { NextRequest, NextResponse } from 'next/server'
2
+ import path from 'path'
3
+ import os from 'os'
4
+ import fs from 'fs'
5
+ import { parseSessionDetail } from '@/lib/session-detail'
6
+
7
+ export const dynamic = 'force-dynamic'
8
+
9
+ export async function GET(request: NextRequest) {
10
+ const sessionId = request.nextUrl.searchParams.get('id')
11
+ if (!sessionId) {
12
+ return NextResponse.json({ error: 'Missing session id' }, { status: 400 })
13
+ }
14
+
15
+ try {
16
+ // Search for the JSONL file across all project directories
17
+ const projectsDir = path.join(os.homedir(), '.claude', 'projects')
18
+ let filePath: string | null = null
19
+
20
+ if (fs.existsSync(projectsDir)) {
21
+ const dirs = fs.readdirSync(projectsDir)
22
+ for (const dir of dirs) {
23
+ const candidate = path.join(projectsDir, dir, `${sessionId}.jsonl`)
24
+ if (fs.existsSync(candidate)) {
25
+ filePath = candidate
26
+ break
27
+ }
28
+ }
29
+ }
30
+
31
+ if (!filePath) {
32
+ return NextResponse.json({ error: 'Session not found' }, { status: 404 })
33
+ }
34
+
35
+ const detail = parseSessionDetail(filePath, sessionId)
36
+ return NextResponse.json(detail)
37
+ } catch (error) {
38
+ return NextResponse.json({ error: (error as Error).message }, { status: 500 })
39
+ }
40
+ }
@@ -65,6 +65,8 @@ function mergeUsageData(a: UsageData, b: UsageData): UsageData {
65
65
  if (existing) {
66
66
  existing.sessions += day.sessions
67
67
  existing.messages += day.messages
68
+ existing.userMessages += day.userMessages
69
+ existing.assistantMessages += day.assistantMessages
68
70
  existing.inputTokens += day.inputTokens
69
71
  existing.outputTokens += day.outputTokens
70
72
  existing.cacheCreationTokens += day.cacheCreationTokens
@@ -72,8 +74,13 @@ function mergeUsageData(a: UsageData, b: UsageData): UsageData {
72
74
  existing.totalTokens += day.totalTokens
73
75
  existing.costUSD += day.costUSD
74
76
  existing.toolCalls += day.toolCalls
77
+ existing.interruptions += day.interruptions
78
+ existing.rateLimitErrors += day.rateLimitErrors
79
+ for (const [tool, count] of Object.entries(day.toolCallsDetail)) {
80
+ existing.toolCallsDetail[tool] = (existing.toolCallsDetail[tool] || 0) + count
81
+ }
75
82
  } else {
76
- dailyMap.set(day.date, { ...day })
83
+ dailyMap.set(day.date, { ...day, toolCallsDetail: { ...day.toolCallsDetail } })
77
84
  }
78
85
  }
79
86
 
@@ -107,7 +114,25 @@ function mergeUsageData(a: UsageData, b: UsageData): UsageData {
107
114
  totalCostUSD: a.overview.totalCostUSD + b.overview.totalCostUSD,
108
115
  totalDurationMinutes: a.overview.totalDurationMinutes + b.overview.totalDurationMinutes,
109
116
  totalToolCalls: a.overview.totalToolCalls + b.overview.totalToolCalls,
117
+ totalApiErrors: a.overview.totalApiErrors + b.overview.totalApiErrors,
118
+ totalRateLimitDays: a.overview.totalRateLimitDays + b.overview.totalRateLimitDays,
119
+ totalUserInterruptions: a.overview.totalUserInterruptions + b.overview.totalUserInterruptions,
120
+ totalSystemPromptEdits: (a.overview.totalSystemPromptEdits ?? 0) + (b.overview.totalSystemPromptEdits ?? 0),
110
121
  models,
122
+ skillUsage: (() => {
123
+ const skills: Record<string, number> = { ...a.overview.skillUsage }
124
+ for (const [s, c] of Object.entries(b.overview.skillUsage)) {
125
+ skills[s] = (skills[s] || 0) + c
126
+ }
127
+ return skills
128
+ })(),
129
+ permissionModes: (() => {
130
+ const modes: Record<string, number> = { ...(a.overview.permissionModes || {}) }
131
+ for (const [m, c] of Object.entries(b.overview.permissionModes || {})) {
132
+ modes[m] = (modes[m] || 0) + c
133
+ }
134
+ return modes
135
+ })(),
111
136
  },
112
137
  sessions,
113
138
  projects,
package/app/layout.tsx CHANGED
@@ -12,7 +12,7 @@ const fontMono = Geist_Mono({
12
12
  })
13
13
 
14
14
  export const metadata = {
15
- title: 'AgentFit — Coding Agent Fitness Tracker',
15
+ title: 'AgentFit — Your AI Coding Fitness Tracker',
16
16
  description: 'Track your AI coding agent\'s fitness — usage, costs, personality, and behavioral patterns across Claude Code, Codex, and more.',
17
17
  }
18
18
 
package/bin/agentfit.mjs CHANGED
@@ -26,7 +26,7 @@ function run(cmd, opts = {}) {
26
26
  // ─── Ensure .env exists ─────────────────────────────────────────────
27
27
  const envPath = path.join(ROOT, '.env')
28
28
  if (!existsSync(envPath)) {
29
- writeFileSync(envPath, 'DATABASE_URL="file:./dev.db"\n')
29
+ writeFileSync(envPath, 'DATABASE_URL="file:./agentfit.db"\n')
30
30
  }
31
31
 
32
32
  // ─── First-run setup: prisma generate + migrate ─────────────────────
@@ -36,7 +36,7 @@ if (!existsSync(generatedClient)) {
36
36
  run('npx prisma generate')
37
37
  }
38
38
 
39
- const dbPath = path.join(ROOT, 'dev.db')
39
+ const dbPath = path.join(ROOT, 'agentfit.db')
40
40
  if (!existsSync(dbPath)) {
41
41
  info('Creating database...')
42
42
  run('npx prisma migrate deploy')
@@ -1,8 +1,9 @@
1
1
  'use client'
2
2
 
3
- import { useMemo } from 'react'
3
+ import { useMemo, useEffect, useState } from 'react'
4
4
  import type { UsageData } from '@/lib/parse-logs'
5
- import { generateCoachInsights, type CoachInsight, type InsightCategory, type InsightSeverity } from '@/lib/coach'
5
+ import { generateCoachInsights, type CoachInsight, type InsightCategory, type InsightSeverity, type CraftDimension, type CraftScores } from '@/lib/coach'
6
+ import type { CommandInsight } from '@/lib/command-insights'
6
7
  import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
7
8
  import { formatCost, formatDuration, formatNumber } from '@/lib/format'
8
9
  import {
@@ -18,8 +19,11 @@ import {
18
19
  Calendar,
19
20
  Search,
20
21
  Clock,
21
- TrendingUp,
22
- MessageSquare,
22
+ Brain,
23
+ Radar,
24
+ Bot,
25
+ Activity,
26
+ Gauge,
23
27
  } from 'lucide-react'
24
28
 
25
29
  const CATEGORY_ICONS: Record<InsightCategory, typeof Trophy> = {
@@ -33,48 +37,90 @@ const CATEGORY_ICONS: Record<InsightCategory, typeof Trophy> = {
33
37
  streak: Flame,
34
38
  }
35
39
 
36
- const CATEGORY_LABELS: Record<InsightCategory, string> = {
37
- cost: 'Cost',
38
- efficiency: 'Efficiency',
39
- tools: 'Tools',
40
- context: 'Context',
41
- model: 'Model',
42
- habits: 'Habits',
43
- discovery: 'Discovery',
44
- streak: 'Streak',
45
- }
46
-
47
40
  const SEVERITY_STYLES: Record<InsightSeverity, { border: string; icon: typeof Trophy; iconClass: string }> = {
48
41
  achievement: { border: 'border-l-chart-2', icon: Trophy, iconClass: 'text-chart-2' },
49
42
  warning: { border: 'border-l-chart-5', icon: AlertTriangle, iconClass: 'text-chart-5' },
50
43
  tip: { border: 'border-l-chart-1', icon: Lightbulb, iconClass: 'text-chart-1' },
51
44
  }
52
45
 
46
+ const CRAFT_DIMENSIONS: {
47
+ key: CraftDimension
48
+ letter: string
49
+ label: string
50
+ color: string
51
+ icon: typeof Brain
52
+ description: string
53
+ metrics: string[]
54
+ }[] = [
55
+ {
56
+ key: 'context',
57
+ letter: 'C',
58
+ label: 'Context',
59
+ color: 'var(--chart-1)',
60
+ icon: Brain,
61
+ description: 'How well you engineer the context available to the AI — not just window size, but the holistic curation of tokens: system prompts (CLAUDE.md), just-in-time retrieval, structured notes, sub-agent isolation, and cache efficiency.',
62
+ metrics: ['System prompt maintenance (15%)', 'Cache reuse (15%)', 'Overflow avoidance (15%)', 'Just-in-time retrieval (20%)', 'Session length (10%)', 'Note-taking (10%)', 'Output density (10%)', 'Sub-agent isolation (5%)'],
63
+ },
64
+ {
65
+ key: 'reach',
66
+ letter: 'R',
67
+ label: 'Reach',
68
+ color: 'var(--chart-2)',
69
+ icon: Radar,
70
+ description: 'How broadly you leverage available capabilities. Using diverse tools, subagents, and custom skills means you\'re getting more out of the AI assistant.',
71
+ metrics: ['Tool diversity (35%)', 'Subagent parallelization (35%)', 'Skill/command adoption (30%)'],
72
+ },
73
+ {
74
+ key: 'autonomy',
75
+ letter: 'A',
76
+ label: 'Autonomy',
77
+ color: 'var(--chart-3)',
78
+ icon: Bot,
79
+ description: 'How independently the agent works for you. High autonomy means clear prompts, fewer interruptions, the agent reading before editing, and trusting it with permissions.',
80
+ metrics: ['Assistant/user message ratio (25%)', 'Low interruption rate (25%)', 'Read-before-edit ratio (25%)', 'Permission trust level (25%)'],
81
+ },
82
+ {
83
+ key: 'flow',
84
+ letter: 'F',
85
+ label: 'Flow',
86
+ color: 'var(--chart-4)',
87
+ icon: Activity,
88
+ description: 'How consistently you maintain a coding rhythm. Regular usage builds mastery faster than sporadic intense sessions.',
89
+ metrics: ['Current streak length (35%)', 'Daily consistency (35%)', 'Active days coverage (30%)'],
90
+ },
91
+ {
92
+ key: 'throughput',
93
+ letter: 'T',
94
+ label: 'Throughput',
95
+ color: 'var(--chart-6)',
96
+ icon: Gauge,
97
+ description: 'How much output you get for your investment. Efficient sessions produce more output per dollar with fewer errors, and parallel sessions multiply your throughput.',
98
+ metrics: ['Cost efficiency (25%)', 'Output volume (25%)', 'Parallel sessions (25%)', 'Low error rate (25%)'],
99
+ },
100
+ ]
101
+
53
102
  function FitnessRing({ score, label }: { score: number; label: string }) {
54
103
  const radius = 70
55
104
  const circumference = 2 * Math.PI * radius
56
105
  const offset = circumference - (score / 100) * circumference
57
106
 
107
+ const getColor = (s: number) => {
108
+ if (s >= 85) return 'var(--chart-2)'
109
+ if (s >= 70) return 'var(--chart-1)'
110
+ if (s >= 55) return 'var(--chart-3)'
111
+ return 'var(--chart-5)'
112
+ }
113
+
58
114
  return (
59
115
  <div className="flex flex-col items-center gap-2">
60
116
  <div className="relative">
61
117
  <svg width="180" height="180" viewBox="0 0 180 180">
118
+ <circle cx="90" cy="90" r={radius} fill="none" stroke="var(--muted)" strokeWidth="10" />
62
119
  <circle
63
120
  cx="90" cy="90" r={radius}
64
- fill="none"
65
- stroke="var(--muted)"
66
- strokeWidth="10"
67
- />
68
- <circle
69
- cx="90" cy="90" r={radius}
70
- fill="none"
71
- stroke="var(--chart-2)"
72
- strokeWidth="10"
73
- strokeLinecap="round"
74
- strokeDasharray={circumference}
75
- strokeDashoffset={offset}
121
+ fill="none" stroke={getColor(score)} strokeWidth="10"
122
+ strokeLinecap="round" strokeDasharray={circumference} strokeDashoffset={offset}
76
123
  transform="rotate(-90 90 90)"
77
- className="transition-all duration-1000"
78
124
  />
79
125
  </svg>
80
126
  <div className="absolute inset-0 flex flex-col items-center justify-center">
@@ -86,9 +132,19 @@ function FitnessRing({ score, label }: { score: number; label: string }) {
86
132
  )
87
133
  }
88
134
 
135
+ function DimensionBar({ value, color }: { value: number; color: string }) {
136
+ return (
137
+ <div className="flex-1 h-3 rounded-full bg-muted overflow-hidden">
138
+ <div
139
+ className="h-full rounded-full transition-all duration-500"
140
+ style={{ width: `${value}%`, backgroundColor: color }}
141
+ />
142
+ </div>
143
+ )
144
+ }
145
+
89
146
  function InsightCard({ insight }: { insight: CoachInsight }) {
90
147
  const style = SEVERITY_STYLES[insight.severity]
91
- const CategoryIcon = CATEGORY_ICONS[insight.category]
92
148
  const SeverityIcon = style.icon
93
149
 
94
150
  return (
@@ -98,17 +154,11 @@ function InsightCard({ insight }: { insight: CoachInsight }) {
98
154
  <div className="flex-1 space-y-1.5">
99
155
  <div className="flex items-center justify-between gap-2">
100
156
  <h4 className="text-sm font-semibold">{insight.title}</h4>
101
- <div className="flex items-center gap-1.5 shrink-0">
102
- {insight.metric && (
103
- <span className="rounded-md bg-muted px-2 py-0.5 text-xs font-mono font-medium">
104
- {insight.metric}
105
- </span>
106
- )}
107
- <span className="flex items-center gap-1 text-xs text-muted-foreground">
108
- <CategoryIcon className="h-3 w-3" />
109
- {CATEGORY_LABELS[insight.category]}
157
+ {insight.metric && (
158
+ <span className="rounded-md bg-muted px-2 py-0.5 text-xs font-mono font-medium shrink-0">
159
+ {insight.metric}
110
160
  </span>
111
- </div>
161
+ )}
112
162
  </div>
113
163
  <p className="text-sm text-muted-foreground">{insight.description}</p>
114
164
  {insight.recommendation && (
@@ -125,124 +175,201 @@ function InsightCard({ insight }: { insight: CoachInsight }) {
125
175
 
126
176
  export function AgentCoach({ data }: { data: UsageData }) {
127
177
  const coach = useMemo(() => generateCoachInsights(data), [data])
178
+ const [cmdInsights, setCmdInsights] = useState<CommandInsight[]>([])
128
179
 
129
- const achievements = coach.insights.filter(i => i.severity === 'achievement')
130
- const warnings = coach.insights.filter(i => i.severity === 'warning')
131
- const tips = coach.insights.filter(i => i.severity === 'tip')
180
+ useEffect(() => {
181
+ fetch('/api/command-insights')
182
+ .then(r => r.json())
183
+ .then(setCmdInsights)
184
+ .catch(() => {})
185
+ }, [])
132
186
 
133
- return (
134
- <div className="space-y-6">
135
- {/* Fitness Score + Quick Stats */}
136
- <div className="grid gap-4 lg:grid-cols-3">
137
- <Card className="lg:row-span-2">
138
- <CardHeader>
139
- <CardTitle>Agent Fitness Score</CardTitle>
140
- <CardDescription>Overall health of your human-AI collaboration</CardDescription>
141
- </CardHeader>
142
- <CardContent className="flex justify-center pb-6">
143
- <FitnessRing score={coach.score} label={coach.scoreLabel} />
144
- </CardContent>
145
- </Card>
187
+ const allInsights: CoachInsight[] = [
188
+ ...coach.insights,
189
+ ...cmdInsights.map(i => ({ ...i, category: 'discovery' as InsightCategory, craft: 'reach' as CraftDimension })),
190
+ ]
146
191
 
147
- <Card>
148
- <CardHeader className="flex flex-row items-center justify-between pb-2">
149
- <CardTitle className="text-sm font-medium text-muted-foreground">Avg Cost / Session</CardTitle>
150
- <DollarSign className="h-4 w-4 text-muted-foreground" />
151
- </CardHeader>
152
- <CardContent>
153
- <div className="text-2xl font-bold">{formatCost(coach.stats.avgCostPerSession)}</div>
154
- </CardContent>
155
- </Card>
192
+ // Group insights by CRAFT dimension
193
+ const insightsByDimension = (dim: CraftDimension) =>
194
+ allInsights.filter(i => i.craft === dim)
156
195
 
157
- <Card>
158
- <CardHeader className="flex flex-row items-center justify-between pb-2">
159
- <CardTitle className="text-sm font-medium text-muted-foreground">Avg Duration</CardTitle>
160
- <Clock className="h-4 w-4 text-muted-foreground" />
161
- </CardHeader>
162
- <CardContent>
163
- <div className="text-2xl font-bold">{formatDuration(coach.stats.avgDurationMinutes)}</div>
164
- </CardContent>
165
- </Card>
196
+ // Ungrouped insights (no craft tag)
197
+ const ungroupedWarnings = allInsights.filter(i => !i.craft && i.severity === 'warning')
198
+ const ungroupedTips = allInsights.filter(i => !i.craft && i.severity === 'tip')
199
+ const ungroupedAchievements = allInsights.filter(i => !i.craft && i.severity === 'achievement')
166
200
 
201
+ return (
202
+ <div className="space-y-6">
203
+ {/* Hero: CRAFT Score + Framework Overview */}
204
+ <div className="grid gap-4 lg:grid-cols-2">
167
205
  <Card>
168
- <CardHeader className="flex flex-row items-center justify-between pb-2">
169
- <CardTitle className="text-sm font-medium text-muted-foreground">Avg Messages</CardTitle>
170
- <MessageSquare className="h-4 w-4 text-muted-foreground" />
206
+ <CardHeader className="pb-2">
207
+ <CardTitle>CRAFT Score</CardTitle>
208
+ <CardDescription>
209
+ Your overall AI coding proficiency, measured across 5 dimensions
210
+ </CardDescription>
171
211
  </CardHeader>
172
- <CardContent>
173
- <div className="text-2xl font-bold">{formatNumber(Math.round(coach.stats.avgMessagesPerSession))}</div>
212
+ <CardContent className="flex items-center gap-6">
213
+ <FitnessRing score={coach.score} label={coach.scoreLabel} />
214
+ <div className="flex-1 space-y-3">
215
+ <div className="flex items-center gap-3 text-sm text-muted-foreground">
216
+ <span className="flex items-center gap-1">
217
+ <Flame className="h-3.5 w-3.5" />
218
+ {coach.stats.currentStreak > 0 ? `${coach.stats.currentStreak}d streak` : 'No streak'}
219
+ {coach.stats.longestStreak > 0 && (
220
+ <span className="text-xs"> (best: {coach.stats.longestStreak}d)</span>
221
+ )}
222
+ </span>
223
+ </div>
224
+ <div className="space-y-2">
225
+ {CRAFT_DIMENSIONS.map(({ key, letter, label, color }) => (
226
+ <div key={key} className="flex items-center gap-2">
227
+ <span className="w-5 text-xs font-bold" style={{ color }}>{letter}</span>
228
+ <span className="w-20 text-xs text-muted-foreground">{label}</span>
229
+ <DimensionBar value={coach.craft[key]} color={color} />
230
+ <span className="w-7 text-xs font-semibold text-right">{coach.craft[key]}</span>
231
+ </div>
232
+ ))}
233
+ </div>
234
+ <div className="text-[10px] text-muted-foreground">
235
+ Weights: A 25% · C/R/T 20% each · F 15%
236
+ </div>
237
+ </div>
174
238
  </CardContent>
175
239
  </Card>
176
240
 
241
+ {/* What is CRAFT */}
177
242
  <Card>
178
- <CardHeader className="flex flex-row items-center justify-between pb-2">
179
- <CardTitle className="text-sm font-medium text-muted-foreground">Coding Streak</CardTitle>
180
- <Flame className="h-4 w-4 text-muted-foreground" />
243
+ <CardHeader className="pb-2">
244
+ <CardTitle>What is CRAFT?</CardTitle>
245
+ <CardDescription>
246
+ A framework for measuring Human-AI coding proficiency
247
+ </CardDescription>
181
248
  </CardHeader>
182
- <CardContent>
183
- <div className="text-2xl font-bold">
184
- {coach.stats.currentStreak > 0 ? `${coach.stats.currentStreak}d` : '—'}
185
- <span className="text-sm font-normal text-muted-foreground ml-2">
186
- best: {coach.stats.longestStreak}d
187
- </span>
249
+ <CardContent className="text-sm space-y-3">
250
+ <p className="text-muted-foreground">
251
+ Every CRAFT metric is derived directly from your local conversation logs.
252
+ No external integrations or surveys needed.
253
+ </p>
254
+ <div className="grid grid-cols-5 gap-2 text-center">
255
+ {CRAFT_DIMENSIONS.map(({ letter, label, color, icon: Icon }) => (
256
+ <div key={letter} className="space-y-1">
257
+ <div className="flex justify-center">
258
+ <Icon className="h-5 w-5" style={{ color }} />
259
+ </div>
260
+ <div className="text-xs font-bold" style={{ color }}>{letter}</div>
261
+ <div className="text-[10px] text-muted-foreground">{label}</div>
262
+ </div>
263
+ ))}
188
264
  </div>
265
+ <p className="text-xs text-muted-foreground">
266
+ Each dimension is scored 0-100 based on your actual usage patterns. The overall score is a
267
+ weighted average prioritizing behavioral quality (Autonomy) over volume (Throughput).
268
+ </p>
189
269
  </CardContent>
190
270
  </Card>
191
271
  </div>
192
272
 
193
- {/* Achievements */}
194
- {achievements.length > 0 && (
195
- <Card>
196
- <CardHeader>
197
- <div className="flex items-center gap-2">
198
- <Trophy className="h-5 w-5 text-chart-2" />
199
- <div>
200
- <CardTitle>Achievements</CardTitle>
201
- <CardDescription>Things you're doing well</CardDescription>
273
+ {/* Per-dimension breakdown with insights */}
274
+ {CRAFT_DIMENSIONS.map(({ key, letter, label, color, icon: Icon, description, metrics }) => {
275
+ const dimInsights = insightsByDimension(key)
276
+ const dimScore = coach.craft[key]
277
+ return (
278
+ <Card key={key}>
279
+ <CardHeader>
280
+ <div className="flex items-center justify-between">
281
+ <div className="flex items-center gap-3">
282
+ <div className="flex h-10 w-10 items-center justify-center rounded-lg" style={{ backgroundColor: `color-mix(in srgb, ${color} 15%, transparent)` }}>
283
+ <Icon className="h-5 w-5" style={{ color }} />
284
+ </div>
285
+ <div>
286
+ <CardTitle className="flex items-center gap-2">
287
+ <span className="text-lg font-bold" style={{ color }}>{letter}</span>
288
+ {label}
289
+ </CardTitle>
290
+ <CardDescription>{description}</CardDescription>
291
+ </div>
292
+ </div>
293
+ <div className="text-right">
294
+ <div className="text-2xl font-bold" style={{ color }}>{dimScore}</div>
295
+ <div className="text-xs text-muted-foreground">/ 100</div>
296
+ </div>
297
+ </div>
298
+ </CardHeader>
299
+ <CardContent className="space-y-4">
300
+ {/* How this score is calculated */}
301
+ <div className="rounded-md bg-muted/50 p-3">
302
+ <div className="text-xs font-medium mb-2">How this score is calculated:</div>
303
+ <div className="grid gap-1">
304
+ {metrics.map((m) => (
305
+ <div key={m} className="text-xs text-muted-foreground flex items-center gap-2">
306
+ <div className="h-1.5 w-1.5 rounded-full" style={{ backgroundColor: color }} />
307
+ {m}
308
+ </div>
309
+ ))}
310
+ </div>
202
311
  </div>
203
- </div>
204
- </CardHeader>
205
- <CardContent className="space-y-3">
206
- {achievements.map(i => <InsightCard key={i.id} insight={i} />)}
207
- </CardContent>
208
- </Card>
209
- )}
210
312
 
211
- {/* Warnings */}
212
- {warnings.length > 0 && (
213
- <Card>
214
- <CardHeader>
215
- <div className="flex items-center gap-2">
216
- <AlertTriangle className="h-5 w-5 text-chart-5" />
217
- <div>
218
- <CardTitle>Areas to Improve</CardTitle>
219
- <CardDescription>Issues that may be costing you time or money</CardDescription>
313
+ {/* Score bar */}
314
+ <div className="flex items-center gap-3">
315
+ <DimensionBar value={dimScore} color={color} />
316
+ <span className="text-sm font-semibold w-10 text-right">{dimScore}/100</span>
220
317
  </div>
221
- </div>
222
- </CardHeader>
223
- <CardContent className="space-y-3">
224
- {warnings.map(i => <InsightCard key={i.id} insight={i} />)}
225
- </CardContent>
226
- </Card>
227
- )}
228
318
 
229
- {/* Tips */}
230
- {tips.length > 0 && (
319
+ {/* Insights for this dimension */}
320
+ {dimInsights.length > 0 ? (
321
+ <div className="space-y-3">
322
+ {dimInsights.map(i => <InsightCard key={i.id} insight={i} />)}
323
+ </div>
324
+ ) : (
325
+ <p className="text-sm text-muted-foreground italic">No specific insights for this dimension yet.</p>
326
+ )}
327
+ </CardContent>
328
+ </Card>
329
+ )
330
+ })}
331
+
332
+ {/* Ungrouped insights (if any) */}
333
+ {(ungroupedWarnings.length > 0 || ungroupedTips.length > 0 || ungroupedAchievements.length > 0) && (
231
334
  <Card>
232
335
  <CardHeader>
233
- <div className="flex items-center gap-2">
234
- <Lightbulb className="h-5 w-5 text-chart-1" />
235
- <div>
236
- <CardTitle>Tips & Suggestions</CardTitle>
237
- <CardDescription>Ways to get more out of your coding agent</CardDescription>
238
- </div>
239
- </div>
336
+ <CardTitle>Other Insights</CardTitle>
337
+ <CardDescription>General recommendations not tied to a specific CRAFT dimension</CardDescription>
240
338
  </CardHeader>
241
339
  <CardContent className="space-y-3">
242
- {tips.map(i => <InsightCard key={i.id} insight={i} />)}
340
+ {[...ungroupedWarnings, ...ungroupedTips, ...ungroupedAchievements].map(i => (
341
+ <InsightCard key={i.id} insight={i} />
342
+ ))}
243
343
  </CardContent>
244
344
  </Card>
245
345
  )}
346
+
347
+ {/* Session Averages */}
348
+ <Card>
349
+ <CardHeader>
350
+ <CardTitle className="text-sm">Session Averages</CardTitle>
351
+ </CardHeader>
352
+ <CardContent>
353
+ <div className="grid grid-cols-2 gap-4 sm:grid-cols-4 text-sm">
354
+ <div>
355
+ <div className="text-muted-foreground">Cost / Session</div>
356
+ <div className="font-semibold">{formatCost(coach.stats.avgCostPerSession)}</div>
357
+ </div>
358
+ <div>
359
+ <div className="text-muted-foreground">Duration</div>
360
+ <div className="font-semibold">{formatDuration(coach.stats.avgDurationMinutes)}</div>
361
+ </div>
362
+ <div>
363
+ <div className="text-muted-foreground">Messages</div>
364
+ <div className="font-semibold">{formatNumber(Math.round(coach.stats.avgMessagesPerSession))}</div>
365
+ </div>
366
+ <div>
367
+ <div className="text-muted-foreground">Peak Hour</div>
368
+ <div className="font-semibold">{coach.stats.peakHour}:00</div>
369
+ </div>
370
+ </div>
371
+ </CardContent>
372
+ </Card>
246
373
  </div>
247
374
  )
248
375
  }