@prmichaelsen/acp-visualizer 0.2.1 → 0.5.0

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@prmichaelsen/acp-visualizer",
3
- "version": "0.2.1",
3
+ "version": "0.5.0",
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) && 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: 'LR', 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="grid grid-cols-3 gap-4 min-h-[300px]">
73
- {columns.map((col) => {
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
- export function Sidebar() {
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
+ }