prjct-cli 0.11.3 → 0.11.5

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 (34) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/bin/prjct +4 -0
  3. package/bin/serve.js +22 -6
  4. package/core/__tests__/utils/date-helper.test.js +416 -0
  5. package/core/agentic/agent-router.js +30 -18
  6. package/core/agentic/command-executor.js +20 -24
  7. package/core/agentic/context-builder.js +7 -8
  8. package/core/agentic/memory-system.js +14 -19
  9. package/core/agentic/prompt-builder.js +41 -27
  10. package/core/agentic/template-loader.js +8 -2
  11. package/core/infrastructure/agent-detector.js +7 -4
  12. package/core/infrastructure/migrator.js +10 -13
  13. package/core/infrastructure/session-manager.js +10 -10
  14. package/package.json +1 -1
  15. package/packages/web/app/project/[id]/stats/page.tsx +102 -343
  16. package/packages/web/components/stats/ActivityTimeline.tsx +201 -0
  17. package/packages/web/components/stats/AgentsCard.tsx +56 -0
  18. package/packages/web/components/stats/BentoCard.tsx +88 -0
  19. package/packages/web/components/stats/BentoGrid.tsx +22 -0
  20. package/packages/web/components/stats/EmptyState.tsx +67 -0
  21. package/packages/web/components/stats/HeroSection.tsx +172 -0
  22. package/packages/web/components/stats/IdeasCard.tsx +59 -0
  23. package/packages/web/components/stats/NowCard.tsx +71 -0
  24. package/packages/web/components/stats/ProgressRing.tsx +74 -0
  25. package/packages/web/components/stats/QueueCard.tsx +58 -0
  26. package/packages/web/components/stats/RoadmapCard.tsx +97 -0
  27. package/packages/web/components/stats/ShipsCard.tsx +70 -0
  28. package/packages/web/components/stats/SparklineChart.tsx +44 -0
  29. package/packages/web/components/stats/StreakCard.tsx +59 -0
  30. package/packages/web/components/stats/VelocityCard.tsx +60 -0
  31. package/packages/web/components/stats/index.ts +17 -0
  32. package/packages/web/components/ui/tooltip.tsx +2 -2
  33. package/packages/web/next-env.d.ts +1 -1
  34. package/packages/web/package.json +2 -1
@@ -1,32 +1,29 @@
1
1
  'use client'
2
2
 
3
- import { use, useMemo, useState } from 'react'
3
+ import { use, useMemo } from 'react'
4
4
  import { useRouter } from 'next/navigation'
5
- import Link from 'next/link'
6
5
  import { useProject } from '@/hooks/useProjects'
7
6
  import { useProjectStats } from '@/hooks/useProjectStats'
8
7
  import { Button } from '@/components/ui/button'
9
- import {
10
- ArrowLeft,
11
- Zap,
12
- TrendingUp,
13
- TrendingDown,
14
- Lightbulb,
15
- AlertTriangle,
16
- CheckCircle2,
17
- Bot,
18
- Rocket,
19
- Flame,
20
- Play,
21
- Copy,
22
- FileText,
23
- Target,
24
- Clock
25
- } from 'lucide-react'
26
- import { cn } from '@/lib/utils'
8
+ import { ArrowLeft } from 'lucide-react'
27
9
  import type { TimelineEvent } from '@/lib/parse-prjct-files'
28
10
 
