prjct-cli 0.11.4 → 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.
- package/CHANGELOG.md +14 -0
- package/bin/serve.js +22 -6
- package/core/__tests__/utils/date-helper.test.js +416 -0
- package/core/agentic/agent-router.js +30 -18
- package/core/agentic/command-executor.js +20 -24
- package/core/agentic/context-builder.js +7 -8
- package/core/agentic/memory-system.js +14 -19
- package/core/agentic/prompt-builder.js +41 -27
- package/core/agentic/template-loader.js +8 -2
- package/core/infrastructure/agent-detector.js +7 -4
- package/core/infrastructure/migrator.js +10 -13
- package/core/infrastructure/session-manager.js +10 -10
- package/package.json +1 -1
- package/packages/web/app/project/[id]/stats/page.tsx +102 -343
- package/packages/web/components/stats/ActivityTimeline.tsx +201 -0
- package/packages/web/components/stats/AgentsCard.tsx +56 -0
- package/packages/web/components/stats/BentoCard.tsx +88 -0
- package/packages/web/components/stats/BentoGrid.tsx +22 -0
- package/packages/web/components/stats/EmptyState.tsx +67 -0
- package/packages/web/components/stats/HeroSection.tsx +172 -0
- package/packages/web/components/stats/IdeasCard.tsx +59 -0
- package/packages/web/components/stats/NowCard.tsx +71 -0
- package/packages/web/components/stats/ProgressRing.tsx +74 -0
- package/packages/web/components/stats/QueueCard.tsx +58 -0
- package/packages/web/components/stats/RoadmapCard.tsx +97 -0
- package/packages/web/components/stats/ShipsCard.tsx +70 -0
- package/packages/web/components/stats/SparklineChart.tsx +44 -0
- package/packages/web/components/stats/StreakCard.tsx +59 -0
- package/packages/web/components/stats/VelocityCard.tsx +60 -0
- package/packages/web/components/stats/index.ts +17 -0
- package/packages/web/components/ui/tooltip.tsx +2 -2
- package/packages/web/next-env.d.ts +1 -1
- package/packages/web/package.json +2 -1
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useMemo, useState } from 'react'
|
|
4
|
+
import { BentoCard } from './BentoCard'
|
|
5
|
+
import { EmptyState } from './EmptyState'
|
|
6
|
+
import { Activity, CheckCircle2, Rocket, Target, RefreshCw, ChevronDown } from 'lucide-react'
|
|
7
|
+
import { cn } from '@/lib/utils'
|
|
8
|
+
import { Button } from '@/components/ui/button'
|
|
9
|
+
import type { TimelineEvent } from '@/lib/parse-prjct-files'
|
|
10
|
+
|
|
11
|
+
interface ActivityTimelineProps {
|
|
12
|
+
timeline: TimelineEvent[]
|
|
13
|
+
className?: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function getEventIcon(type: string) {
|
|
17
|
+
switch (type) {
|
|
18
|
+
case 'task_complete':
|
|
19
|
+
return CheckCircle2
|
|
20
|
+
case 'task_start':
|
|
21
|
+
return Target
|
|
22
|
+
case 'feature_ship':
|
|
23
|
+
return Rocket
|
|
24
|
+
case 'sync':
|
|
25
|
+
return RefreshCw
|
|
26
|
+
default:
|
|
27
|
+
return Activity
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function getEventColor(type: string) {
|
|
32
|
+
switch (type) {
|
|
33
|
+
case 'task_complete':
|
|
34
|
+
return 'text-emerald-500'
|
|
35
|
+
case 'task_start':
|
|
36
|
+
return 'text-amber-500'
|
|
37
|
+
case 'feature_ship':
|
|
38
|
+
return 'text-blue-500'
|
|
39
|
+
case 'sync':
|
|
40
|
+
return 'text-muted-foreground'
|
|
41
|
+
default:
|
|
42
|
+
return 'text-muted-foreground'
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function getEventLabel(event: TimelineEvent): string {
|
|
47
|
+
const e = event as Record<string, unknown>
|
|
48
|
+
switch (event.type) {
|
|
49
|
+
case 'task_complete':
|
|
50
|
+
return (e.task as string) || 'Task completed'
|
|
51
|
+
case 'task_start':
|
|
52
|
+
return (e.task as string) || 'Task started'
|
|
53
|
+
case 'feature_ship':
|
|
54
|
+
return (e.name as string) || 'Feature shipped'
|
|
55
|
+
case 'sync':
|
|
56
|
+
return 'Project synced'
|
|
57
|
+
default:
|
|
58
|
+
return event.type
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function getEventBadge(type: string): string {
|
|
63
|
+
switch (type) {
|
|
64
|
+
case 'task_complete':
|
|
65
|
+
return 'DONE'
|
|
66
|
+
case 'task_start':
|
|
67
|
+
return 'START'
|
|
68
|
+
case 'feature_ship':
|
|
69
|
+
return 'SHIP'
|
|
70
|
+
case 'sync':
|
|
71
|
+
return 'SYNC'
|
|
72
|
+
default:
|
|
73
|
+
return type.toUpperCase()
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function formatTime(dateString: string): string {
|
|
78
|
+
const date = new Date(dateString)
|
|
79
|
+
return date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true })
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function formatDate(dateString: string): string {
|
|
83
|
+
const date = new Date(dateString)
|
|
84
|
+
const today = new Date()
|
|
85
|
+
const yesterday = new Date(today)
|
|
86
|
+
yesterday.setDate(yesterday.getDate() - 1)
|
|
87
|
+
|
|
88
|
+
if (date.toDateString() === today.toDateString()) return 'Today'
|
|
89
|
+
if (date.toDateString() === yesterday.toDateString()) return 'Yesterday'
|
|
90
|
+
|
|
91
|
+
return date.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' })
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Group events by date
|
|
95
|
+
function groupEventsByDate(events: TimelineEvent[]): Map<string, TimelineEvent[]> {
|
|
96
|
+
const groups = new Map<string, TimelineEvent[]>()
|
|
97
|
+
|
|
98
|
+
events.forEach(event => {
|
|
99
|
+
if (!event.ts) return
|
|
100
|
+
const dateKey = event.ts.split('T')[0]
|
|
101
|
+
const existing = groups.get(dateKey) || []
|
|
102
|
+
groups.set(dateKey, [...existing, event])
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
return groups
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function ActivityTimeline({ timeline, className }: ActivityTimelineProps) {
|
|
109
|
+
const [showAll, setShowAll] = useState(false)
|
|
110
|
+
|
|
111
|
+
const displayEvents = showAll ? timeline.slice(0, 20) : timeline.slice(0, 8)
|
|
112
|
+
const groupedEvents = useMemo(() => groupEventsByDate(displayEvents), [displayEvents])
|
|
113
|
+
const hasMore = timeline.length > displayEvents.length
|
|
114
|
+
|
|
115
|
+
return (
|
|
116
|
+
<BentoCard
|
|
117
|
+
size="full"
|
|
118
|
+
title="Recent Activity"
|
|
119
|
+
icon={Activity}
|
|
120
|
+
count={timeline.length > 0 ? `${timeline.length} events` : undefined}
|
|
121
|
+
className={className}
|
|
122
|
+
>
|
|
123
|
+
{timeline.length === 0 ? (
|
|
124
|
+
<EmptyState
|
|
125
|
+
icon={Activity}
|
|
126
|
+
title="No recent activity"
|
|
127
|
+
description="Activity will appear as you work"
|
|
128
|
+
compact
|
|
129
|
+
/>
|
|
130
|
+
) : (
|
|
131
|
+
<div className="space-y-4">
|
|
132
|
+
{Array.from(groupedEvents.entries()).map(([dateKey, events]) => (
|
|
133
|
+
<div key={dateKey}>
|
|
134
|
+
{/* Date header */}
|
|
135
|
+
<p className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground mb-2">
|
|
136
|
+
{formatDate(dateKey + 'T00:00:00')}
|
|
137
|
+
</p>
|
|
138
|
+
|
|
139
|
+
{/* Events for this date */}
|
|
140
|
+
<div className="space-y-1">
|
|
141
|
+
{events.map((event, i) => {
|
|
142
|
+
const Icon = getEventIcon(event.type)
|
|
143
|
+
const e = event as Record<string, unknown>
|
|
144
|
+
|
|
145
|
+
const duration = typeof e.duration === 'string' ? e.duration : null
|
|
146
|
+
|
|
147
|
+
return (
|
|
148
|
+
<div
|
|
149
|
+
key={i}
|
|
150
|
+
className="flex items-center gap-3 py-1.5 px-2 -mx-2 rounded-md hover:bg-muted/50 transition-colors group"
|
|
151
|
+
>
|
|
152
|
+
{/* Time */}
|
|
153
|
+
{event.ts && (
|
|
154
|
+
<span className="text-[10px] text-muted-foreground w-14 shrink-0 tabular-nums">
|
|
155
|
+
{formatTime(event.ts)}
|
|
156
|
+
</span>
|
|
157
|
+
)}
|
|
158
|
+
|
|
159
|
+
{/* Icon */}
|
|
160
|
+
<Icon className={cn('h-3.5 w-3.5 shrink-0', getEventColor(event.type))} />
|
|
161
|
+
|
|
162
|
+
{/* Label */}
|
|
163
|
+
<span className="text-sm truncate flex-1 group-hover:text-foreground transition-colors">
|
|
164
|
+
{getEventLabel(event)}
|
|
165
|
+
</span>
|
|
166
|
+
|
|
167
|
+
{/* Badge */}
|
|
168
|
+
<span className="text-[9px] font-bold tracking-wider text-muted-foreground shrink-0">
|
|
169
|
+
{getEventBadge(event.type)}
|
|
170
|
+
</span>
|
|
171
|
+
|
|
172
|
+
{/* Duration if available */}
|
|
173
|
+
{duration && (
|
|
174
|
+
<span className="text-[10px] text-muted-foreground shrink-0">
|
|
175
|
+
{duration}
|
|
176
|
+
</span>
|
|
177
|
+
)}
|
|
178
|
+
</div>
|
|
179
|
+
)
|
|
180
|
+
})}
|
|
181
|
+
</div>
|
|
182
|
+
</div>
|
|
183
|
+
))}
|
|
184
|
+
|
|
185
|
+
{/* Load more button */}
|
|
186
|
+
{hasMore && (
|
|
187
|
+
<Button
|
|
188
|
+
variant="ghost"
|
|
189
|
+
size="sm"
|
|
190
|
+
onClick={() => setShowAll(!showAll)}
|
|
191
|
+
className="w-full text-muted-foreground"
|
|
192
|
+
>
|
|
193
|
+
<ChevronDown className={cn('h-4 w-4 mr-1 transition-transform', showAll && 'rotate-180')} />
|
|
194
|
+
{showAll ? 'Show less' : `Show ${timeline.length - 8} more`}
|
|
195
|
+
</Button>
|
|
196
|
+
)}
|
|
197
|
+
</div>
|
|
198
|
+
)}
|
|
199
|
+
</BentoCard>
|
|
200
|
+
)
|
|
201
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { BentoCard } from './BentoCard'
|
|
4
|
+
import { EmptyState } from './EmptyState'
|
|
5
|
+
import { Bot } from 'lucide-react'
|
|
6
|
+
import { Badge } from '@/components/ui/badge'
|
|
7
|
+
|
|
8
|
+
interface Agent {
|
|
9
|
+
name: string
|
|
10
|
+
description?: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface AgentsCardProps {
|
|
14
|
+
agents: Agent[]
|
|
15
|
+
className?: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function AgentsCard({ agents, className }: AgentsCardProps) {
|
|
19
|
+
const displayAgents = agents.slice(0, 8)
|
|
20
|
+
|
|
21
|
+
return (
|
|
22
|
+
<BentoCard
|
|
23
|
+
size="1x1"
|
|
24
|
+
title="Agents"
|
|
25
|
+
icon={Bot}
|
|
26
|
+
count={agents.length}
|
|
27
|
+
className={className}
|
|
28
|
+
>
|
|
29
|
+
{agents.length === 0 ? (
|
|
30
|
+
<EmptyState
|
|
31
|
+
icon={Bot}
|
|
32
|
+
title="No agents"
|
|
33
|
+
description="Run /p:sync to generate"
|
|
34
|
+
compact
|
|
35
|
+
/>
|
|
36
|
+
) : (
|
|
37
|
+
<div className="flex flex-wrap gap-1.5">
|
|
38
|
+
{displayAgents.map((agent) => (
|
|
39
|
+
<Badge
|
|
40
|
+
key={agent.name}
|
|
41
|
+
variant="secondary"
|
|
42
|
+
className="text-xs px-2 py-0.5 font-mono"
|
|
43
|
+
>
|
|
44
|
+
@{agent.name}
|
|
45
|
+
</Badge>
|
|
46
|
+
))}
|
|
47
|
+
{agents.length > 8 && (
|
|
48
|
+
<Badge variant="outline" className="text-xs px-2 py-0.5">
|
|
49
|
+
+{agents.length - 8}
|
|
50
|
+
</Badge>
|
|
51
|
+
)}
|
|
52
|
+
</div>
|
|
53
|
+
)}
|
|
54
|
+
</BentoCard>
|
|
55
|
+
)
|
|
56
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import * as React from 'react'
|
|
2
|
+
import { cn } from '@/lib/utils'
|
|
3
|
+
import type { LucideIcon } from 'lucide-react'
|
|
4
|
+
|
|
5
|
+
export type BentoSize = '1x1' | '1x2' | '2x1' | '2x2' | 'full'
|
|
6
|
+
|
|
7
|
+
const sizeClasses: Record<BentoSize, string> = {
|
|
8
|
+
'1x1': 'col-span-1 row-span-1',
|
|
9
|
+
'1x2': 'col-span-1 row-span-2',
|
|
10
|
+
'2x1': 'col-span-2 row-span-1',
|
|
11
|
+
'2x2': 'col-span-2 row-span-2',
|
|
12
|
+
'full': 'col-span-full',
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface BentoCardProps {
|
|
16
|
+
size?: BentoSize
|
|
17
|
+
title?: string
|
|
18
|
+
count?: number | string
|
|
19
|
+
icon?: LucideIcon
|
|
20
|
+
accentColor?: 'default' | 'success' | 'warning' | 'destructive'
|
|
21
|
+
className?: string
|
|
22
|
+
headerClassName?: string
|
|
23
|
+
children: React.ReactNode
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const accentStyles: Record<NonNullable<BentoCardProps['accentColor']>, string> = {
|
|
27
|
+
default: '',
|
|
28
|
+
success: 'border-emerald-500/20 bg-emerald-500/5',
|
|
29
|
+
warning: 'border-amber-500/20 bg-amber-500/5',
|
|
30
|
+
destructive: 'border-destructive/20 bg-destructive/5',
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function BentoCard({
|
|
34
|
+
size = '1x1',
|
|
35
|
+
title,
|
|
36
|
+
count,
|
|
37
|
+
icon: Icon,
|
|
38
|
+
accentColor = 'default',
|
|
39
|
+
className,
|
|
40
|
+
headerClassName,
|
|
41
|
+
children,
|
|
42
|
+
}: BentoCardProps) {
|
|
43
|
+
return (
|
|
44
|
+
<div
|
|
45
|
+
className={cn(
|
|
46
|
+
'relative overflow-hidden rounded-xl border bg-card p-4 transition-all duration-200',
|
|
47
|
+
'hover:shadow-md hover:border-foreground/20',
|
|
48
|
+
sizeClasses[size],
|
|
49
|
+
accentStyles[accentColor],
|
|
50
|
+
className
|
|
51
|
+
)}
|
|
52
|
+
>
|
|
53
|
+
{(title || count !== undefined || Icon) && (
|
|
54
|
+
<div className={cn('flex items-center justify-between mb-3', headerClassName)}>
|
|
55
|
+
<div className="flex items-center gap-2">
|
|
56
|
+
{Icon && <Icon className="h-3.5 w-3.5 text-muted-foreground" />}
|
|
57
|
+
{title && (
|
|
58
|
+
<span className="text-[10px] font-bold uppercase tracking-[0.15em] text-muted-foreground">
|
|
59
|
+
{title}
|
|
60
|
+
</span>
|
|
61
|
+
)}
|
|
62
|
+
</div>
|
|
63
|
+
{count !== undefined && (
|
|
64
|
+
<span className="text-xs font-medium text-muted-foreground tabular-nums">
|
|
65
|
+
{count}
|
|
66
|
+
</span>
|
|
67
|
+
)}
|
|
68
|
+
</div>
|
|
69
|
+
)}
|
|
70
|
+
{children}
|
|
71
|
+
</div>
|
|
72
|
+
)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function BentoCardSkeleton({ size = '1x1' }: { size?: BentoSize }) {
|
|
76
|
+
return (
|
|
77
|
+
<div
|
|
78
|
+
className={cn(
|
|
79
|
+
'rounded-xl border bg-card p-4 animate-pulse',
|
|
80
|
+
sizeClasses[size]
|
|
81
|
+
)}
|
|
82
|
+
>
|
|
83
|
+
<div className="h-3 w-16 bg-muted rounded mb-3" />
|
|
84
|
+
<div className="h-6 w-24 bg-muted rounded mb-2" />
|
|
85
|
+
<div className="h-3 w-full bg-muted rounded" />
|
|
86
|
+
</div>
|
|
87
|
+
)
|
|
88
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import * as React from 'react'
|
|
2
|
+
import { cn } from '@/lib/utils'
|
|
3
|
+
|
|
4
|
+
interface BentoGridProps {
|
|
5
|
+
className?: string
|
|
6
|
+
children: React.ReactNode
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function BentoGrid({ className, children }: BentoGridProps) {
|
|
10
|
+
return (
|
|
11
|
+
<div
|
|
12
|
+
className={cn(
|
|
13
|
+
'grid gap-4',
|
|
14
|
+
'grid-cols-1 sm:grid-cols-2 lg:grid-cols-4',
|
|
15
|
+
'auto-rows-[minmax(140px,auto)]',
|
|
16
|
+
className
|
|
17
|
+
)}
|
|
18
|
+
>
|
|
19
|
+
{children}
|
|
20
|
+
</div>
|
|
21
|
+
)
|
|
22
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { cn } from '@/lib/utils'
|
|
4
|
+
import { Copy } from 'lucide-react'
|
|
5
|
+
import type { LucideIcon } from 'lucide-react'
|
|
6
|
+
|
|
7
|
+
interface EmptyStateProps {
|
|
8
|
+
icon: LucideIcon
|
|
9
|
+
title: string
|
|
10
|
+
description?: string
|
|
11
|
+
command?: string
|
|
12
|
+
className?: string
|
|
13
|
+
compact?: boolean
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function EmptyState({
|
|
17
|
+
icon: Icon,
|
|
18
|
+
title,
|
|
19
|
+
description,
|
|
20
|
+
command,
|
|
21
|
+
className,
|
|
22
|
+
compact = false,
|
|
23
|
+
}: EmptyStateProps) {
|
|
24
|
+
const handleCopy = () => {
|
|
25
|
+
if (command) {
|
|
26
|
+
navigator.clipboard.writeText(command)
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (compact) {
|
|
31
|
+
return (
|
|
32
|
+
<div className={cn('flex items-center gap-2 text-muted-foreground', className)}>
|
|
33
|
+
<Icon className="h-4 w-4" />
|
|
34
|
+
<span className="text-sm">{title}</span>
|
|
35
|
+
{command && (
|
|
36
|
+
<button
|
|
37
|
+
onClick={handleCopy}
|
|
38
|
+
className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded bg-muted text-xs font-mono hover:bg-muted/80"
|
|
39
|
+
>
|
|
40
|
+
{command}
|
|
41
|
+
</button>
|
|
42
|
+
)}
|
|
43
|
+
</div>
|
|
44
|
+
)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return (
|
|
48
|
+
<div className={cn('flex flex-col items-center justify-center text-center py-4', className)}>
|
|
49
|
+
<div className="w-10 h-10 rounded-full bg-muted flex items-center justify-center mb-3">
|
|
50
|
+
<Icon className="h-5 w-5 text-muted-foreground" />
|
|
51
|
+
</div>
|
|
52
|
+
<p className="text-sm font-medium text-muted-foreground">{title}</p>
|
|
53
|
+
{description && (
|
|
54
|
+
<p className="text-xs text-muted-foreground/70 mt-1">{description}</p>
|
|
55
|
+
)}
|
|
56
|
+
{command && (
|
|
57
|
+
<button
|
|
58
|
+
onClick={handleCopy}
|
|
59
|
+
className="mt-2 inline-flex items-center gap-1.5 px-2 py-1 rounded-md bg-muted text-xs font-mono text-muted-foreground hover:bg-muted/80 hover:text-foreground transition-colors group"
|
|
60
|
+
>
|
|
61
|
+
{command}
|
|
62
|
+
<Copy className="h-3 w-3 opacity-0 group-hover:opacity-100 transition-opacity" />
|
|
63
|
+
</button>
|
|
64
|
+
)}
|
|
65
|
+
</div>
|
|
66
|
+
)
|
|
67
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState, useMemo } from 'react'
|
|
4
|
+
import Link from 'next/link'
|
|
5
|
+
import { cn } from '@/lib/utils'
|
|
6
|
+
import { ArrowLeft, TrendingUp, TrendingDown } from 'lucide-react'
|
|
7
|
+
import { ProgressRing } from './ProgressRing'
|
|
8
|
+
import { SparklineChart } from './SparklineChart'
|
|
9
|
+
import type { TimelineEvent } from '@/lib/parse-prjct-files'
|
|
10
|
+
|
|
11
|
+
interface HeroSectionProps {
|
|
12
|
+
projectId: string
|
|
13
|
+
projectName: string
|
|
14
|
+
tasksCompleted: number
|
|
15
|
+
healthScore: number
|
|
16
|
+
velocity: number
|
|
17
|
+
velocityChange: number
|
|
18
|
+
insightMessage: string
|
|
19
|
+
timeline?: TimelineEvent[]
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Animated count-up hook
|
|
23
|
+
function useCountUp(target: number, duration: number = 800) {
|
|
24
|
+
const [count, setCount] = useState(0)
|
|
25
|
+
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
if (target === 0) {
|
|
28
|
+
setCount(0)
|
|
29
|
+
return
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
let start = 0
|
|
33
|
+
const startTime = performance.now()
|
|
34
|
+
|
|
35
|
+
const animate = (currentTime: number) => {
|
|
36
|
+
const elapsed = currentTime - startTime
|
|
37
|
+
const progress = Math.min(elapsed / duration, 1)
|
|
38
|
+
|
|
39
|
+
// Easing function (ease-out cubic)
|
|
40
|
+
const easeOut = 1 - Math.pow(1 - progress, 3)
|
|
41
|
+
const current = Math.floor(easeOut * target)
|
|
42
|
+
|
|
43
|
+
setCount(current)
|
|
44
|
+
|
|
45
|
+
if (progress < 1) {
|
|
46
|
+
requestAnimationFrame(animate)
|
|
47
|
+
} else {
|
|
48
|
+
setCount(target)
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
requestAnimationFrame(animate)
|
|
53
|
+
}, [target, duration])
|
|
54
|
+
|
|
55
|
+
return count
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Calculate 7-day activity data from timeline
|
|
59
|
+
function getWeeklyActivityData(timeline: TimelineEvent[]): number[] {
|
|
60
|
+
const today = new Date()
|
|
61
|
+
const counts: number[] = []
|
|
62
|
+
|
|
63
|
+
for (let i = 6; i >= 0; i--) {
|
|
64
|
+
const date = new Date(today)
|
|
65
|
+
date.setDate(date.getDate() - i)
|
|
66
|
+
const dateStr = date.toISOString().split('T')[0]
|
|
67
|
+
|
|
68
|
+
const count = timeline.filter(e => {
|
|
69
|
+
if (!e.ts) return false
|
|
70
|
+
return e.ts.startsWith(dateStr)
|
|
71
|
+
}).length
|
|
72
|
+
|
|
73
|
+
counts.push(count)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return counts
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function HeroSection({
|
|
80
|
+
projectId,
|
|
81
|
+
projectName,
|
|
82
|
+
tasksCompleted,
|
|
83
|
+
healthScore,
|
|
84
|
+
velocity,
|
|
85
|
+
velocityChange,
|
|
86
|
+
insightMessage,
|
|
87
|
+
timeline = [],
|
|
88
|
+
}: HeroSectionProps) {
|
|
89
|
+
const animatedCount = useCountUp(tasksCompleted)
|
|
90
|
+
const weeklyData = useMemo(() => getWeeklyActivityData(timeline), [timeline])
|
|
91
|
+
|
|
92
|
+
const healthColor = healthScore >= 70 ? 'success' : healthScore >= 40 ? 'warning' : 'destructive'
|
|
93
|
+
|
|
94
|
+
return (
|
|
95
|
+
<div className="relative mb-8">
|
|
96
|
+
{/* Background gradient based on health */}
|
|
97
|
+
<div
|
|
98
|
+
className={cn(
|
|
99
|
+
'absolute inset-0 -m-8 rounded-2xl opacity-30 blur-3xl transition-colors duration-1000',
|
|
100
|
+
healthScore >= 70 && 'bg-gradient-to-br from-emerald-500/20 to-transparent',
|
|
101
|
+
healthScore >= 40 && healthScore < 70 && 'bg-gradient-to-br from-amber-500/20 to-transparent',
|
|
102
|
+
healthScore < 40 && 'bg-gradient-to-br from-destructive/20 to-transparent'
|
|
103
|
+
)}
|
|
104
|
+
/>
|
|
105
|
+
|
|
106
|
+
<div className="relative flex items-start justify-between">
|
|
107
|
+
{/* Left: Health ring + Main metric */}
|
|
108
|
+
<div className="flex items-start gap-6">
|
|
109
|
+
<ProgressRing
|
|
110
|
+
value={healthScore}
|
|
111
|
+
size="xl"
|
|
112
|
+
accentColor={healthColor}
|
|
113
|
+
/>
|
|
114
|
+
|
|
115
|
+
<div>
|
|
116
|
+
{/* Tasks completed - big number */}
|
|
117
|
+
<div className="flex items-baseline gap-3">
|
|
118
|
+
<span className="text-7xl font-bold tracking-tighter tabular-nums">
|
|
119
|
+
{animatedCount}
|
|
120
|
+
</span>
|
|
121
|
+
<span className="text-lg text-muted-foreground">tasks completed</span>
|
|
122
|
+
</div>
|
|
123
|
+
|
|
124
|
+
{/* Velocity trend */}
|
|
125
|
+
{velocityChange !== 0 && (
|
|
126
|
+
<div className="flex items-center gap-2 mt-2">
|
|
127
|
+
<span
|
|
128
|
+
className={cn(
|
|
129
|
+
'inline-flex items-center gap-1 text-sm font-medium px-2 py-0.5 rounded-md',
|
|
130
|
+
velocityChange >= 0
|
|
131
|
+
? 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400'
|
|
132
|
+
: 'bg-muted text-muted-foreground'
|
|
133
|
+
)}
|
|
134
|
+
>
|
|
135
|
+
{velocityChange >= 0 ? (
|
|
136
|
+
<TrendingUp className="h-3.5 w-3.5" />
|
|
137
|
+
) : (
|
|
138
|
+
<TrendingDown className="h-3.5 w-3.5" />
|
|
139
|
+
)}
|
|
140
|
+
{velocityChange >= 0 ? '+' : ''}{velocityChange}%
|
|
141
|
+
</span>
|
|
142
|
+
<span className="text-sm text-muted-foreground">vs last week</span>
|
|
143
|
+
</div>
|
|
144
|
+
)}
|
|
145
|
+
|
|
146
|
+
{/* Insight message */}
|
|
147
|
+
<p className="text-muted-foreground mt-3 max-w-md">{insightMessage}</p>
|
|
148
|
+
</div>
|
|
149
|
+
</div>
|
|
150
|
+
|
|
151
|
+
{/* Right: Sparkline + Navigation */}
|
|
152
|
+
<div className="flex flex-col items-end gap-4">
|
|
153
|
+
<Link
|
|
154
|
+
href={`/project/${projectId}`}
|
|
155
|
+
className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
|
156
|
+
>
|
|
157
|
+
<ArrowLeft className="w-4 h-4" />
|
|
158
|
+
{projectName}
|
|
159
|
+
</Link>
|
|
160
|
+
|
|
161
|
+
{/* 7-day sparkline */}
|
|
162
|
+
<div className="w-32">
|
|
163
|
+
<p className="text-[10px] uppercase tracking-wider text-muted-foreground mb-1 text-right">
|
|
164
|
+
7-day activity
|
|
165
|
+
</p>
|
|
166
|
+
<SparklineChart data={weeklyData} height={40} />
|
|
167
|
+
</div>
|
|
168
|
+
</div>
|
|
169
|
+
</div>
|
|
170
|
+
</div>
|
|
171
|
+
)
|
|
172
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { BentoCard } from './BentoCard'
|
|
4
|
+
import { EmptyState } from './EmptyState'
|
|
5
|
+
import { Lightbulb } from 'lucide-react'
|
|
6
|
+
import { cn } from '@/lib/utils'
|
|
7
|
+
|
|
8
|
+
interface Idea {
|
|
9
|
+
title: string
|
|
10
|
+
impact?: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface IdeasCardProps {
|
|
14
|
+
ideas: Idea[]
|
|
15
|
+
className?: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function IdeasCard({ ideas, className }: IdeasCardProps) {
|
|
19
|
+
const displayIdeas = ideas.slice(0, 4)
|
|
20
|
+
const highImpactCount = ideas.filter(i => i.impact === 'HIGH').length
|
|
21
|
+
|
|
22
|
+
return (
|
|
23
|
+
<BentoCard
|
|
24
|
+
size="1x1"
|
|
25
|
+
title="Ideas"
|
|
26
|
+
icon={Lightbulb}
|
|
27
|
+
count={ideas.length}
|
|
28
|
+
className={className}
|
|
29
|
+
>
|
|
30
|
+
{ideas.length === 0 ? (
|
|
31
|
+
<EmptyState
|
|
32
|
+
icon={Lightbulb}
|
|
33
|
+
title="No ideas yet"
|
|
34
|
+
command="/p:idea"
|
|
35
|
+
compact
|
|
36
|
+
/>
|
|
37
|
+
) : (
|
|
38
|
+
<div className="space-y-1.5">
|
|
39
|
+
{displayIdeas.map((idea, i) => (
|
|
40
|
+
<div key={i} className="flex items-start gap-2">
|
|
41
|
+
<Lightbulb
|
|
42
|
+
className={cn(
|
|
43
|
+
'h-3 w-3 mt-0.5 shrink-0',
|
|
44
|
+
idea.impact === 'HIGH' ? 'text-amber-500' : 'text-muted-foreground'
|
|
45
|
+
)}
|
|
46
|
+
/>
|
|
47
|
+
<p className="text-sm truncate">{idea.title}</p>
|
|
48
|
+
</div>
|
|
49
|
+
))}
|
|
50
|
+
{highImpactCount > 0 && (
|
|
51
|
+
<p className="text-[10px] text-amber-600 dark:text-amber-400 mt-2 pl-5 font-medium">
|
|
52
|
+
{highImpactCount} high impact
|
|
53
|
+
</p>
|
|
54
|
+
)}
|
|
55
|
+
</div>
|
|
56
|
+
)}
|
|
57
|
+
</BentoCard>
|
|
58
|
+
)
|
|
59
|
+
}
|