prjct-cli 0.11.0 → 0.11.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/bin/serve.js +90 -26
- package/package.json +11 -1
- package/packages/shared/dist/index.d.ts +615 -0
- package/packages/shared/dist/index.js +204 -0
- package/packages/shared/package.json +29 -0
- package/packages/shared/src/index.ts +9 -0
- package/packages/shared/src/schemas.ts +124 -0
- package/packages/shared/src/types.ts +187 -0
- package/packages/shared/src/utils.ts +148 -0
- package/packages/shared/tsconfig.json +18 -0
- package/packages/web/README.md +36 -0
- package/packages/web/app/api/claude/sessions/route.ts +44 -0
- package/packages/web/app/api/claude/status/route.ts +34 -0
- package/packages/web/app/api/projects/[id]/delete/route.ts +21 -0
- package/packages/web/app/api/projects/[id]/icon/route.ts +33 -0
- package/packages/web/app/api/projects/[id]/route.ts +29 -0
- package/packages/web/app/api/projects/[id]/stats/route.ts +36 -0
- package/packages/web/app/api/projects/[id]/status/route.ts +21 -0
- package/packages/web/app/api/projects/route.ts +16 -0
- package/packages/web/app/api/sessions/history/route.ts +122 -0
- package/packages/web/app/api/stats/route.ts +38 -0
- package/packages/web/app/error.tsx +34 -0
- package/packages/web/app/favicon.ico +0 -0
- package/packages/web/app/globals.css +155 -0
- package/packages/web/app/layout.tsx +43 -0
- package/packages/web/app/loading.tsx +7 -0
- package/packages/web/app/not-found.tsx +25 -0
- package/packages/web/app/page.tsx +227 -0
- package/packages/web/app/project/[id]/error.tsx +41 -0
- package/packages/web/app/project/[id]/loading.tsx +9 -0
- package/packages/web/app/project/[id]/not-found.tsx +27 -0
- package/packages/web/app/project/[id]/page.tsx +253 -0
- package/packages/web/app/project/[id]/stats/page.tsx +447 -0
- package/packages/web/app/sessions/page.tsx +165 -0
- package/packages/web/app/settings/page.tsx +150 -0
- package/packages/web/components/AppSidebar.tsx +113 -0
- package/packages/web/components/CommandButton.tsx +39 -0
- package/packages/web/components/ConnectionStatus.tsx +29 -0
- package/packages/web/components/Logo.tsx +65 -0
- package/packages/web/components/MarkdownContent.tsx +123 -0
- package/packages/web/components/ProjectAvatar.tsx +54 -0
- package/packages/web/components/TechStackBadges.tsx +20 -0
- package/packages/web/components/TerminalTab.tsx +84 -0
- package/packages/web/components/TerminalTabs.tsx +210 -0
- package/packages/web/components/charts/SessionsChart.tsx +172 -0
- package/packages/web/components/providers.tsx +45 -0
- package/packages/web/components/ui/alert-dialog.tsx +157 -0
- package/packages/web/components/ui/badge.tsx +46 -0
- package/packages/web/components/ui/button.tsx +60 -0
- package/packages/web/components/ui/card.tsx +92 -0
- package/packages/web/components/ui/chart.tsx +385 -0
- package/packages/web/components/ui/dropdown-menu.tsx +257 -0
- package/packages/web/components/ui/scroll-area.tsx +58 -0
- package/packages/web/components/ui/sheet.tsx +139 -0
- package/packages/web/components/ui/tabs.tsx +66 -0
- package/packages/web/components/ui/tooltip.tsx +61 -0
- package/packages/web/components.json +22 -0
- package/packages/web/context/TerminalContext.tsx +45 -0
- package/packages/web/context/TerminalTabsContext.tsx +136 -0
- package/packages/web/eslint.config.mjs +18 -0
- package/packages/web/hooks/useClaudeTerminal.ts +375 -0
- package/packages/web/hooks/useProjectStats.ts +38 -0
- package/packages/web/hooks/useProjects.ts +73 -0
- package/packages/web/hooks/useStats.ts +28 -0
- package/packages/web/lib/format.ts +23 -0
- package/packages/web/lib/parse-prjct-files.ts +1122 -0
- package/packages/web/lib/projects.ts +452 -0
- package/packages/web/lib/pty.ts +101 -0
- package/packages/web/lib/query-config.ts +44 -0
- package/packages/web/lib/utils.ts +6 -0
- package/packages/web/next-env.d.ts +6 -0
- package/packages/web/next.config.ts +7 -0
- package/packages/web/package.json +53 -0
- package/packages/web/postcss.config.mjs +7 -0
- package/packages/web/public/file.svg +1 -0
- package/packages/web/public/globe.svg +1 -0
- package/packages/web/public/next.svg +1 -0
- package/packages/web/public/vercel.svg +1 -0
- package/packages/web/public/window.svg +1 -0
- package/packages/web/server.ts +262 -0
- package/packages/web/tsconfig.json +34 -0
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { use, useMemo, useState } from 'react'
|
|
4
|
+
import { useRouter } from 'next/navigation'
|
|
5
|
+
import Link from 'next/link'
|
|
6
|
+
import { useProject } from '@/hooks/useProjects'
|
|
7
|
+
import { useProjectStats } from '@/hooks/useProjectStats'
|
|
8
|
+
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'
|
|
27
|
+
import type { TimelineEvent } from '@/lib/parse-prjct-files'
|
|
28
|
+
|
|
29
|
+
// Calculate streak
|
|
30
|
+
function calculateStreak(timeline: TimelineEvent[]): number {
|
|
31
|
+
if (!timeline.length) return 0
|
|
32
|
+
const dates = new Set(timeline.map(e => e.ts?.split('T')[0]).filter(Boolean))
|
|
33
|
+
let streak = 0
|
|
34
|
+
const today = new Date()
|
|
35
|
+
for (let i = 0; i < 30; i++) {
|
|
36
|
+
const date = new Date(today)
|
|
37
|
+
date.setDate(date.getDate() - i)
|
|
38
|
+
const dateStr = date.toISOString().split('T')[0]
|
|
39
|
+
if (dates.has(dateStr)) streak++
|
|
40
|
+
else if (i > 0) break
|
|
41
|
+
}
|
|
42
|
+
return streak
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Health score (0-100)
|
|
46
|
+
function getHealthScore(stats: any): number {
|
|
47
|
+
if (!stats) return 0
|
|
48
|
+
const velocity = stats?.metrics?.velocity?.tasksPerDay || 0
|
|
49
|
+
const hasCurrentTask = !!stats?.currentTask
|
|
50
|
+
const queueSize = stats?.queue?.length || 0
|
|
51
|
+
const recentActivity = stats?.timeline?.slice(0, 7).length || 0
|
|
52
|
+
|
|
53
|
+
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
|
|
58
|
+
|
|
59
|
+
return Math.min(100, Math.round(score))
|
|
60
|
+
}
|
|
61
|
+
|
|
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
|
+
// Contextual insight message
|
|
85
|
+
function getInsightMessage(stats: any, streak: number): string {
|
|
86
|
+
if (!stats) return ''
|
|
87
|
+
|
|
88
|
+
const velocity = stats?.metrics?.velocity?.tasksPerDay || 0
|
|
89
|
+
const hasCurrentTask = !!stats?.currentTask
|
|
90
|
+
const queueSize = stats?.queue?.length || 0
|
|
91
|
+
const shipsCount = stats?.summary?.totalShipsEver || 0
|
|
92
|
+
|
|
93
|
+
if (hasCurrentTask && streak > 3) return 'Killing it. Keep the momentum.'
|
|
94
|
+
if (hasCurrentTask) return 'Good focus. Ship when ready.'
|
|
95
|
+
if (queueSize === 0) return 'Queue empty. Time to plan the next feature.'
|
|
96
|
+
if (velocity > 2) return 'Fast pace. Watch for burnout.'
|
|
97
|
+
if (shipsCount === 0) return 'No ships yet. Start small, ship fast.'
|
|
98
|
+
if (streak === 0) return 'Get back in the flow. Start something.'
|
|
99
|
+
return 'Steady progress. Pick the next task.'
|
|
100
|
+
}
|
|
101
|
+
|
|
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
|
+
)
|
|
109
|
+
}
|
|
110
|
+
|
|
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]
|
|
119
|
+
|
|
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
|
+
}
|
|
137
|
+
|
|
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' },
|
|
149
|
+
}
|
|
150
|
+
const styles = sizeClasses[size]
|
|
151
|
+
|
|
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
|
+
)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Stats row component - minimal divider
|
|
164
|
+
function StatsRow({ children, className }: { children: React.ReactNode; className?: string }) {
|
|
165
|
+
return (
|
|
166
|
+
<div className={cn('flex flex-wrap gap-x-10 gap-y-4', className)}>
|
|
167
|
+
{children}
|
|
168
|
+
</div>
|
|
169
|
+
)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export default function ProjectStatsPage({ params }: { params: Promise<{ id: string }> }) {
|
|
173
|
+
const { id: projectId } = use(params)
|
|
174
|
+
const router = useRouter()
|
|
175
|
+
const [expandedSection, setExpandedSection] = useState<string | null>(null)
|
|
176
|
+
|
|
177
|
+
const { data: project, isLoading: projectLoading } = useProject(projectId)
|
|
178
|
+
const { data, isLoading: statsLoading } = useProjectStats(projectId)
|
|
179
|
+
const stats = data?.stats
|
|
180
|
+
|
|
181
|
+
const streak = useMemo(() => calculateStreak(stats?.timeline || []), [stats?.timeline])
|
|
182
|
+
const healthScore = useMemo(() => getHealthScore(stats), [stats])
|
|
183
|
+
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])
|
|
202
|
+
|
|
203
|
+
if (projectLoading || statsLoading) {
|
|
204
|
+
return <div className="flex items-center justify-center h-full"><div className="animate-pulse text-muted-foreground">Loading...</div></div>
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (!project || !stats) {
|
|
208
|
+
return (
|
|
209
|
+
<div className="flex items-center justify-center h-full">
|
|
210
|
+
<div className="text-center space-y-4">
|
|
211
|
+
<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>
|
|
213
|
+
</div>
|
|
214
|
+
</div>
|
|
215
|
+
)
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return (
|
|
219
|
+
<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"
|
|
266
|
+
/>
|
|
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>
|
|
274
|
+
|
|
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
|
+
)}
|
|
312
|
+
|
|
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
|
+
)}
|
|
413
|
+
|
|
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
|
+
)}
|
|
442
|
+
</div>
|
|
443
|
+
|
|
444
|
+
<div className="h-8" />
|
|
445
|
+
</div>
|
|
446
|
+
)
|
|
447
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useQuery } from '@tanstack/react-query'
|
|
4
|
+
import { Clock, Target, CheckCircle, PauseCircle, GitCommit, FileCode } from 'lucide-react'
|
|
5
|
+
|
|
6
|
+
function formatDuration(ms: number): string {
|
|
7
|
+
const hours = Math.floor(ms / 3600000)
|
|
8
|
+
const minutes = Math.floor((ms % 3600000) / 60000)
|
|
9
|
+
if (hours > 0) return `${hours}h ${minutes}m`
|
|
10
|
+
return `${minutes}m`
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function getRelativeTime(dateString: string): string {
|
|
14
|
+
const date = new Date(dateString)
|
|
15
|
+
const now = new Date()
|
|
16
|
+
const diff = now.getTime() - date.getTime()
|
|
17
|
+
const minutes = Math.floor(diff / 60000)
|
|
18
|
+
const hours = Math.floor(diff / 3600000)
|
|
19
|
+
const days = Math.floor(diff / 86400000)
|
|
20
|
+
|
|
21
|
+
if (minutes < 1) return 'just now'
|
|
22
|
+
if (minutes < 60) return `${minutes}m ago`
|
|
23
|
+
if (hours < 24) return `${hours}h ago`
|
|
24
|
+
return `${days}d ago`
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export default function Sessions() {
|
|
28
|
+
const { data: projects } = useQuery({
|
|
29
|
+
queryKey: ['projects'],
|
|
30
|
+
queryFn: async () => {
|
|
31
|
+
const res = await fetch('/api/projects')
|
|
32
|
+
const json = await res.json()
|
|
33
|
+
return json.data || []
|
|
34
|
+
}
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
// Get sessions from first project (for demo)
|
|
38
|
+
const projectId = projects?.[0]?.id
|
|
39
|
+
|
|
40
|
+
const { data: sessions, isLoading } = useQuery({
|
|
41
|
+
queryKey: ['sessions', projectId],
|
|
42
|
+
queryFn: async () => {
|
|
43
|
+
if (!projectId) return []
|
|
44
|
+
const res = await fetch(`/api/sessions?projectId=${projectId}`)
|
|
45
|
+
const json = await res.json()
|
|
46
|
+
return json.data || []
|
|
47
|
+
},
|
|
48
|
+
enabled: !!projectId
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
if (isLoading) {
|
|
52
|
+
return (
|
|
53
|
+
<div className="flex items-center justify-center h-full">
|
|
54
|
+
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
|
|
55
|
+
</div>
|
|
56
|
+
)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return (
|
|
60
|
+
<div className="p-6 h-full overflow-auto">
|
|
61
|
+
<header className="mb-6">
|
|
62
|
+
<h1 className="text-2xl font-bold flex items-center gap-2">
|
|
63
|
+
<Clock className="w-6 h-6" />
|
|
64
|
+
Sessions
|
|
65
|
+
</h1>
|
|
66
|
+
<p className="text-muted-foreground mt-1">
|
|
67
|
+
Your work session history
|
|
68
|
+
</p>
|
|
69
|
+
</header>
|
|
70
|
+
|
|
71
|
+
{!sessions?.length ? (
|
|
72
|
+
<div className="border border-dashed border-border rounded-lg p-8 text-center">
|
|
73
|
+
<Clock className="w-12 h-12 text-muted-foreground mx-auto mb-4" />
|
|
74
|
+
<p className="text-muted-foreground">
|
|
75
|
+
No sessions yet. Start one with <code className="bg-muted px-2 py-1 rounded">/p:now</code>
|
|
76
|
+
</p>
|
|
77
|
+
</div>
|
|
78
|
+
) : (
|
|
79
|
+
<div className="space-y-4">
|
|
80
|
+
{sessions.map((session: Session) => (
|
|
81
|
+
<SessionCard key={session.id} session={session} />
|
|
82
|
+
))}
|
|
83
|
+
</div>
|
|
84
|
+
)}
|
|
85
|
+
</div>
|
|
86
|
+
)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
interface Session {
|
|
90
|
+
id: string
|
|
91
|
+
task: string
|
|
92
|
+
status: 'active' | 'paused' | 'completed'
|
|
93
|
+
startedAt: string
|
|
94
|
+
completedAt?: string
|
|
95
|
+
duration: number
|
|
96
|
+
metrics: {
|
|
97
|
+
filesChanged: number
|
|
98
|
+
linesAdded: number
|
|
99
|
+
linesRemoved: number
|
|
100
|
+
commits: number
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function SessionCard({ session }: { session: Session }) {
|
|
105
|
+
const statusConfig = {
|
|
106
|
+
active: {
|
|
107
|
+
icon: Target,
|
|
108
|
+
color: 'text-green-500',
|
|
109
|
+
bg: 'bg-green-500/10',
|
|
110
|
+
label: 'Active'
|
|
111
|
+
},
|
|
112
|
+
paused: {
|
|
113
|
+
icon: PauseCircle,
|
|
114
|
+
color: 'text-yellow-500',
|
|
115
|
+
bg: 'bg-yellow-500/10',
|
|
116
|
+
label: 'Paused'
|
|
117
|
+
},
|
|
118
|
+
completed: {
|
|
119
|
+
icon: CheckCircle,
|
|
120
|
+
color: 'text-blue-500',
|
|
121
|
+
bg: 'bg-blue-500/10',
|
|
122
|
+
label: 'Completed'
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const status = statusConfig[session.status]
|
|
127
|
+
const StatusIcon = status.icon
|
|
128
|
+
|
|
129
|
+
return (
|
|
130
|
+
<div className="bg-card border border-border rounded-lg p-4">
|
|
131
|
+
<div className="flex items-start justify-between mb-3">
|
|
132
|
+
<div className="flex-1">
|
|
133
|
+
<h3 className="font-medium mb-1">{session.task}</h3>
|
|
134
|
+
<div className="flex items-center gap-3 text-sm text-muted-foreground">
|
|
135
|
+
<span>{getRelativeTime(session.startedAt)}</span>
|
|
136
|
+
<span>•</span>
|
|
137
|
+
<span>{formatDuration(session.duration)}</span>
|
|
138
|
+
</div>
|
|
139
|
+
</div>
|
|
140
|
+
|
|
141
|
+
<div className={`flex items-center gap-1.5 px-2 py-1 rounded-md text-xs font-medium ${status.bg} ${status.color}`}>
|
|
142
|
+
<StatusIcon className="w-3 h-3" />
|
|
143
|
+
{status.label}
|
|
144
|
+
</div>
|
|
145
|
+
</div>
|
|
146
|
+
|
|
147
|
+
<div className="flex items-center gap-4 text-sm text-muted-foreground">
|
|
148
|
+
<span className="flex items-center gap-1">
|
|
149
|
+
<FileCode className="w-3.5 h-3.5" />
|
|
150
|
+
{session.metrics.filesChanged} files
|
|
151
|
+
</span>
|
|
152
|
+
<span className="flex items-center gap-1 text-green-500">
|
|
153
|
+
+{session.metrics.linesAdded}
|
|
154
|
+
</span>
|
|
155
|
+
<span className="flex items-center gap-1 text-red-500">
|
|
156
|
+
-{session.metrics.linesRemoved}
|
|
157
|
+
</span>
|
|
158
|
+
<span className="flex items-center gap-1">
|
|
159
|
+
<GitCommit className="w-3.5 h-3.5" />
|
|
160
|
+
{session.metrics.commits} commits
|
|
161
|
+
</span>
|
|
162
|
+
</div>
|
|
163
|
+
</div>
|
|
164
|
+
)
|
|
165
|
+
}
|