29
- // Calculate streak
11
+ import {
12
+ BentoGrid,
13
+ BentoCardSkeleton,
14
+ HeroSection,
15
+ NowCard,
16
+ VelocityCard,
17
+ StreakCard,
18
+ QueueCard,
19
+ ShipsCard,
20
+ IdeasCard,
21
+ AgentsCard,
22
+ RoadmapCard,
23
+ ActivityTimeline,
24
+ } from '@/components/stats'
25
+
26
+ // Calculate streak from timeline
30
27
  function calculateStreak(timeline: TimelineEvent[]): number {
31
28
  if (!timeline.length) return 0
32
29
  const dates = new Set(timeline.map(e => e.ts?.split('T')[0]).filter(Boolean))
@@ -51,36 +48,14 @@ function getHealthScore(stats: any): number {
51
48
  const recentActivity = stats?.timeline?.slice(0, 7).length || 0
52
49
 
53
50
  let score = 0
54
- score += Math.min(30, velocity * 15) // Up to 30 for velocity
55
- score += hasCurrentTask ? 20 : 0 // 20 for active work
56
- score += queueSize > 0 && queueSize < 15 ? 20 : queueSize === 0 ? 5 : 10 // Queue health
57
- score += Math.min(30, recentActivity * 5) // Recent activity
51
+ score += Math.min(30, velocity * 15)
52
+ score += hasCurrentTask ? 20 : 0
53
+ score += queueSize > 0 && queueSize < 15 ? 20 : queueSize === 0 ? 5 : 10
54
+ score += Math.min(30, recentActivity * 5)
58
55
 
59
56
  return Math.min(100, Math.round(score))
60
57
  }
61
58
 
62
- // Copy to clipboard
63
- function copyCommand(cmd: string) {
64
- navigator.clipboard.writeText(cmd)
65
- }
66
-
67
- // Format relative time
68
- function formatRelativeTime(dateString: string): string {
69
- const date = new Date(dateString)
70
- const now = new Date()
71
- const diffMs = now.getTime() - date.getTime()
72
- const diffMins = Math.floor(diffMs / 60000)
73
- const diffHours = Math.floor(diffMs / 3600000)
74
- const diffDays = Math.floor(diffMs / 86400000)
75
-
76
- if (diffMins < 1) return 'NOW'
77
- if (diffMins < 60) return `${diffMins}M`
78
- if (diffHours < 24) return `${diffHours}H`
79
- if (diffDays === 1) return '1D'
80
- if (diffDays < 7) return `${diffDays}D`
81
- return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }).toUpperCase()
82
- }
83
-
84
59
  // Contextual insight message
