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.
- package/.github/workflows/release.yml +111 -0
- package/README.md +41 -38
- package/app/(dashboard)/daily/page.tsx +1 -1
- package/app/(dashboard)/data-management/page.tsx +180 -0
- package/app/(dashboard)/flow/page.tsx +17 -0
- package/app/(dashboard)/layout.tsx +2 -0
- package/app/(dashboard)/page.tsx +24 -5
- package/app/(dashboard)/reports/[id]/page.tsx +72 -0
- package/app/(dashboard)/reports/page.tsx +132 -0
- package/app/(dashboard)/sessions/[id]/page.tsx +167 -0
- package/app/api/backup/route.ts +215 -0
- package/app/api/check/route.ts +11 -1
- package/app/api/command-insights/route.ts +13 -0
- package/app/api/commands/route.ts +55 -1
- package/app/api/images-analysis/route.ts +3 -4
- package/app/api/reports/[id]/route.ts +23 -0
- package/app/api/reports/route.ts +50 -0
- package/app/api/reset/route.ts +21 -0
- package/app/api/session/route.ts +40 -0
- package/app/api/usage/route.ts +26 -1
- package/app/layout.tsx +1 -1
- package/bin/agentfit.mjs +2 -2
- package/components/agent-coach.tsx +256 -129
- package/components/app-sidebar.tsx +45 -10
- package/components/backup-section.tsx +236 -0
- package/components/daily-chart.tsx +447 -83
- package/components/dashboard-shell.tsx +29 -31
- package/components/data-provider.tsx +88 -8
- package/components/fitness-score.tsx +95 -54
- package/components/overview-cards.tsx +148 -41
- package/components/report-view.tsx +307 -0
- package/components/screenshots-analysis.tsx +51 -46
- package/components/session-chatlog.tsx +124 -0
- package/components/session-timeline.tsx +184 -0
- package/components/session-workflow.tsx +183 -0
- package/components/sessions-table.tsx +9 -1
- package/components/tool-flow-graph.tsx +144 -0
- package/components/ui/carousel.tsx +242 -0
- package/components/ui/sidebar.tsx +1 -1
- package/components/ui/sonner.tsx +51 -0
- package/electron/entitlements.mac.plist +16 -0
- package/electron/init-db.mjs +37 -0
- package/electron/main.mjs +203 -0
- package/generated/prisma/browser.ts +5 -0
- package/generated/prisma/client.ts +5 -0
- package/generated/prisma/internal/class.ts +14 -4
- package/generated/prisma/internal/prismaNamespace.ts +97 -2
- package/generated/prisma/internal/prismaNamespaceBrowser.ts +21 -1
- package/generated/prisma/models/Report.ts +1219 -0
- package/generated/prisma/models/Session.ts +221 -1
- package/generated/prisma/models.ts +1 -0
- package/lib/coach.ts +571 -211
- package/lib/command-insights.ts +231 -0
- package/lib/db.ts +2 -2
- package/lib/parse-codex.ts +6 -0
- package/lib/parse-logs.ts +80 -1
- package/lib/queries-codex.ts +24 -0
- package/lib/queries.ts +45 -0
- package/lib/report.ts +156 -0
- package/lib/session-detail.ts +382 -0
- package/lib/sync.ts +87 -0
- package/lib/tool-flow.ts +71 -0
- package/next.config.mjs +6 -1
- package/package.json +17 -2
- package/plugins/cost-heatmap/component.tsx +72 -50
- package/prisma/migrations/20260401144555_add_system_prompt_edits/migration.sql +80 -0
- package/prisma/schema.prisma +18 -0
- package/prisma/schema.sql +81 -0
- package/.claude/settings.local.json +0 -26
- package/CONTRIBUTING.md +0 -209
- package/prisma/migrations/20260328152517_init/migration.sql +0 -41
- package/prisma/migrations/20260328153801_add_image_model/migration.sql +0 -18
- package/prisma.config.ts +0 -14
- package/setup.sh +0 -73
|
@@ -3,10 +3,6 @@
|
|
|
3
3
|
import { usePathname } from 'next/navigation'
|
|
4
4
|
import { RefreshCw } from 'lucide-react'
|
|
5
5
|
import { SidebarProvider, SidebarInset, SidebarTrigger } from '@/components/ui/sidebar'
|
|
6
|
-
import { Separator } from '@/components/ui/separator'
|
|
7
|
-
import { Button } from '@/components/ui/button'
|
|
8
|
-
import { Badge } from '@/components/ui/badge'
|
|
9
|
-
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
|
10
6
|
import {
|
|
11
7
|
Select,
|
|
12
8
|
SelectContent,
|
|
@@ -15,12 +11,12 @@ import {
|
|
|
15
11
|
SelectValue,
|
|
16
12
|
} from '@/components/ui/select'
|
|
17
13
|
import { AppSidebar } from './app-sidebar'
|
|
18
|
-
import { useData, type TimeRange } from './data-provider'
|
|
14
|
+
import { useData, type TimeRange, type AgentType } from './data-provider'
|
|
19
15
|
import { getPlugin } from '@/lib/plugins'
|
|
20
16
|
import '@/plugins' // ensure plugins are registered
|
|
21
17
|
import type { ReactNode } from 'react'
|
|
22
18
|
|
|
23
|
-
const VIEW_TITLES: Record<string,
|
|
19
|
+
const VIEW_TITLES: Record<string, ReactNode> = {
|
|
24
20
|
'/': 'Dashboard',
|
|
25
21
|
'/daily': 'Daily Usage',
|
|
26
22
|
'/tokens': 'Token Breakdown',
|
|
@@ -29,19 +25,23 @@ const VIEW_TITLES: Record<string, string> = {
|
|
|
29
25
|
'/sessions': 'Sessions',
|
|
30
26
|
'/personality': 'Personality Fit',
|
|
31
27
|
'/commands': 'Command Usage',
|
|
32
|
-
'/images': '
|
|
33
|
-
'/coach': '
|
|
28
|
+
'/images': 'Image Analysis',
|
|
29
|
+
'/coach': 'CRAFT Coach',
|
|
30
|
+
'/flow': 'Session Flow',
|
|
31
|
+
'/community': 'Community',
|
|
32
|
+
'/reports': 'Reports',
|
|
33
|
+
'/data-management': 'Data Management',
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
-
function resolveTitle(pathname: string):
|
|
36
|
+
function resolveTitle(pathname: string): ReactNode {
|
|
37
37
|
if (VIEW_TITLES[pathname]) return VIEW_TITLES[pathname]
|
|
38
|
-
if (pathname === '/community') return 'Community'
|
|
39
|
-
// Community plugin routes: /community/<slug>
|
|
40
38
|
const match = pathname.match(/^\/community\/([a-z0-9-]+)$/)
|
|
41
39
|
if (match) {
|
|
42
40
|
const plugin = getPlugin(match[1])
|
|
43
41
|
if (plugin) return plugin.manifest.name
|
|
44
42
|
}
|
|
43
|
+
if (pathname.startsWith('/reports/')) return 'Report Detail'
|
|
44
|
+
if (pathname.startsWith('/sessions/')) return 'Session Detail'
|
|
45
45
|
return 'Dashboard'
|
|
46
46
|
}
|
|
47
47
|
|
|
@@ -65,13 +65,13 @@ export function DashboardShell({ children }: { children: ReactNode }) {
|
|
|
65
65
|
const pathname = usePathname()
|
|
66
66
|
const {
|
|
67
67
|
agent, setAgent, timeRange, setTimeRange,
|
|
68
|
-
|
|
69
|
-
|
|
68
|
+
selectedProject, setSelectedProject, allProjects,
|
|
69
|
+
loading,
|
|
70
70
|
} = useData()
|
|
71
71
|
|
|
72
72
|
const title = resolveTitle(pathname)
|
|
73
|
-
const
|
|
74
|
-
const communityPlugin =
|
|
73
|
+
const communitySlug = pathname.match(/^\/community\/([a-z0-9-]+)$/)?.[1]
|
|
74
|
+
const communityPlugin = communitySlug ? getPlugin(communitySlug) : undefined
|
|
75
75
|
const showTimeFilter =
|
|
76
76
|
!NO_TIME_FILTER.has(pathname) && !communityPlugin?.manifest.customDataSource
|
|
77
77
|
|
|
@@ -113,7 +113,20 @@ export function DashboardShell({ children }: { children: ReactNode }) {
|
|
|
113
113
|
))}
|
|
114
114
|
</div>
|
|
115
115
|
)}
|
|
116
|
-
|
|
116
|
+
{allProjects.length > 1 && (
|
|
117
|
+
<Select value={selectedProject} onValueChange={(v) => v && setSelectedProject(v)}>
|
|
118
|
+
<SelectTrigger className="h-8 w-[160px] text-xs">
|
|
119
|
+
<SelectValue>{selectedProject === 'all' ? 'All Projects' : selectedProject}</SelectValue>
|
|
120
|
+
</SelectTrigger>
|
|
121
|
+
<SelectContent>
|
|
122
|
+
<SelectItem value="all">All Projects</SelectItem>
|
|
123
|
+
{allProjects.map((p) => (
|
|
124
|
+
<SelectItem key={p} value={p}>{p}</SelectItem>
|
|
125
|
+
))}
|
|
126
|
+
</SelectContent>
|
|
127
|
+
</Select>
|
|
128
|
+
)}
|
|
129
|
+
<Select value={agent} onValueChange={(v) => v && setAgent(v as AgentType)}>
|
|
117
130
|
<SelectTrigger className="h-8 w-[130px] text-xs">
|
|
118
131
|
<SelectValue>{AGENT_LABELS[agent]}</SelectValue>
|
|
119
132
|
</SelectTrigger>
|
|
@@ -123,21 +136,6 @@ export function DashboardShell({ children }: { children: ReactNode }) {
|
|
|
123
136
|
<SelectItem value="combined">Combined</SelectItem>
|
|
124
137
|
</SelectContent>
|
|
125
138
|
</Select>
|
|
126
|
-
{lastSyncTime && lastSyncResult && lastSyncResult.sessionsAdded > 0 && (
|
|
127
|
-
<Badge variant="secondary" className="hidden text-xs sm:inline-flex">
|
|
128
|
-
+{lastSyncResult.sessionsAdded} new
|
|
129
|
-
</Badge>
|
|
130
|
-
)}
|
|
131
|
-
<Button
|
|
132
|
-
variant="outline"
|
|
133
|
-
size="sm"
|
|
134
|
-
onClick={handleSync}
|
|
135
|
-
disabled={syncing}
|
|
136
|
-
className="gap-2"
|
|
137
|
-
>
|
|
138
|
-
<RefreshCw className={`h-3.5 w-3.5 ${syncing ? 'animate-spin' : ''}`} />
|
|
139
|
-
{syncing ? 'Syncing...' : 'Sync'}
|
|
140
|
-
</Button>
|
|
141
139
|
</div>
|
|
142
140
|
</header>
|
|
143
141
|
<main className="flex-1 space-y-6 p-6">
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
3
|
import { createContext, useContext, useEffect, useState, useCallback, useMemo, type ReactNode } from 'react'
|
|
4
|
+
import { toast } from 'sonner'
|
|
4
5
|
import type { UsageData, SessionSummary, DailyUsage, ProjectSummary, OverviewStats } from '@/lib/parse-logs'
|
|
5
6
|
|
|
6
7
|
export type AgentType = 'claude' | 'codex' | 'combined'
|
|
@@ -22,10 +23,15 @@ interface DataContextValue {
|
|
|
22
23
|
setAgent: (agent: AgentType) => void
|
|
23
24
|
timeRange: TimeRange
|
|
24
25
|
setTimeRange: (range: TimeRange) => void
|
|
26
|
+
selectedProject: string
|
|
27
|
+
setSelectedProject: (project: string) => void
|
|
28
|
+
allProjects: string[]
|
|
25
29
|
syncing: boolean
|
|
30
|
+
resetting: boolean
|
|
26
31
|
lastSyncResult: SyncResult | null
|
|
27
32
|
lastSyncTime: Date | null
|
|
28
33
|
handleSync: () => Promise<void>
|
|
34
|
+
handleReset: () => Promise<void>
|
|
29
35
|
newSessionsAvailable: number
|
|
30
36
|
}
|
|
31
37
|
|
|
@@ -44,16 +50,21 @@ const RANGE_DAYS: Record<TimeRange, number> = {
|
|
|
44
50
|
'all': Infinity,
|
|
45
51
|
}
|
|
46
52
|
|
|
47
|
-
function
|
|
48
|
-
if (!raw
|
|
53
|
+
function filterData(raw: UsageData | null, range: TimeRange, project: string): UsageData | null {
|
|
54
|
+
if (!raw) return raw
|
|
55
|
+
if (range === 'all' && project === 'all') return raw
|
|
49
56
|
|
|
50
57
|
const days = RANGE_DAYS[range]
|
|
51
58
|
const cutoff = new Date()
|
|
52
59
|
cutoff.setDate(cutoff.getDate() - days)
|
|
53
|
-
const cutoffISO = cutoff.toISOString()
|
|
60
|
+
const cutoffISO = range === 'all' ? '' : cutoff.toISOString()
|
|
54
61
|
|
|
55
|
-
// Filter sessions
|
|
56
|
-
const sessions = raw.sessions.filter(s =>
|
|
62
|
+
// Filter sessions by time range and project
|
|
63
|
+
const sessions = raw.sessions.filter(s => {
|
|
64
|
+
if (range !== 'all' && s.startTime < cutoffISO) return false
|
|
65
|
+
if (project !== 'all' && s.project !== project) return false
|
|
66
|
+
return true
|
|
67
|
+
})
|
|
57
68
|
|
|
58
69
|
// Re-aggregate from filtered sessions
|
|
59
70
|
const projectMap = new Map<string, ProjectSummary>()
|
|
@@ -90,14 +101,18 @@ function filterByTimeRange(raw: UsageData | null, range: TimeRange): UsageData |
|
|
|
90
101
|
if (!dailyMap.has(date)) {
|
|
91
102
|
dailyMap.set(date, {
|
|
92
103
|
date, sessions: 0, messages: 0,
|
|
104
|
+
userMessages: 0, assistantMessages: 0,
|
|
93
105
|
inputTokens: 0, outputTokens: 0,
|
|
94
106
|
cacheCreationTokens: 0, cacheReadTokens: 0,
|
|
95
107
|
totalTokens: 0, costUSD: 0, toolCalls: 0,
|
|
108
|
+
toolCallsDetail: {}, interruptions: 0, rateLimitErrors: 0,
|
|
96
109
|
})
|
|
97
110
|
}
|
|
98
111
|
const day = dailyMap.get(date)!
|
|
99
112
|
day.sessions++
|
|
100
113
|
day.messages += s.totalMessages
|
|
114
|
+
day.userMessages += s.userMessages
|
|
115
|
+
day.assistantMessages += s.assistantMessages
|
|
101
116
|
day.inputTokens += s.inputTokens
|
|
102
117
|
day.outputTokens += s.outputTokens
|
|
103
118
|
day.cacheCreationTokens += s.cacheCreationTokens
|
|
@@ -105,6 +120,11 @@ function filterByTimeRange(raw: UsageData | null, range: TimeRange): UsageData |
|
|
|
105
120
|
day.totalTokens += s.totalTokens
|
|
106
121
|
day.costUSD += s.costUSD
|
|
107
122
|
day.toolCalls += s.toolCallsTotal
|
|
123
|
+
day.interruptions += s.userInterruptions
|
|
124
|
+
day.rateLimitErrors += s.rateLimitErrors
|
|
125
|
+
for (const [tool, count] of Object.entries(s.toolCalls)) {
|
|
126
|
+
day.toolCallsDetail[tool] = (day.toolCallsDetail[tool] || 0) + count
|
|
127
|
+
}
|
|
108
128
|
|
|
109
129
|
// Tools
|
|
110
130
|
for (const [tool, count] of Object.entries(s.toolCalls)) {
|
|
@@ -130,9 +150,37 @@ function filterByTimeRange(raw: UsageData | null, range: TimeRange): UsageData |
|
|
|
130
150
|
totalCacheReadTokens: sessions.reduce((a, s) => a + s.cacheReadTokens, 0),
|
|
131
151
|
totalTokens: sessions.reduce((a, s) => a + s.totalTokens, 0),
|
|
132
152
|
totalCostUSD: sessions.reduce((a, s) => a + s.costUSD, 0),
|
|
153
|
+
totalSystemPromptEdits: sessions.reduce((a, s) => a + (s.systemPromptEdits ?? 0), 0),
|
|
133
154
|
totalDurationMinutes: sessions.reduce((a, s) => a + s.durationMinutes, 0),
|
|
134
155
|
totalToolCalls: sessions.reduce((a, s) => a + s.toolCallsTotal, 0),
|
|
156
|
+
totalApiErrors: sessions.reduce((a, s) => a + s.apiErrors, 0),
|
|
157
|
+
totalRateLimitDays: (() => {
|
|
158
|
+
const days = new Set<string>()
|
|
159
|
+
for (const s of sessions) {
|
|
160
|
+
if (s.rateLimitErrors > 0) days.add(s.startTime.slice(0, 10))
|
|
161
|
+
}
|
|
162
|
+
return days.size
|
|
163
|
+
})(),
|
|
164
|
+
totalUserInterruptions: sessions.reduce((a, s) => a + s.userInterruptions, 0),
|
|
135
165
|
models,
|
|
166
|
+
skillUsage: (() => {
|
|
167
|
+
const skills: Record<string, number> = {}
|
|
168
|
+
for (const s of sessions) {
|
|
169
|
+
for (const [skill, count] of Object.entries(s.skillCalls)) {
|
|
170
|
+
skills[skill] = (skills[skill] || 0) + count
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return skills
|
|
174
|
+
})(),
|
|
175
|
+
permissionModes: (() => {
|
|
176
|
+
const modes: Record<string, number> = {}
|
|
177
|
+
for (const s of sessions) {
|
|
178
|
+
for (const [mode, count] of Object.entries(s.permissionModes || {})) {
|
|
179
|
+
modes[mode] = (modes[mode] || 0) + count
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return modes
|
|
183
|
+
})(),
|
|
136
184
|
}
|
|
137
185
|
|
|
138
186
|
return { overview, sessions, projects, daily, toolUsage }
|
|
@@ -144,12 +192,19 @@ export function DataProvider({ children }: { children: ReactNode }) {
|
|
|
144
192
|
const [error, setError] = useState<string | null>(null)
|
|
145
193
|
const [agent, setAgentState] = useState<AgentType>('claude')
|
|
146
194
|
const [timeRange, setTimeRange] = useState<TimeRange>('all')
|
|
195
|
+
const [selectedProject, setSelectedProject] = useState<string>('all')
|
|
147
196
|
const [syncing, setSyncing] = useState(false)
|
|
197
|
+
const [resetting, setResetting] = useState(false)
|
|
148
198
|
const [lastSyncResult, setLastSyncResult] = useState<SyncResult | null>(null)
|
|
149
199
|
const [lastSyncTime, setLastSyncTime] = useState<Date | null>(null)
|
|
150
200
|
const [newSessionsAvailable, setNewSessionsAvailable] = useState(0)
|
|
151
201
|
|
|
152
|
-
const data = useMemo(() =>
|
|
202
|
+
const data = useMemo(() => filterData(rawData, timeRange, selectedProject), [rawData, timeRange, selectedProject])
|
|
203
|
+
|
|
204
|
+
const allProjects = useMemo(() => {
|
|
205
|
+
if (!rawData) return []
|
|
206
|
+
return rawData.projects.map(p => p.name).sort((a, b) => a.localeCompare(b))
|
|
207
|
+
}, [rawData])
|
|
153
208
|
|
|
154
209
|
const fetchData = useCallback(async (selectedAgent: AgentType) => {
|
|
155
210
|
try {
|
|
@@ -183,7 +238,31 @@ export function DataProvider({ children }: { children: ReactNode }) {
|
|
|
183
238
|
}
|
|
184
239
|
}, [fetchData, agent])
|
|
185
240
|
|
|
186
|
-
|
|
241
|
+
const handleReset = useCallback(async () => {
|
|
242
|
+
setResetting(true)
|
|
243
|
+
try {
|
|
244
|
+
const res = await fetch('/api/reset', { method: 'POST' })
|
|
245
|
+
const body = await res.json()
|
|
246
|
+
if (!res.ok) {
|
|
247
|
+
throw new Error(body.error || 'Reset failed')
|
|
248
|
+
}
|
|
249
|
+
setLastSyncResult(body)
|
|
250
|
+
setLastSyncTime(new Date())
|
|
251
|
+
setNewSessionsAvailable(0)
|
|
252
|
+
toast.success('Database reset complete', {
|
|
253
|
+
description: `Re-imported ${body.sessionsAdded || 0} sessions from disk.`,
|
|
254
|
+
})
|
|
255
|
+
// Full reload to dashboard so all state is fresh
|
|
256
|
+
setTimeout(() => { window.location.href = '/' }, 1500)
|
|
257
|
+
return
|
|
258
|
+
} catch (e) {
|
|
259
|
+
const msg = (e as Error).message
|
|
260
|
+
setError(msg)
|
|
261
|
+
toast.error('Reset failed', { description: msg })
|
|
262
|
+
} finally {
|
|
263
|
+
setResetting(false)
|
|
264
|
+
}
|
|
265
|
+
}, [fetchData, agent])
|
|
187
266
|
|
|
188
267
|
const setAgent = useCallback((newAgent: AgentType) => {
|
|
189
268
|
setAgentState(newAgent)
|
|
@@ -204,7 +283,8 @@ export function DataProvider({ children }: { children: ReactNode }) {
|
|
|
204
283
|
<DataContext.Provider value={{
|
|
205
284
|
data, loading, error, agent, setAgent,
|
|
206
285
|
timeRange, setTimeRange,
|
|
207
|
-
|
|
286
|
+
selectedProject, setSelectedProject, allProjects,
|
|
287
|
+
syncing, resetting, lastSyncResult, lastSyncTime, handleSync, handleReset,
|
|
208
288
|
newSessionsAvailable,
|
|
209
289
|
}}>
|
|
210
290
|
{children}
|
|
@@ -2,14 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
import { useMemo } from 'react'
|
|
4
4
|
import Link from 'next/link'
|
|
5
|
-
import { Card, CardContent } from '@/components/ui/card'
|
|
5
|
+
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
|
6
6
|
import type { UsageData } from '@/lib/parse-logs'
|
|
7
|
-
import { generateCoachInsights } from '@/lib/coach'
|
|
8
|
-
import { ArrowRight,
|
|
7
|
+
import { generateCoachInsights, type CraftScores } from '@/lib/coach'
|
|
8
|
+
import { ArrowRight, AlertTriangle, Lightbulb, Flame } from 'lucide-react'
|
|
9
9
|
|
|
10
|
-
function ScoreRing({ score, size =
|
|
11
|
-
const radius = (size -
|
|
10
|
+
function ScoreRing({ score, size = 100 }: { score: number; size?: number }) {
|
|
11
|
+
const radius = (size - 14) / 2
|
|
12
12
|
const circumference = 2 * Math.PI * radius
|
|
13
|
+
const offset = circumference - (score / 100) * circumference
|
|
13
14
|
|
|
14
15
|
const getColor = (s: number) => {
|
|
15
16
|
if (s >= 85) return 'var(--chart-2)'
|
|
@@ -22,74 +23,114 @@ function ScoreRing({ score, size = 120 }: { score: number; size?: number }) {
|
|
|
22
23
|
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`}>
|
|
23
24
|
<circle
|
|
24
25
|
cx={size / 2} cy={size / 2} r={radius}
|
|
25
|
-
fill="none"
|
|
26
|
-
stroke="var(--muted)"
|
|
27
|
-
strokeWidth="8"
|
|
26
|
+
fill="none" stroke="var(--muted)" strokeWidth="7"
|
|
28
27
|
/>
|
|
29
28
|
<circle
|
|
30
29
|
cx={size / 2} cy={size / 2} r={radius}
|
|
31
|
-
fill="none"
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
strokeLinecap="round"
|
|
35
|
-
strokeDasharray={circumference}
|
|
36
|
-
strokeDashoffset={circumference - (score / 100) * circumference}
|
|
30
|
+
fill="none" stroke={getColor(score)} strokeWidth="7"
|
|
31
|
+
strokeLinecap="round" strokeDasharray={circumference}
|
|
32
|
+
strokeDashoffset={offset}
|
|
37
33
|
transform={`rotate(-90 ${size / 2} ${size / 2})`}
|
|
38
|
-
className="transition-all duration-1000"
|
|
39
34
|
/>
|
|
40
|
-
<text x={size / 2} y={size / 2 -
|
|
35
|
+
<text x={size / 2} y={size / 2 - 2} textAnchor="middle" dominantBaseline="middle" className="fill-foreground font-bold" style={{ fontSize: size * 0.24 }}>
|
|
41
36
|
{score}
|
|
42
37
|
</text>
|
|
43
|
-
<text x={size / 2} y={size / 2 +
|
|
38
|
+
<text x={size / 2} y={size / 2 + 14} textAnchor="middle" className="fill-muted-foreground" style={{ fontSize: size * 0.1 }}>
|
|
44
39
|
/ 100
|
|
45
40
|
</text>
|
|
46
41
|
</svg>
|
|
47
42
|
)
|
|
48
43
|
}
|
|
49
44
|
|
|
45
|
+
const CRAFT_LABELS: { key: keyof CraftScores; letter: string; label: string; color: string }[] = [
|
|
46
|
+
{ key: 'context', letter: 'C', label: 'Context', color: 'var(--chart-1)' },
|
|
47
|
+
{ key: 'reach', letter: 'R', label: 'Reach', color: 'var(--chart-2)' },
|
|
48
|
+
{ key: 'autonomy', letter: 'A', label: 'Autonomy', color: 'var(--chart-3)' },
|
|
49
|
+
{ key: 'flow', letter: 'F', label: 'Flow', color: 'var(--chart-4)' },
|
|
50
|
+
{ key: 'throughput', letter: 'T', label: 'Throughput', color: 'var(--chart-6)' },
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
function CraftBars({ craft }: { craft: CraftScores }) {
|
|
54
|
+
return (
|
|
55
|
+
<div className="space-y-1.5">
|
|
56
|
+
{CRAFT_LABELS.map(({ key, letter, label, color }) => (
|
|
57
|
+
<div key={key} className="flex items-center gap-2">
|
|
58
|
+
<span className="w-4 text-[10px] font-bold" style={{ color }}>{letter}</span>
|
|
59
|
+
<span className="w-16 text-[10px] text-muted-foreground truncate">{label}</span>
|
|
60
|
+
<div className="flex-1 h-2 rounded-full bg-muted overflow-hidden">
|
|
61
|
+
<div
|
|
62
|
+
className="h-full rounded-full transition-all duration-500"
|
|
63
|
+
style={{ width: `${craft[key]}%`, backgroundColor: color }}
|
|
64
|
+
/>
|
|
65
|
+
</div>
|
|
66
|
+
<span className="w-6 text-[10px] font-medium text-right">{craft[key]}</span>
|
|
67
|
+
</div>
|
|
68
|
+
))}
|
|
69
|
+
</div>
|
|
70
|
+
)
|
|
71
|
+
}
|
|
72
|
+
|
|
50
73
|
export function FitnessScore({ data }: { data: UsageData }) {
|
|
51
74
|
const coach = useMemo(() => generateCoachInsights(data), [data])
|
|
52
75
|
|
|
53
|
-
|
|
54
|
-
const
|
|
55
|
-
|
|
76
|
+
// Pick top 2 insights: warnings first, then tips
|
|
77
|
+
const topInsights = [
|
|
78
|
+
...coach.insights.filter(i => i.severity === 'warning'),
|
|
79
|
+
...coach.insights.filter(i => i.severity === 'tip'),
|
|
80
|
+
].slice(0, 2)
|
|
56
81
|
|
|
57
82
|
return (
|
|
58
|
-
<
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
<
|
|
62
|
-
<
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
83
|
+
<div className="grid gap-4 lg:grid-cols-3">
|
|
84
|
+
{/* CRAFT Status — score ring + dimension bars in one card */}
|
|
85
|
+
<Card>
|
|
86
|
+
<CardHeader className="pb-2">
|
|
87
|
+
<CardTitle className="text-sm font-medium text-muted-foreground">CRAFT Status</CardTitle>
|
|
88
|
+
</CardHeader>
|
|
89
|
+
<CardContent className="flex items-center gap-6">
|
|
90
|
+
<div className="flex flex-col items-center gap-1">
|
|
91
|
+
<ScoreRing score={coach.score} />
|
|
92
|
+
<div className="text-xs font-semibold">{coach.scoreLabel}</div>
|
|
93
|
+
<div className="flex items-center gap-1 text-[10px] text-muted-foreground">
|
|
94
|
+
<Flame className="h-3 w-3" />
|
|
95
|
+
{coach.stats.currentStreak > 0
|
|
96
|
+
? `${coach.stats.currentStreak}d streak (best: ${coach.stats.longestStreak}d)`
|
|
97
|
+
: 'Start a streak'}
|
|
98
|
+
</div>
|
|
99
|
+
<Link
|
|
100
|
+
href="/coach"
|
|
101
|
+
className="inline-flex items-center gap-1 text-[10px] font-medium text-primary hover:underline mt-1"
|
|
102
|
+
>
|
|
103
|
+
All insights <ArrowRight className="h-3 w-3" />
|
|
104
|
+
</Link>
|
|
67
105
|
</div>
|
|
68
|
-
<div className="flex
|
|
69
|
-
{
|
|
70
|
-
<span className="flex items-center gap-1 text-chart-2">
|
|
71
|
-
<Trophy className="h-3.5 w-3.5" /> {achievements} achievement{achievements !== 1 && 's'}
|
|
72
|
-
</span>
|
|
73
|
-
)}
|
|
74
|
-
{warnings > 0 && (
|
|
75
|
-
<span className="flex items-center gap-1 text-chart-5">
|
|
76
|
-
<AlertTriangle className="h-3.5 w-3.5" /> {warnings} warning{warnings !== 1 && 's'}
|
|
77
|
-
</span>
|
|
78
|
-
)}
|
|
79
|
-
{tips > 0 && (
|
|
80
|
-
<span className="flex items-center gap-1 text-chart-1">
|
|
81
|
-
<Lightbulb className="h-3.5 w-3.5" /> {tips} tip{tips !== 1 && 's'}
|
|
82
|
-
</span>
|
|
83
|
-
)}
|
|
106
|
+
<div className="flex-1">
|
|
107
|
+
<CraftBars craft={coach.craft} />
|
|
84
108
|
</div>
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
109
|
+
</CardContent>
|
|
110
|
+
</Card>
|
|
111
|
+
|
|
112
|
+
{/* Top 2 Insights */}
|
|
113
|
+
{topInsights.map((insight, i) => (
|
|
114
|
+
<Card
|
|
115
|
+
key={insight.id}
|
|
116
|
+
className={`border-l-4 ${insight.severity === 'warning' ? 'border-l-chart-5' : 'border-l-chart-1'}`}
|
|
117
|
+
>
|
|
118
|
+
<CardHeader className="pb-1.5">
|
|
119
|
+
<CardTitle className="text-[11px] font-medium flex items-center gap-1.5 text-muted-foreground">
|
|
120
|
+
{insight.severity === 'warning'
|
|
121
|
+
? <><AlertTriangle className="h-3 w-3 text-chart-5" /> {i === 0 ? 'Top Priority' : 'Improve'}</>
|
|
122
|
+
: <><Lightbulb className="h-3 w-3 text-chart-1" /> Tip</>}
|
|
123
|
+
{insight.craft && (
|
|
124
|
+
<span className="ml-auto text-[9px] uppercase tracking-wider opacity-60">{insight.craft}</span>
|
|
125
|
+
)}
|
|
126
|
+
</CardTitle>
|
|
127
|
+
</CardHeader>
|
|
128
|
+
<CardContent>
|
|
129
|
+
<div className="text-sm font-semibold mb-1 line-clamp-1">{insight.title}</div>
|
|
130
|
+
<p className="text-xs text-muted-foreground line-clamp-2">{insight.recommendation || insight.description}</p>
|
|
131
|
+
</CardContent>
|
|
132
|
+
</Card>
|
|
133
|
+
))}
|
|
134
|
+
</div>
|
|
94
135
|
)
|
|
95
136
|
}
|