@prmichaelsen/acp-visualizer 0.2.1 → 0.5.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/package.json +12 -2
- package/src/components/DependencyGraph.tsx +196 -0
- package/src/components/FilterBar.tsx +1 -0
- package/src/components/GitHubInput.tsx +65 -0
- package/src/components/MilestoneGantt.tsx +150 -0
- package/src/components/MilestoneKanban.tsx +15 -2
- package/src/components/ProjectSelector.tsx +66 -0
- package/src/components/Sidebar.tsx +24 -2
- package/src/components/StatusBadge.tsx +2 -0
- package/src/components/StatusDot.tsx +1 -0
- package/src/components/ViewToggle.tsx +3 -1
- package/src/contexts/ProgressContext.tsx +22 -0
- package/src/lib/types.ts +1 -1
- package/src/lib/yaml-loader-real.spec.ts +54 -1
- package/src/lib/yaml-loader.ts +134 -5
- package/src/routes/__root.tsx +136 -22
- package/src/routes/activity.tsx +2 -2
- package/src/routes/index.tsx +23 -6
- package/src/routes/milestones.tsx +15 -3
- package/src/routes/search.tsx +2 -1
- package/src/routes/tasks.tsx +2 -1
- package/src/server.ts +13 -0
- package/src/services/github.service.ts +51 -0
- package/src/services/progress-database.service.ts +17 -39
- package/src/services/projects.service.ts +69 -0
- package/vite.config.ts +7 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@prmichaelsen/acp-visualizer",
|
|
3
|
-
"version": "0.2
|
|
3
|
+
"version": "0.5.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Browser-based dashboard for visualizing ACP progress.yaml data",
|
|
6
6
|
"bin": {
|
|
@@ -16,20 +16,30 @@
|
|
|
16
16
|
"dev": "vite dev --port 3400 --host",
|
|
17
17
|
"build": "vite build",
|
|
18
18
|
"serve": "vite preview",
|
|
19
|
+
"deploy": "export $(cat .env.cloudflare.local | xargs) && VITE_HOSTED=true npm run build && wrangler deploy",
|
|
20
|
+
"tail": "export $(cat .env.cloudflare.local | xargs) && wrangler tail",
|
|
19
21
|
"test": "vitest",
|
|
20
22
|
"test:run": "vitest run",
|
|
21
|
-
"test:coverage": "vitest run --coverage"
|
|
23
|
+
"test:coverage": "vitest run --coverage",
|
|
24
|
+
"cf-secrets:e1": "tsx scripts/upload-cloudflare-secrets.ts -- --env .env.e1.local",
|
|
25
|
+
"cf-secrets:prod": "tsx scripts/upload-cloudflare-secrets.ts -- --env .env.prod.local",
|
|
26
|
+
"typecheck": "tsc --noEmit",
|
|
27
|
+
"cf-typegen": "wrangler types"
|
|
22
28
|
},
|
|
23
29
|
"dependencies": {
|
|
30
|
+
"@cloudflare/vite-plugin": "^1.28.0",
|
|
31
|
+
"@cloudflare/workers-types": "^4.20260313.1",
|
|
24
32
|
"@tailwindcss/vite": "^4.0.6",
|
|
25
33
|
"@tanstack/react-router": "^1.132.0",
|
|
26
34
|
"@tanstack/react-start": "^1.132.0",
|
|
27
35
|
"@tanstack/react-table": "^8.21.3",
|
|
28
36
|
"@tanstack/router-plugin": "^1.132.0",
|
|
37
|
+
"@types/dagre": "^0.7.54",
|
|
29
38
|
"@types/js-yaml": "^4.0.9",
|
|
30
39
|
"@types/react": "^19.0.8",
|
|
31
40
|
"@types/react-dom": "^19.0.3",
|
|
32
41
|
"@vitejs/plugin-react": "^5.0.4",
|
|
42
|
+
"dagre": "^0.8.5",
|
|
33
43
|
"fuse.js": "^7.1.0",
|
|
34
44
|
"js-yaml": "^4.1.0",
|
|
35
45
|
"lucide-react": "^0.544.0",
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { useMemo } from 'react'
|
|
2
|
+
import dagre from 'dagre'
|
|
3
|
+
import type { ProgressData, Task, Status } from '../lib/types'
|
|
4
|
+
|
|
5
|
+
interface DependencyGraphProps {
|
|
6
|
+
data: ProgressData
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
interface GraphNode {
|
|
10
|
+
id: string
|
|
11
|
+
label: string
|
|
12
|
+
status: Status
|
|
13
|
+
milestone: string
|
|
14
|
+
x: number
|
|
15
|
+
y: number
|
|
16
|
+
width: number
|
|
17
|
+
height: number
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface GraphEdge {
|
|
21
|
+
from: string
|
|
22
|
+
to: string
|
|
23
|
+
points: Array<{ x: number; y: number }>
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const NODE_WIDTH = 180
|
|
27
|
+
const NODE_HEIGHT = 44
|
|
28
|
+
|
|
29
|
+
const statusColors: Record<Status, { bg: string; border: string; text: string }> = {
|
|
30
|
+
completed: { bg: '#22c55e15', border: '#22c55e40', text: '#4ade80' },
|
|
31
|
+
in_progress: { bg: '#3b82f615', border: '#3b82f640', text: '#60a5fa' },
|
|
32
|
+
not_started: { bg: '#6b728015', border: '#6b728040', text: '#9ca3af' },
|
|
33
|
+
wont_do: { bg: '#eab30815', border: '#eab30840', text: '#fbbf24' },
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function buildGraph(data: ProgressData): { nodes: GraphNode[]; edges: GraphEdge[]; width: number; height: number } {
|
|
37
|
+
const g = new dagre.graphlib.Graph()
|
|
38
|
+
g.setGraph({ rankdir: 'TB', nodesep: 30, ranksep: 60, marginx: 20, marginy: 20 })
|
|
39
|
+
g.setDefaultEdgeLabel(() => ({}))
|
|
40
|
+
|
|
41
|
+
// Collect all tasks with their milestone context
|
|
42
|
+
const allTasks: Array<Task & { milestoneName: string }> = []
|
|
43
|
+
for (const milestone of data.milestones) {
|
|
44
|
+
const tasks = data.tasks[milestone.id] || []
|
|
45
|
+
for (const task of tasks) {
|
|
46
|
+
allTasks.push({ ...task, milestoneName: milestone.name })
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (allTasks.length === 0) {
|
|
51
|
+
return { nodes: [], edges: [], width: 0, height: 0 }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Add nodes
|
|
55
|
+
for (const task of allTasks) {
|
|
56
|
+
g.setNode(String(task.id), { width: NODE_WIDTH, height: NODE_HEIGHT })
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Infer edges from task ordering within milestones (sequential dependency)
|
|
60
|
+
// Also check task notes/extra for explicit dependency references
|
|
61
|
+
for (const milestone of data.milestones) {
|
|
62
|
+
const tasks = data.tasks[milestone.id] || []
|
|
63
|
+
for (let i = 1; i < tasks.length; i++) {
|
|
64
|
+
g.setEdge(String(tasks[i - 1].id), String(tasks[i].id))
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Add cross-milestone edges: last task of milestone N → first task of milestone N+1
|
|
69
|
+
for (let i = 1; i < data.milestones.length; i++) {
|
|
70
|
+
const prevTasks = data.tasks[data.milestones[i - 1].id] || []
|
|
71
|
+
const currTasks = data.tasks[data.milestones[i].id] || []
|
|
72
|
+
if (prevTasks.length > 0 && currTasks.length > 0) {
|
|
73
|
+
g.setEdge(String(prevTasks[prevTasks.length - 1].id), String(currTasks[0].id))
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
dagre.layout(g)
|
|
78
|
+
|
|
79
|
+
const nodes: GraphNode[] = allTasks.map((task) => {
|
|
80
|
+
const node = g.node(String(task.id))
|
|
81
|
+
return {
|
|
82
|
+
id: String(task.id),
|
|
83
|
+
label: task.name,
|
|
84
|
+
status: task.status,
|
|
85
|
+
milestone: task.milestoneName,
|
|
86
|
+
x: node.x,
|
|
87
|
+
y: node.y,
|
|
88
|
+
width: NODE_WIDTH,
|
|
89
|
+
height: NODE_HEIGHT,
|
|
90
|
+
}
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
const edges: GraphEdge[] = g.edges().map((e) => {
|
|
94
|
+
const edge = g.edge(e)
|
|
95
|
+
return {
|
|
96
|
+
from: e.v,
|
|
97
|
+
to: e.w,
|
|
98
|
+
points: edge.points || [],
|
|
99
|
+
}
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
const graphInfo = g.graph()
|
|
103
|
+
return {
|
|
104
|
+
nodes,
|
|
105
|
+
edges,
|
|
106
|
+
width: (graphInfo.width || 800) + 40,
|
|
107
|
+
height: (graphInfo.height || 400) + 40,
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function edgePathD(points: Array<{ x: number; y: number }>): string {
|
|
112
|
+
if (points.length < 2) return ''
|
|
113
|
+
const [start, ...rest] = points
|
|
114
|
+
return `M ${start.x} ${start.y} ${rest.map((p) => `L ${p.x} ${p.y}`).join(' ')}`
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function DependencyGraph({ data }: DependencyGraphProps) {
|
|
118
|
+
const { nodes, edges, width, height } = useMemo(() => buildGraph(data), [data])
|
|
119
|
+
|
|
120
|
+
if (nodes.length === 0) {
|
|
121
|
+
return (
|
|
122
|
+
<div className="text-center py-8">
|
|
123
|
+
<p className="text-gray-600 text-sm">No tasks to display in dependency graph</p>
|
|
124
|
+
</div>
|
|
125
|
+
)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return (
|
|
129
|
+
<div className="border border-gray-800 rounded-lg overflow-auto bg-gray-950/50">
|
|
130
|
+
<svg width={width} height={height} className="min-w-full">
|
|
131
|
+
<defs>
|
|
132
|
+
<marker
|
|
133
|
+
id="arrowhead"
|
|
134
|
+
viewBox="0 0 10 7"
|
|
135
|
+
refX="10"
|
|
136
|
+
refY="3.5"
|
|
137
|
+
markerWidth="8"
|
|
138
|
+
markerHeight="6"
|
|
139
|
+
orient="auto"
|
|
140
|
+
>
|
|
141
|
+
<polygon points="0 0, 10 3.5, 0 7" fill="#4b5563" />
|
|
142
|
+
</marker>
|
|
143
|
+
</defs>
|
|
144
|
+
|
|
145
|
+
{/* Edges */}
|
|
146
|
+
{edges.map((edge, i) => (
|
|
147
|
+
<path
|
|
148
|
+
key={i}
|
|
149
|
+
d={edgePathD(edge.points)}
|
|
150
|
+
fill="none"
|
|
151
|
+
stroke="#374151"
|
|
152
|
+
strokeWidth={1.5}
|
|
153
|
+
markerEnd="url(#arrowhead)"
|
|
154
|
+
/>
|
|
155
|
+
))}
|
|
156
|
+
|
|
157
|
+
{/* Nodes */}
|
|
158
|
+
{nodes.map((node) => {
|
|
159
|
+
const colors = statusColors[node.status]
|
|
160
|
+
return (
|
|
161
|
+
<g key={node.id} transform={`translate(${node.x - node.width / 2}, ${node.y - node.height / 2})`}>
|
|
162
|
+
<rect
|
|
163
|
+
width={node.width}
|
|
164
|
+
height={node.height}
|
|
165
|
+
rx={6}
|
|
166
|
+
ry={6}
|
|
167
|
+
fill={colors.bg}
|
|
168
|
+
stroke={colors.border}
|
|
169
|
+
strokeWidth={1.5}
|
|
170
|
+
/>
|
|
171
|
+
<text
|
|
172
|
+
x={10}
|
|
173
|
+
y={18}
|
|
174
|
+
fill={colors.text}
|
|
175
|
+
fontSize={11}
|
|
176
|
+
fontFamily="Inter, system-ui, sans-serif"
|
|
177
|
+
fontWeight={500}
|
|
178
|
+
>
|
|
179
|
+
{node.label.length > 22 ? node.label.slice(0, 22) + '...' : node.label}
|
|
180
|
+
</text>
|
|
181
|
+
<text
|
|
182
|
+
x={10}
|
|
183
|
+
y={34}
|
|
184
|
+
fill="#6b7280"
|
|
185
|
+
fontSize={9}
|
|
186
|
+
fontFamily="Inter, system-ui, sans-serif"
|
|
187
|
+
>
|
|
188
|
+
{node.milestone}
|
|
189
|
+
</text>
|
|
190
|
+
</g>
|
|
191
|
+
)
|
|
192
|
+
})}
|
|
193
|
+
</svg>
|
|
194
|
+
</div>
|
|
195
|
+
)
|
|
196
|
+
}
|
|
@@ -5,6 +5,7 @@ const statusOptions: Array<{ value: Status | 'all'; label: string }> = [
|
|
|
5
5
|
{ value: 'in_progress', label: 'In Progress' },
|
|
6
6
|
{ value: 'not_started', label: 'Not Started' },
|
|
7
7
|
{ value: 'completed', label: 'Completed' },
|
|
8
|
+
{ value: 'wont_do', label: "Won't Do" },
|
|
8
9
|
]
|
|
9
10
|
|
|
10
11
|
interface FilterBarProps {
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { useState } from 'react'
|
|
2
|
+
import { Github, Loader2 } from 'lucide-react'
|
|
3
|
+
|
|
4
|
+
interface GitHubInputProps {
|
|
5
|
+
onLoad: (owner: string, repo: string) => Promise<void>
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function GitHubInput({ onLoad }: GitHubInputProps) {
|
|
9
|
+
const [value, setValue] = useState('')
|
|
10
|
+
const [loading, setLoading] = useState(false)
|
|
11
|
+
const [error, setError] = useState<string | null>(null)
|
|
12
|
+
|
|
13
|
+
const handleSubmit = async () => {
|
|
14
|
+
setError(null)
|
|
15
|
+
// Parse owner/repo from various formats
|
|
16
|
+
const cleaned = value.trim()
|
|
17
|
+
.replace(/^https?:\/\/github\.com\//, '')
|
|
18
|
+
.replace(/\.git$/, '')
|
|
19
|
+
.replace(/\/$/, '')
|
|
20
|
+
|
|
21
|
+
const parts = cleaned.split('/')
|
|
22
|
+
if (parts.length < 2) {
|
|
23
|
+
setError('Enter owner/repo (e.g. user/project)')
|
|
24
|
+
return
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
setLoading(true)
|
|
28
|
+
try {
|
|
29
|
+
await onLoad(parts[0], parts[1])
|
|
30
|
+
setValue('')
|
|
31
|
+
} catch {
|
|
32
|
+
setError('Failed to load')
|
|
33
|
+
} finally {
|
|
34
|
+
setLoading(false)
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<div>
|
|
40
|
+
<div className="flex gap-1">
|
|
41
|
+
<div className="relative flex-1">
|
|
42
|
+
<Github className="absolute left-2 top-[7px] w-3.5 h-3.5 text-gray-500" />
|
|
43
|
+
<input
|
|
44
|
+
type="text"
|
|
45
|
+
value={value}
|
|
46
|
+
onChange={(e) => { setValue(e.target.value); setError(null) }}
|
|
47
|
+
onKeyDown={(e) => e.key === 'Enter' && handleSubmit()}
|
|
48
|
+
placeholder="owner/repo"
|
|
49
|
+
className="w-full bg-gray-900 border border-gray-800 rounded-md pl-7 pr-2 py-1.5 text-xs text-gray-200 placeholder-gray-600 focus:outline-none focus:border-gray-600 transition-colors"
|
|
50
|
+
/>
|
|
51
|
+
</div>
|
|
52
|
+
<button
|
|
53
|
+
onClick={handleSubmit}
|
|
54
|
+
disabled={loading || !value.trim()}
|
|
55
|
+
className="px-2 py-1.5 bg-gray-800 border border-gray-700 rounded-md text-xs text-gray-300 hover:bg-gray-700 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
|
56
|
+
>
|
|
57
|
+
{loading ? <Loader2 className="w-3 h-3 animate-spin" /> : 'Go'}
|
|
58
|
+
</button>
|
|
59
|
+
</div>
|
|
60
|
+
{error && (
|
|
61
|
+
<p className="text-xs text-red-400 mt-1">{error}</p>
|
|
62
|
+
)}
|
|
63
|
+
</div>
|
|
64
|
+
)
|
|
65
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { useMemo } from 'react'
|
|
2
|
+
import { StatusBadge } from './StatusBadge'
|
|
3
|
+
import type { Milestone, Task } from '../lib/types'
|
|
4
|
+
|
|
5
|
+
interface MilestoneGanttProps {
|
|
6
|
+
milestones: Milestone[]
|
|
7
|
+
tasks: Record<string, Task[]>
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function parseDate(d: string | null): Date | null {
|
|
11
|
+
if (!d) return null
|
|
12
|
+
const parsed = new Date(d)
|
|
13
|
+
return isNaN(parsed.getTime()) ? null : parsed
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function formatDate(d: Date): string {
|
|
17
|
+
return d.toISOString().slice(0, 10)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function daysBetween(a: Date, b: Date): number {
|
|
21
|
+
return Math.max(1, Math.round((b.getTime() - a.getTime()) / (1000 * 60 * 60 * 24)))
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function MilestoneGantt({ milestones, tasks }: MilestoneGanttProps) {
|
|
25
|
+
const { bars, minDate, maxDate, totalDays } = useMemo(() => {
|
|
26
|
+
const now = new Date()
|
|
27
|
+
|
|
28
|
+
// Build bars with start/end dates
|
|
29
|
+
const bars = milestones.map((m) => {
|
|
30
|
+
const start = parseDate(m.started)
|
|
31
|
+
const end = parseDate(m.completed) || (m.status === 'in_progress' ? now : null)
|
|
32
|
+
|
|
33
|
+
// Estimate end from start + estimated_weeks if no end date
|
|
34
|
+
const weeks = parseFloat(m.estimated_weeks) || 0
|
|
35
|
+
const estimatedEnd = start && weeks > 0
|
|
36
|
+
? new Date(start.getTime() + weeks * 7 * 24 * 60 * 60 * 1000)
|
|
37
|
+
: null
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
milestone: m,
|
|
41
|
+
tasks: tasks[m.id] || [],
|
|
42
|
+
start,
|
|
43
|
+
end: end || estimatedEnd,
|
|
44
|
+
}
|
|
45
|
+
}).filter((b) => b.start != null) // Only show milestones with dates
|
|
46
|
+
|
|
47
|
+
if (bars.length === 0) {
|
|
48
|
+
return { bars: [], minDate: now, maxDate: now, totalDays: 1 }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const allDates = bars.flatMap((b) => [b.start!, b.end!].filter(Boolean))
|
|
52
|
+
const minDate = new Date(Math.min(...allDates.map((d) => d.getTime())))
|
|
53
|
+
const maxDate = new Date(Math.max(...allDates.map((d) => d.getTime())))
|
|
54
|
+
|
|
55
|
+
// Add some padding
|
|
56
|
+
minDate.setDate(minDate.getDate() - 2)
|
|
57
|
+
maxDate.setDate(maxDate.getDate() + 2)
|
|
58
|
+
|
|
59
|
+
const totalDays = daysBetween(minDate, maxDate)
|
|
60
|
+
|
|
61
|
+
return { bars, minDate, maxDate, totalDays }
|
|
62
|
+
}, [milestones, tasks])
|
|
63
|
+
|
|
64
|
+
if (bars.length === 0) {
|
|
65
|
+
return (
|
|
66
|
+
<div className="text-center py-8">
|
|
67
|
+
<p className="text-gray-600 text-sm">No milestones with dates for timeline view</p>
|
|
68
|
+
<p className="text-gray-700 text-xs mt-1">
|
|
69
|
+
Add `started` dates to milestones in progress.yaml
|
|
70
|
+
</p>
|
|
71
|
+
</div>
|
|
72
|
+
)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Generate month labels
|
|
76
|
+
const monthLabels: Array<{ label: string; left: number }> = []
|
|
77
|
+
const cursor = new Date(minDate)
|
|
78
|
+
cursor.setDate(1)
|
|
79
|
+
while (cursor <= maxDate) {
|
|
80
|
+
const daysFromStart = daysBetween(minDate, cursor)
|
|
81
|
+
const left = (daysFromStart / totalDays) * 100
|
|
82
|
+
if (left >= 0 && left <= 100) {
|
|
83
|
+
monthLabels.push({
|
|
84
|
+
label: cursor.toLocaleDateString('en-US', { month: 'short', year: '2-digit' }),
|
|
85
|
+
left,
|
|
86
|
+
})
|
|
87
|
+
}
|
|
88
|
+
cursor.setMonth(cursor.getMonth() + 1)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return (
|
|
92
|
+
<div className="border border-gray-800 rounded-lg overflow-hidden">
|
|
93
|
+
{/* Timeline header */}
|
|
94
|
+
<div className="relative h-8 bg-gray-900/50 border-b border-gray-800">
|
|
95
|
+
{monthLabels.map((m, i) => (
|
|
96
|
+
<div
|
|
97
|
+
key={i}
|
|
98
|
+
className="absolute top-0 h-full border-l border-gray-800 flex items-center"
|
|
99
|
+
style={{ left: `${Math.max(0, m.left)}%` }}
|
|
100
|
+
>
|
|
101
|
+
<span className="text-[10px] text-gray-500 pl-1.5">{m.label}</span>
|
|
102
|
+
</div>
|
|
103
|
+
))}
|
|
104
|
+
</div>
|
|
105
|
+
|
|
106
|
+
{/* Bars */}
|
|
107
|
+
<div className="divide-y divide-gray-800/50">
|
|
108
|
+
{bars.map(({ milestone, start, end }) => {
|
|
109
|
+
const barStart = start ? (daysBetween(minDate, start) / totalDays) * 100 : 0
|
|
110
|
+
const barEnd = end ? (daysBetween(minDate, end) / totalDays) * 100 : barStart + 5
|
|
111
|
+
const barWidth = Math.max(2, barEnd - barStart)
|
|
112
|
+
|
|
113
|
+
const barColor =
|
|
114
|
+
milestone.status === 'completed'
|
|
115
|
+
? 'bg-green-500/40 border-green-500/60'
|
|
116
|
+
: milestone.status === 'in_progress'
|
|
117
|
+
? 'bg-blue-500/40 border-blue-500/60'
|
|
118
|
+
: 'bg-gray-500/30 border-gray-500/40'
|
|
119
|
+
|
|
120
|
+
return (
|
|
121
|
+
<div key={milestone.id} className="flex items-center h-12 px-3 hover:bg-gray-800/20">
|
|
122
|
+
{/* Label */}
|
|
123
|
+
<div className="w-48 shrink-0 flex items-center gap-2">
|
|
124
|
+
<span className="text-xs text-gray-300 truncate">{milestone.name}</span>
|
|
125
|
+
</div>
|
|
126
|
+
{/* Bar area */}
|
|
127
|
+
<div className="flex-1 relative h-6">
|
|
128
|
+
<div
|
|
129
|
+
className={`absolute top-1 h-4 rounded-sm border ${barColor} transition-all`}
|
|
130
|
+
style={{ left: `${barStart}%`, width: `${barWidth}%` }}
|
|
131
|
+
title={`${milestone.name}: ${start ? formatDate(start) : '?'} → ${end ? formatDate(end) : '?'} (${milestone.progress}%)`}
|
|
132
|
+
>
|
|
133
|
+
{/* Progress fill within bar */}
|
|
134
|
+
<div
|
|
135
|
+
className={`h-full rounded-sm ${
|
|
136
|
+
milestone.status === 'completed'
|
|
137
|
+
? 'bg-green-500/60'
|
|
138
|
+
: 'bg-blue-500/60'
|
|
139
|
+
}`}
|
|
140
|
+
style={{ width: `${milestone.progress}%` }}
|
|
141
|
+
/>
|
|
142
|
+
</div>
|
|
143
|
+
</div>
|
|
144
|
+
</div>
|
|
145
|
+
)
|
|
146
|
+
})}
|
|
147
|
+
</div>
|
|
148
|
+
</div>
|
|
149
|
+
)
|
|
150
|
+
}
|
|
@@ -9,6 +9,7 @@ const columns: Array<{ status: Status; label: string; color: string }> = [
|
|
|
9
9
|
{ status: 'not_started', label: 'Not Started', color: 'border-gray-600' },
|
|
10
10
|
{ status: 'in_progress', label: 'In Progress', color: 'border-blue-500' },
|
|
11
11
|
{ status: 'completed', label: 'Completed', color: 'border-green-500' },
|
|
12
|
+
{ status: 'wont_do', label: "Won't Do", color: 'border-yellow-500' },
|
|
12
13
|
]
|
|
13
14
|
|
|
14
15
|
interface MilestoneKanbanProps {
|
|
@@ -68,9 +69,21 @@ export function MilestoneKanban({ milestones, tasks }: MilestoneKanbanProps) {
|
|
|
68
69
|
return <p className="text-gray-600 text-sm">No milestones</p>
|
|
69
70
|
}
|
|
70
71
|
|
|
72
|
+
// Only show columns that have items, except always show the core 3
|
|
73
|
+
const coreStatuses = new Set<Status>(['not_started', 'in_progress', 'completed'])
|
|
74
|
+
const activeColumns = columns.filter(
|
|
75
|
+
(col) =>
|
|
76
|
+
coreStatuses.has(col.status) ||
|
|
77
|
+
milestones.some((m) => m.status === col.status),
|
|
78
|
+
)
|
|
79
|
+
const gridCols =
|
|
80
|
+
activeColumns.length <= 3
|
|
81
|
+
? 'grid-cols-3'
|
|
82
|
+
: 'grid-cols-4'
|
|
83
|
+
|
|
71
84
|
return (
|
|
72
|
-
<div className=
|
|
73
|
-
{
|
|
85
|
+
<div className={`grid ${gridCols} gap-4 min-h-[300px]`}>
|
|
86
|
+
{activeColumns.map((col) => {
|
|
74
87
|
const items = milestones.filter((m) => m.status === col.status)
|
|
75
88
|
return (
|
|
76
89
|
<div key={col.status} className="flex flex-col">
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { ChevronDown } from 'lucide-react'
|
|
2
|
+
import { useState } from 'react'
|
|
3
|
+
import type { AcpProject } from '../services/projects.service'
|
|
4
|
+
|
|
5
|
+
interface ProjectSelectorProps {
|
|
6
|
+
projects: AcpProject[]
|
|
7
|
+
currentProject: string | null
|
|
8
|
+
onSelect: (projectId: string) => void
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function ProjectSelector({
|
|
12
|
+
projects,
|
|
13
|
+
currentProject,
|
|
14
|
+
onSelect,
|
|
15
|
+
}: ProjectSelectorProps) {
|
|
16
|
+
const [open, setOpen] = useState(false)
|
|
17
|
+
|
|
18
|
+
const available = projects.filter((p) => p.hasProgress)
|
|
19
|
+
const current = available.find((p) => p.id === currentProject)
|
|
20
|
+
|
|
21
|
+
if (available.length <= 1) return null
|
|
22
|
+
|
|
23
|
+
return (
|
|
24
|
+
<div className="relative">
|
|
25
|
+
<button
|
|
26
|
+
onClick={() => setOpen(!open)}
|
|
27
|
+
className="w-full flex items-center justify-between px-3 py-1.5 text-xs text-gray-400 bg-gray-900 border border-gray-800 rounded-md hover:text-gray-300 hover:border-gray-600 transition-colors"
|
|
28
|
+
>
|
|
29
|
+
<span className="truncate">
|
|
30
|
+
{current?.id || 'Select project'}
|
|
31
|
+
</span>
|
|
32
|
+
<ChevronDown className={`w-3 h-3 shrink-0 transition-transform ${open ? 'rotate-180' : ''}`} />
|
|
33
|
+
</button>
|
|
34
|
+
|
|
35
|
+
{open && (
|
|
36
|
+
<>
|
|
37
|
+
<div
|
|
38
|
+
className="fixed inset-0 z-40"
|
|
39
|
+
onClick={() => setOpen(false)}
|
|
40
|
+
/>
|
|
41
|
+
<div className="absolute left-0 right-0 top-full mt-1 z-50 bg-gray-900 border border-gray-700 rounded-lg shadow-xl overflow-hidden max-h-64 overflow-y-auto">
|
|
42
|
+
{available.map((p) => (
|
|
43
|
+
<button
|
|
44
|
+
key={p.id}
|
|
45
|
+
onClick={() => {
|
|
46
|
+
onSelect(p.id)
|
|
47
|
+
setOpen(false)
|
|
48
|
+
}}
|
|
49
|
+
className={`w-full text-left px-3 py-2 text-xs transition-colors ${
|
|
50
|
+
p.id === currentProject
|
|
51
|
+
? 'bg-gray-800 text-gray-100'
|
|
52
|
+
: 'text-gray-400 hover:bg-gray-800/50 hover:text-gray-200'
|
|
53
|
+
}`}
|
|
54
|
+
>
|
|
55
|
+
<div className="font-medium truncate">{p.id}</div>
|
|
56
|
+
{p.description && p.description !== 'No description' && (
|
|
57
|
+
<div className="text-gray-600 truncate mt-0.5">{p.description}</div>
|
|
58
|
+
)}
|
|
59
|
+
</button>
|
|
60
|
+
))}
|
|
61
|
+
</div>
|
|
62
|
+
</>
|
|
63
|
+
)}
|
|
64
|
+
</div>
|
|
65
|
+
)
|
|
66
|
+
}
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { Link, useRouterState } from '@tanstack/react-router'
|
|
2
2
|
import { LayoutDashboard, Flag, CheckSquare, Clock, Search } from 'lucide-react'
|
|
3
|
+
import { ProjectSelector } from './ProjectSelector'
|
|
4
|
+
import { GitHubInput } from './GitHubInput'
|
|
5
|
+
import type { AcpProject } from '../services/projects.service'
|
|
3
6
|
|
|
4
7
|
const navItems = [
|
|
5
8
|
{ to: '/' as const, icon: LayoutDashboard, label: 'Overview' },
|
|
@@ -8,7 +11,14 @@ const navItems = [
|
|
|
8
11
|
{ to: '/activity' as const, icon: Clock, label: 'Activity' },
|
|
9
12
|
]
|
|
10
13
|
|
|
11
|
-
|
|
14
|
+
interface SidebarProps {
|
|
15
|
+
projects?: AcpProject[]
|
|
16
|
+
currentProject?: string | null
|
|
17
|
+
onProjectSelect?: (projectId: string) => void
|
|
18
|
+
onGitHubLoad?: (owner: string, repo: string) => Promise<void>
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function Sidebar({ projects = [], currentProject = null, onProjectSelect, onGitHubLoad }: SidebarProps) {
|
|
12
22
|
const location = useRouterState({ select: (s) => s.location })
|
|
13
23
|
|
|
14
24
|
return (
|
|
@@ -18,6 +28,15 @@ export function Sidebar() {
|
|
|
18
28
|
ACP Visualizer
|
|
19
29
|
</span>
|
|
20
30
|
</div>
|
|
31
|
+
{projects.length > 1 && onProjectSelect && (
|
|
32
|
+
<div className="px-3 pt-3">
|
|
33
|
+
<ProjectSelector
|
|
34
|
+
projects={projects}
|
|
35
|
+
currentProject={currentProject}
|
|
36
|
+
onSelect={onProjectSelect}
|
|
37
|
+
/>
|
|
38
|
+
</div>
|
|
39
|
+
)}
|
|
21
40
|
<div className="flex-1 py-2">
|
|
22
41
|
{navItems.map((item) => {
|
|
23
42
|
const isActive =
|
|
@@ -41,7 +60,7 @@ export function Sidebar() {
|
|
|
41
60
|
)
|
|
42
61
|
})}
|
|
43
62
|
</div>
|
|
44
|
-
<div className="p-3 border-t border-gray-800">
|
|
63
|
+
<div className="p-3 border-t border-gray-800 space-y-2">
|
|
45
64
|
<Link
|
|
46
65
|
to="/search"
|
|
47
66
|
className="flex items-center gap-2 px-3 py-1.5 text-sm text-gray-500 bg-gray-900 border border-gray-800 rounded-md hover:text-gray-300 hover:border-gray-600 transition-colors"
|
|
@@ -49,6 +68,9 @@ export function Sidebar() {
|
|
|
49
68
|
<Search className="w-4 h-4" />
|
|
50
69
|
Search...
|
|
51
70
|
</Link>
|
|
71
|
+
{onGitHubLoad && (
|
|
72
|
+
<GitHubInput onLoad={onGitHubLoad} />
|
|
73
|
+
)}
|
|
52
74
|
</div>
|
|
53
75
|
</nav>
|
|
54
76
|
)
|
|
@@ -4,12 +4,14 @@ const statusStyles: Record<Status, string> = {
|
|
|
4
4
|
completed: 'bg-green-500/15 text-green-400 border-green-500/20',
|
|
5
5
|
in_progress: 'bg-blue-500/15 text-blue-400 border-blue-500/20',
|
|
6
6
|
not_started: 'bg-gray-500/15 text-gray-500 border-gray-500/20',
|
|
7
|
+
wont_do: 'bg-yellow-500/15 text-yellow-500 border-yellow-500/20',
|
|
7
8
|
}
|
|
8
9
|
|
|
9
10
|
const statusLabels: Record<Status, string> = {
|
|
10
11
|
completed: 'Completed',
|
|
11
12
|
in_progress: 'In Progress',
|
|
12
13
|
not_started: 'Not Started',
|
|
14
|
+
wont_do: "Won't Do",
|
|
13
15
|
}
|
|
14
16
|
|
|
15
17
|
export function StatusBadge({ status }: { status: Status }) {
|
|
@@ -4,6 +4,7 @@ const dotStyles: Record<Status, { symbol: string; color: string }> = {
|
|
|
4
4
|
completed: { symbol: '✓', color: 'text-green-400' },
|
|
5
5
|
in_progress: { symbol: '●', color: 'text-blue-400' },
|
|
6
6
|
not_started: { symbol: '○', color: 'text-gray-500' },
|
|
7
|
+
wont_do: { symbol: '✕', color: 'text-yellow-500' },
|
|
7
8
|
}
|
|
8
9
|
|
|
9
10
|
export function StatusDot({ status }: { status: Status }) {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export type ViewMode = 'table' | 'tree' | 'kanban'
|
|
1
|
+
export type ViewMode = 'table' | 'tree' | 'kanban' | 'gantt' | 'graph'
|
|
2
2
|
|
|
3
3
|
interface ViewToggleProps {
|
|
4
4
|
value: ViewMode
|
|
@@ -9,6 +9,8 @@ const views: Array<{ id: ViewMode; label: string }> = [
|
|
|
9
9
|
{ id: 'table', label: 'Table' },
|
|
10
10
|
{ id: 'tree', label: 'Tree' },
|
|
11
11
|
{ id: 'kanban', label: 'Kanban' },
|
|
12
|
+
{ id: 'gantt', label: 'Gantt' },
|
|
13
|
+
{ id: 'graph', label: 'Graph' },
|
|
12
14
|
]
|
|
13
15
|
|
|
14
16
|
export function ViewToggle({ value, onChange }: ViewToggleProps) {
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { createContext, useContext } from 'react'
|
|
2
|
+
import type { ProgressData } from '../lib/types'
|
|
3
|
+
|
|
4
|
+
const ProgressContext = createContext<ProgressData | null>(null)
|
|
5
|
+
|
|
6
|
+
export function ProgressProvider({
|
|
7
|
+
data,
|
|
8
|
+
children,
|
|
9
|
+
}: {
|
|
10
|
+
data: ProgressData | null
|
|
11
|
+
children: React.ReactNode
|
|
12
|
+
}) {
|
|
13
|
+
return (
|
|
14
|
+
<ProgressContext.Provider value={data}>
|
|
15
|
+
{children}
|
|
16
|
+
</ProgressContext.Provider>
|
|
17
|
+
)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function useProgressData(): ProgressData | null {
|
|
21
|
+
return useContext(ProgressContext)
|
|
22
|
+
}
|