85
60
  function getInsightMessage(stats: any, streak: number): string {
86
61
  if (!stats) return ''
@@ -99,72 +74,52 @@ function getInsightMessage(stats: any, streak: number): string {
99
74
  return 'Steady progress. Pick the next task.'
100
75
  }
101
76
 
102
- // Section label component
103
- function SectionLabel({ children, className }: { children: React.ReactNode; className?: string }) {
104
- return (
105
- <p className={cn("text-[9px] font-bold uppercase tracking-[0.2em] text-foreground/40", className)}>
106
- {children}
107
- </p>
108
- )
77
+ // Calculate velocity change (simulated)
78
+ function getVelocityChange(velocity: number): number {
79
+ return velocity > 2 ? 15 : velocity > 1 ? 5 : velocity > 0 ? 0 : -10
109
80
  }
110
81
 
111
- // Health ring component
112
- function HealthRing({ score, size = 'md' }: { score: number; size?: 'sm' | 'md' | 'lg' }) {
113
- const sizes = {
114
- sm: { container: 'h-8 w-8', text: 'text-[10px]' },
115
- md: { container: 'h-12 w-12', text: 'text-xs' },
116
- lg: { container: 'h-16 w-16', text: 'text-sm' },
117
- }
118
- const { container, text } = sizes[size]
82
+ // Get weekly velocity data from timeline
83
+ function getWeeklyVelocityData(timeline: TimelineEvent[]): number[] {
84
+ const today = new Date()
85
+ const counts: number[] = []
119
86
 
120
- return (
121
- <div className={cn('relative', container)}>
122
- <svg className="h-full w-full -rotate-90" viewBox="0 0 36 36">
123
- <circle cx="18" cy="18" r="15" fill="none" stroke="currentColor" strokeWidth="3" className="text-foreground/10" />
124
- <circle
125
- cx="18" cy="18" r="15" fill="none" stroke="currentColor" strokeWidth="3"
126
- strokeDasharray={`${score} 100`}
127
- strokeLinecap="round"
128
- className="text-foreground transition-all duration-700"
129
- />
130
- </svg>
131
- <span className={cn('absolute inset-0 flex items-center justify-center font-bold', text)}>
132
- {score}
133
- </span>
134
- </div>
135
- )
136
- }
87
+ for (let i = 6; i >= 0; i--) {
88
+ const date = new Date(today)
89
+ date.setDate(date.getDate() - i)
90
+ const dateStr = date.toISOString().split('T')[0]
91
+
92
+ const count = timeline.filter(e => {
93
+ if (!e.ts) return false
94
+ return e.ts.startsWith(dateStr) && e.type === 'task_complete'
95
+ }).length
137
96
 
138
- // Stat card component
139
- function StatCard({ value, label, suffix, size = 'md' }: {
140
- value: string | number
141
- label: string
142
- suffix?: string
143
- size?: 'sm' | 'md' | 'lg'
144
- }) {
145
- const sizeClasses = {
146
- sm: { value: 'text-lg font-bold', label: 'text-[9px] font-bold uppercase tracking-[0.2em] text-foreground/40' },
147
- md: { value: 'text-2xl font-bold', label: 'text-xs text-muted-foreground' },
148
- lg: { value: 'text-3xl font-black tracking-tight tabular-nums', label: 'text-[9px] font-bold uppercase tracking-[0.2em] text-foreground/40 mt-1' },
97
+ counts.push(count)
149
98
  }
150
- const styles = sizeClasses[size]
151
99
 
152
- return (
153
- <div>
154
- <p className={cn(styles.value, 'text-foreground')}>
155
- {value}
156
- {suffix && <span className="text-foreground/50 font-normal text-sm ml-1">{suffix}</span>}
157
- </p>
158
- <p className={styles.label}>{label}</p>
159
- </div>
160
- )
100
+ return counts
161
101
  }
162
102
 
163
- // Stats row component - minimal divider
164
- function StatsRow({ children, className }: { children: React.ReactNode; className?: string }) {
103
+ function LoadingSkeleton() {
165
104
  return (
166
- <div className={cn('flex flex-wrap gap-x-10 gap-y-4', className)}>
167
- {children}
105
+ <div className="p-8 space-y-8">
106
+ <div className="flex items-start gap-6">
107
+ <div className="h-20 w-20 rounded-full bg-muted animate-pulse" />
108
+ <div className="space-y-3">
109
+ <div className="h-16 w-32 bg-muted rounded animate-pulse" />
110
+ <div className="h-4 w-48 bg-muted rounded animate-pulse" />
111
+ </div>
112
+ </div>
113
+ <BentoGrid>
114
+ <BentoCardSkeleton size="2x2" />
115
+ <BentoCardSkeleton size="1x1" />
116
+ <BentoCardSkeleton size="2x2" />
117
+ <BentoCardSkeleton size="1x1" />
118
+ <BentoCardSkeleton size="1x2" />
119
+ <BentoCardSkeleton size="1x2" />
120
+ <BentoCardSkeleton size="1x1" />
121
+ <BentoCardSkeleton size="1x1" />
122
+ </BentoGrid>
168
123
  </div>
169
124
  )
170
125
  }
@@ -172,7 +127,6 @@ function StatsRow({ children, className }: { children: React.ReactNode; classNam
172
127
  export default function ProjectStatsPage({ params }: { params: Promise<{ id: string }> }) {
173
128
  const { id: projectId } = use(params)
174
129
  const router = useRouter()
175
- const [expandedSection, setExpandedSection] = useState<string | null>(null)
176
130
 
177
131
  const { data: project, isLoading: projectLoading } = useProject(projectId)
178
132
  const { data, isLoading: statsLoading } = useProjectStats(projectId)
@@ -181,27 +135,12 @@ export default function ProjectStatsPage({ params }: { params: Promise<{ id: str
181
135
  const streak = useMemo(() => calculateStreak(stats?.timeline || []), [stats?.timeline])
182
136
  const healthScore = useMemo(() => getHealthScore(stats), [stats])
183
137
  const insightMessage = useMemo(() => getInsightMessage(stats, streak), [stats, streak])
184
-
185
- const completionRate = useMemo(() => {
186
- if (!stats?.metrics) return 0
187
- const { tasksStarted, tasksCompleted } = stats.metrics
188
- return tasksStarted > 0 ? Math.round((tasksCompleted / tasksStarted) * 100) : 0
189
- }, [stats?.metrics])
190
-
191
- const velocityChange = useMemo(() => {
192
- // Simulated - in reality would compare to previous period
193
- const velocity = stats?.metrics?.velocity?.tasksPerDay || 0
194
- return velocity > 2 ? 15 : velocity > 1 ? 5 : -10
195
- }, [stats?.metrics?.velocity?.tasksPerDay])
196
-
197
- // Recent activity - last 5 for chips
198
- const recentActivity = useMemo(() => {
199
- if (!stats?.timeline) return []
200
- return stats.timeline.slice(0, 5)
201
- }, [stats?.timeline])
138
+ const velocity = stats?.metrics?.velocity?.tasksPerDay || 0
139
+ const velocityChange = useMemo(() => getVelocityChange(velocity), [velocity])
140
+ const weeklyVelocityData = useMemo(() => getWeeklyVelocityData(stats?.timeline || []), [stats?.timeline])
202
141
 
203
142
  if (projectLoading || statsLoading) {
204
- return <div className="flex items-center justify-center h-full"><div className="animate-pulse text-muted-foreground">Loading...</div></div>
143
+ return <LoadingSkeleton />
205
144
  }
206
145
 
207
146
  if (!project || !stats) {
@@ -209,7 +148,10 @@ export default function ProjectStatsPage({ params }: { params: Promise<{ id: str
209
148
  <div className="flex items-center justify-center h-full">
210
149
  <div className="text-center space-y-4">
211
150
  <p className="text-4xl text-muted-foreground">404</p>
212
- <Button variant="ghost" onClick={() => router.back()}><ArrowLeft className="w-4 h-4 mr-2" />Back</Button>
151
+ <Button variant="ghost" onClick={() => router.back()}>
152
+ <ArrowLeft className="w-4 h-4 mr-2" />
153
+ Back
154
+ </Button>
213
155
  </div>
214
156
  </div>
215
157
  )
@@ -217,228 +159,45 @@ export default function ProjectStatsPage({ params }: { params: Promise<{ id: str
217
159
 
218
160
  return (
219
161
  <div className="flex h-full flex-col p-8 overflow-auto">
220
- {/* Hero Section - Big number + insight */}
221
- <div className="flex items-start justify-between mb-2">
222
- <div>
223
- {/* Big Number - Tasks Completed */}
224
- <h1 className="text-8xl font-bold tracking-tighter text-foreground tabular-nums">
225
- {stats.metrics.tasksCompleted}
226
- </h1>
227
- <p className="text-lg text-muted-foreground mt-2">{insightMessage}</p>
228
- </div>
229
-
230
- {/* Navigation back */}
231
- <Link href={`/project/${projectId}`} className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground">
232
- <ArrowLeft className="w-4 h-4" />{project.name}
233
- </Link>
234
- </div>
235
-
236
- {/* Trend indicator */}
237
- {velocityChange !== 0 && (
238
- <div className="flex items-center gap-2 mt-4">
239
- <span className={cn(
240
- 'flex items-center gap-1 text-sm font-medium px-2 py-1 rounded-md',
241
- velocityChange >= 0 ? 'bg-foreground/5 text-foreground' : 'bg-muted text-muted-foreground'
242
- )}>
243
- {velocityChange >= 0 ? <TrendingUp className="h-3.5 w-3.5" /> : <TrendingDown className="h-3.5 w-3.5" />}
244
- {velocityChange >= 0 ? '+' : ''}{velocityChange}%
245
- </span>
246
- <span className="text-sm text-muted-foreground">velocity vs last week</span>
247
- </div>
248
- )}
249
-
250
- {/* Secondary KPIs Row */}
251
- <StatsRow className="mt-8">
252
- <div className="flex items-center gap-3">
253
- <HealthRing score={healthScore} />
254
- <div>
255
- <p className="text-sm font-medium">Health</p>
256
- <p className="text-xs text-muted-foreground">Project score</p>
257
- </div>
258
- </div>
259
-
260
- <StatCard value={`${completionRate}%`} label="Completion" />
261
- <StatCard value={stats.summary.totalShipsEver} label="Ships" />
262
- <StatCard value={stats.metrics.velocity.tasksPerDay} label="Tasks/day" />
263
- <StatCard
264
- value={<>{stats.queue.length} / {stats.ideas.pending.length}</>}
265
- label="Queue / Ideas"
162
+ {/* Hero Section */}
163
+ <HeroSection
164
+ projectId={projectId}
165
+ projectName={project.name || projectId}
166
+ tasksCompleted={stats.metrics.tasksCompleted}
167
+ healthScore={healthScore}
168
+ velocity={velocity}
169
+ velocityChange={velocityChange}
170
+ insightMessage={insightMessage}
171
+ timeline={stats.timeline}
172
+ />
173
+
174
+ {/* Bento Grid */}
175
+ <BentoGrid className="mt-8">
176
+ {/* Row 1: NOW (2x2), VELOCITY (1x1), ROADMAP (2x2) */}
177
+ <NowCard currentTask={stats.currentTask} />
178
+ <VelocityCard
179
+ tasksPerDay={velocity}
180
+ weeklyData={weeklyVelocityData}
181
+ change={velocityChange}
266
182
  />
267
- {streak > 0 && (
268
- <div className="flex items-center gap-2">
269
- <Flame className={cn('w-5 h-5', streak > 3 ? 'text-orange-500' : 'text-foreground/40')} />
270
- <StatCard value={streak} label="Day streak" />
271
- </div>
272
- )}
273
- </StatsRow>
183
+ <RoadmapCard roadmap={stats.roadmap} />
274
184
 
275
- {/* Recent Activity - Chips style */}
276
- {recentActivity.length > 0 && (
277
- <div className="mt-8">
278
- <SectionLabel className="mb-4">RECENT</SectionLabel>
279
- <div className="flex flex-wrap gap-3">
280
- {recentActivity.map((event: TimelineEvent, i: number) => {
281
- const e = event as Record<string, unknown>
282
- const label = event.type === 'feature_ship' ? (e.name as string) :
283
- event.type === 'task_complete' ? (e.task as string) :
284
- event.type === 'task_start' ? (e.task as string) :
285
- event.type === 'sync' ? 'Sync' : event.type
286
- const status = event.type === 'feature_ship' ? 'SHIP' :
287
- event.type === 'task_complete' ? 'DONE' :
288
- event.type === 'task_start' ? 'START' :
289
- event.type === 'sync' ? 'SYNC' : event.type.toUpperCase()
290
- return (
291
- <div
292
- key={i}
293
- className="group flex items-center gap-3 rounded-lg border-2 border-foreground/10 px-4 py-2 transition-all hover:border-foreground hover:bg-foreground hover:text-background cursor-default"
294
- >
295
- <span className="text-sm font-bold text-foreground group-hover:text-background truncate max-w-[150px]">
296
- {label}
297
- </span>
298
- <span className="text-[9px] font-bold tracking-wider text-foreground/40 group-hover:text-background/60">
299
- {status}
300
- </span>
301
- {event.ts && (
302
- <span className="text-[9px] font-bold tracking-wider text-foreground/30 group-hover:text-background/40">
303
- {formatRelativeTime(event.ts)}
304
- </span>
305
- )}
306
- </div>
307
- )
308
- })}
309
- </div>
310
- </div>
311
- )}
185
+ {/* STREAK under VELOCITY */}
186
+ <StreakCard streak={streak} />
312
187
 
313
- {/* Masonry Grid Layout */}
314
- <div className="columns-1 md:columns-2 lg:columns-3 gap-8 mt-10 [column-fill:_balance]">
315
- {/* Current Task */}
316
- <div className="break-inside-avoid mb-8">
317
- <SectionLabel className="mb-3">NOW</SectionLabel>
318
- {stats.currentTask ? (
319
- <div>
320
- <div className="flex items-center gap-2 mb-2">
321
- <div className="w-1.5 h-1.5 rounded-full bg-amber-500 animate-pulse" />
322
- <span className="text-[10px] font-medium text-amber-600 uppercase tracking-wider">Working</span>
323
- </div>
324
- <p className="text-lg font-semibold leading-tight">{stats.currentTask.task}</p>
325
- {stats.currentTask.duration && (
326
- <p className="text-xs text-muted-foreground mt-2">{stats.currentTask.duration}</p>
327
- )}
328
- </div>
329
- ) : (
330
- <p className="text-sm text-muted-foreground">No active task</p>
331
- )}
332
- </div>
333
-
334
- {/* Queue */}
335
- {stats.queue.length > 0 && (
336
- <div className="break-inside-avoid mb-8">
337
- <div className="flex items-center gap-2 mb-3">
338
- <SectionLabel>QUEUE</SectionLabel>
339
- <span className="text-xs text-muted-foreground">{stats.queue.length}</span>
340
- </div>
341
- <div className="space-y-1.5">
342
- {stats.queue.slice(0, 6).map((task: any, i: number) => (
343
- <div key={i} className="flex items-center gap-2 group">
344
- <span className="text-[10px] text-muted-foreground w-3">{i + 1}</span>
345
- <p className="flex-1 text-sm truncate">{task.task}</p>
346
- </div>
347
- ))}
348
- {stats.queue.length > 6 && (
349
- <p className="text-[10px] text-muted-foreground">+{stats.queue.length - 6} more</p>
350
- )}
351
- </div>
352
- </div>
353
- )}
354
-
355
- {/* Ships */}
356
- {stats.shipped.length > 0 && (
357
- <div className="break-inside-avoid mb-8">
358
- <SectionLabel className="mb-3">SHIPS</SectionLabel>
359
- <div className="space-y-3">
360
- {stats.shipped.slice(0, 5).map((ship: any, i: number) => (
361
- <div key={i}>
362
- <p className="text-sm font-medium">{ship.name}</p>
363
- <p className="text-[10px] text-muted-foreground">
364
- {new Date(ship.date).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}
365
- {ship.version && ` · ${ship.version}`}
366
- </p>
367
- </div>
368
- ))}
369
- </div>
370
- </div>
371
- )}
372
-
373
- {/* Agents */}
374
- {stats.agents.length > 0 && (
375
- <div className="break-inside-avoid mb-8">
376
- <div className="flex items-center gap-2 mb-3">
377
- <SectionLabel>AGENTS</SectionLabel>
378
- <span className="text-xs text-muted-foreground">{stats.agents.length}</span>
379
- </div>
380
- <div className="flex flex-wrap gap-1.5">
381
- {stats.agents.slice(0, 10).map((agent: any) => (
382
- <span
383
- key={agent.name}
384
- className="text-xs px-2 py-0.5 bg-foreground/5 rounded"
385
- >
386
- {agent.name}
387
- </span>
388
- ))}
389
- </div>
390
- </div>
391
- )}
392
-
393
- {/* Ideas */}
394
- {stats.ideas.pending.length > 0 && (
395
- <div className="break-inside-avoid mb-8">
396
- <div className="flex items-center gap-2 mb-3">
397
- <SectionLabel>IDEAS</SectionLabel>
398
- <span className="text-xs text-muted-foreground">{stats.ideas.pending.length}</span>
399
- </div>
400
- <div className="space-y-2">
401
- {stats.ideas.pending.slice(0, 5).map((idea: any, i: number) => (
402
- <div key={i} className="flex items-start gap-2">
403
- <Lightbulb className={cn(
404
- 'w-3 h-3 mt-0.5 shrink-0',
405
- idea.impact === 'HIGH' ? 'text-foreground' : 'text-muted-foreground'
406
- )} />
407
- <p className="text-sm">{idea.title}</p>
408
- </div>
409
- ))}
410
- </div>
411
- </div>
412
- )}
188
+ {/* Row 2: QUEUE (1x2), SHIPS (1x2), IDEAS (1x1), AGENTS (1x1) */}
189
+ <QueueCard queue={stats.queue || []} />
190
+ <ShipsCard
191
+ ships={stats.shipped || []}
192
+ totalShips={stats.summary?.totalShipsEver || 0}
193
+ />
194
+ <IdeasCard ideas={stats.ideas?.pending || []} />
195
+ <AgentsCard agents={stats.agents || []} />
196
+ </BentoGrid>
413
197
 
414
- {/* Roadmap */}
415
- {stats.roadmap?.phases?.length > 0 && (
416
- <div className="break-inside-avoid mb-8">
417
- <div className="flex items-center justify-between mb-3">
418
- <SectionLabel>ROADMAP</SectionLabel>
419
- <span className="text-lg font-bold">{stats.roadmap.progress}%</span>
420
- </div>
421
- <div className="space-y-2">
422
- {stats.roadmap.phases.filter((p: any) => (p.features || []).length > 0).slice(0, 4).map((phase: any) => (
423
- <div key={phase.name}>
424
- <div className="flex items-center justify-between text-xs mb-1">
425
- <span>{phase.name}</span>
426
- <span className="text-muted-foreground">{phase.progress}%</span>
427
- </div>
428
- <div className="h-1 bg-muted rounded-full overflow-hidden">
429
- <div
430
- className={cn(
431
- 'h-full rounded-full',
432
- phase.progress === 100 ? 'bg-emerald-500' : 'bg-foreground'
433
- )}
434
- style={{ width: `${phase.progress}%` }}
435
- />
436
- </div>
437
- </div>
438
- ))}
439
- </div>
440
- </div>
441
- )}
198
+ {/* Activity Timeline */}
199
+ <div className="mt-8">
200
+ <ActivityTimeline timeline={stats.timeline || []} />
442
201
  </div>
443
202
 
444
203
  <div className="h-8" />