@prmichaelsen/acp-visualizer 0.1.8 → 0.2.1

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.1.8",
3
+ "version": "0.2.1",
4
4
  "type": "module",
5
5
  "description": "Browser-based dashboard for visualizing ACP progress.yaml data",
6
6
  "bin": {
@@ -35,6 +35,7 @@
35
35
  "lucide-react": "^0.544.0",
36
36
  "react": "^19.0.0",
37
37
  "react-dom": "^19.0.0",
38
+ "recharts": "^3.8.0",
38
39
  "tailwindcss": "^4.0.6",
39
40
  "typescript": "^5.7.2",
40
41
  "vite": "^7.1.7",
@@ -0,0 +1,148 @@
1
+ import {
2
+ AreaChart,
3
+ Area,
4
+ XAxis,
5
+ YAxis,
6
+ CartesianGrid,
7
+ Tooltip,
8
+ ResponsiveContainer,
9
+ } from 'recharts'
10
+ import type { ProgressData } from '../lib/types'
11
+
12
+ interface BurndownChartProps {
13
+ data: ProgressData
14
+ }
15
+
16
+ interface ChartPoint {
17
+ date: string
18
+ completed: number
19
+ total: number
20
+ remaining: number
21
+ }
22
+
23
+ function buildBurndownData(data: ProgressData): ChartPoint[] {
24
+ // Collect all task completion dates
25
+ const allTasks: Array<{ completed_date: string | null; status: string }> = []
26
+ for (const tasks of Object.values(data.tasks)) {
27
+ for (const t of tasks) {
28
+ allTasks.push({ completed_date: t.completed_date, status: t.status })
29
+ }
30
+ }
31
+
32
+ const total = allTasks.length
33
+ if (total === 0) return []
34
+
35
+ // Get sorted unique completion dates
36
+ const completionDates = allTasks
37
+ .map((t) => t.completed_date)
38
+ .filter((d): d is string => d != null)
39
+ .sort()
40
+
41
+ if (completionDates.length === 0) {
42
+ // No completed tasks — show single point
43
+ return [{ date: data.project.started || 'Start', completed: 0, total, remaining: total }]
44
+ }
45
+
46
+ // Build cumulative completion over time
47
+ const points: ChartPoint[] = []
48
+
49
+ // Start point
50
+ const startDate = data.project.started || completionDates[0]
51
+ points.push({ date: startDate, completed: 0, total, remaining: total })
52
+
53
+ // Accumulate completions by date
54
+ const byDate = new Map<string, number>()
55
+ for (const d of completionDates) {
56
+ byDate.set(d, (byDate.get(d) || 0) + 1)
57
+ }
58
+
59
+ let cumulative = 0
60
+ for (const [date, count] of [...byDate.entries()].sort()) {
61
+ cumulative += count
62
+ // Skip if same as start date (already added)
63
+ if (date === startDate && points.length === 1) {
64
+ points[0] = { date, completed: cumulative, total, remaining: total - cumulative }
65
+ continue
66
+ }
67
+ points.push({ date, completed: cumulative, total, remaining: total - cumulative })
68
+ }
69
+
70
+ return points
71
+ }
72
+
73
+ export function BurndownChart({ data }: BurndownChartProps) {
74
+ const chartData = buildBurndownData(data)
75
+
76
+ if (chartData.length === 0) {
77
+ return <p className="text-gray-600 text-sm">No task data for chart</p>
78
+ }
79
+
80
+ return (
81
+ <div className="bg-gray-900/50 border border-gray-800 rounded-xl p-5">
82
+ <h3 className="text-sm font-semibold text-gray-300 mb-4">
83
+ Task Completion Over Time
84
+ </h3>
85
+ <ResponsiveContainer width="100%" height={250}>
86
+ <AreaChart data={chartData} margin={{ top: 5, right: 20, bottom: 5, left: 0 }}>
87
+ <defs>
88
+ <linearGradient id="completedGrad" x1="0" y1="0" x2="0" y2="1">
89
+ <stop offset="5%" stopColor="#22c55e" stopOpacity={0.3} />
90
+ <stop offset="95%" stopColor="#22c55e" stopOpacity={0} />
91
+ </linearGradient>
92
+ <linearGradient id="remainingGrad" x1="0" y1="0" x2="0" y2="1">
93
+ <stop offset="5%" stopColor="#3b82f6" stopOpacity={0.2} />
94
+ <stop offset="95%" stopColor="#3b82f6" stopOpacity={0} />
95
+ </linearGradient>
96
+ </defs>
97
+ <CartesianGrid strokeDasharray="3 3" stroke="#1f2937" />
98
+ <XAxis
99
+ dataKey="date"
100
+ tick={{ fill: '#6b7280', fontSize: 11 }}
101
+ tickLine={false}
102
+ axisLine={{ stroke: '#374151' }}
103
+ />
104
+ <YAxis
105
+ tick={{ fill: '#6b7280', fontSize: 11 }}
106
+ tickLine={false}
107
+ axisLine={{ stroke: '#374151' }}
108
+ />
109
+ <Tooltip
110
+ contentStyle={{
111
+ backgroundColor: '#111827',
112
+ border: '1px solid #374151',
113
+ borderRadius: '8px',
114
+ fontSize: '12px',
115
+ color: '#e5e7eb',
116
+ }}
117
+ />
118
+ <Area
119
+ type="monotone"
120
+ dataKey="completed"
121
+ name="Completed"
122
+ stroke="#22c55e"
123
+ fill="url(#completedGrad)"
124
+ strokeWidth={2}
125
+ />
126
+ <Area
127
+ type="monotone"
128
+ dataKey="remaining"
129
+ name="Remaining"
130
+ stroke="#3b82f6"
131
+ fill="url(#remainingGrad)"
132
+ strokeWidth={2}
133
+ />
134
+ </AreaChart>
135
+ </ResponsiveContainer>
136
+ <div className="flex gap-6 mt-3 justify-center">
137
+ <div className="flex items-center gap-2 text-xs text-gray-400">
138
+ <div className="w-3 h-3 rounded-sm bg-green-500/30 border border-green-500" />
139
+ Completed
140
+ </div>
141
+ <div className="flex items-center gap-2 text-xs text-gray-400">
142
+ <div className="w-3 h-3 rounded-sm bg-blue-500/20 border border-blue-500" />
143
+ Remaining
144
+ </div>
145
+ </div>
146
+ </div>
147
+ )
148
+ }
@@ -0,0 +1,104 @@
1
+ import { StatusBadge } from './StatusBadge'
2
+ import { ProgressBar } from './ProgressBar'
3
+ import { TaskList } from './TaskList'
4
+ import type { Milestone, Task, Status } from '../lib/types'
5
+ import { useState } from 'react'
6
+ import { ChevronDown, ChevronRight } from 'lucide-react'
7
+
8
+ const columns: Array<{ status: Status; label: string; color: string }> = [
9
+ { status: 'not_started', label: 'Not Started', color: 'border-gray-600' },
10
+ { status: 'in_progress', label: 'In Progress', color: 'border-blue-500' },
11
+ { status: 'completed', label: 'Completed', color: 'border-green-500' },
12
+ ]
13
+
14
+ interface MilestoneKanbanProps {
15
+ milestones: Milestone[]
16
+ tasks: Record<string, Task[]>
17
+ }
18
+
19
+ function KanbanCard({
20
+ milestone,
21
+ tasks,
22
+ }: {
23
+ milestone: Milestone
24
+ tasks: Task[]
25
+ }) {
26
+ const [expanded, setExpanded] = useState(false)
27
+
28
+ return (
29
+ <div className="bg-gray-900/50 border border-gray-800 rounded-lg p-3 hover:border-gray-700 transition-colors">
30
+ <div className="flex items-start justify-between gap-2 mb-2">
31
+ <h4 className="text-sm font-medium leading-tight">{milestone.name}</h4>
32
+ </div>
33
+ <div className="flex items-center gap-2 mb-2">
34
+ <div className="flex-1">
35
+ <ProgressBar value={milestone.progress} size="sm" />
36
+ </div>
37
+ <span className="text-xs text-gray-500 font-mono">
38
+ {milestone.tasks_completed}/{milestone.tasks_total}
39
+ </span>
40
+ </div>
41
+ {milestone.notes && (
42
+ <p className="text-xs text-gray-500 truncate mb-2">{milestone.notes}</p>
43
+ )}
44
+ {tasks.length > 0 && (
45
+ <button
46
+ onClick={() => setExpanded(!expanded)}
47
+ className="flex items-center gap-1 text-xs text-gray-500 hover:text-gray-300 transition-colors"
48
+ >
49
+ {expanded ? (
50
+ <ChevronDown className="w-3 h-3" />
51
+ ) : (
52
+ <ChevronRight className="w-3 h-3" />
53
+ )}
54
+ {tasks.length} task{tasks.length !== 1 ? 's' : ''}
55
+ </button>
56
+ )}
57
+ {expanded && (
58
+ <div className="mt-2 border-t border-gray-800 pt-2">
59
+ <TaskList tasks={tasks} />
60
+ </div>
61
+ )}
62
+ </div>
63
+ )
64
+ }
65
+
66
+ export function MilestoneKanban({ milestones, tasks }: MilestoneKanbanProps) {
67
+ if (milestones.length === 0) {
68
+ return <p className="text-gray-600 text-sm">No milestones</p>
69
+ }
70
+
71
+ return (
72
+ <div className="grid grid-cols-3 gap-4 min-h-[300px]">
73
+ {columns.map((col) => {
74
+ const items = milestones.filter((m) => m.status === col.status)
75
+ return (
76
+ <div key={col.status} className="flex flex-col">
77
+ <div
78
+ className={`flex items-center gap-2 pb-3 mb-3 border-b-2 ${col.color}`}
79
+ >
80
+ <h3 className="text-sm font-medium text-gray-300">{col.label}</h3>
81
+ <span className="text-xs text-gray-500 bg-gray-800 px-1.5 py-0.5 rounded-full">
82
+ {items.length}
83
+ </span>
84
+ </div>
85
+ <div className="space-y-2 flex-1">
86
+ {items.map((m) => (
87
+ <KanbanCard
88
+ key={m.id}
89
+ milestone={m}
90
+ tasks={tasks[m.id] || []}
91
+ />
92
+ ))}
93
+ {items.length === 0 && (
94
+ <p className="text-xs text-gray-700 text-center py-4">
95
+ No milestones
96
+ </p>
97
+ )}
98
+ </div>
99
+ </div>
100
+ )
101
+ })}
102
+ </div>
103
+ )
104
+ }
@@ -1,10 +1,11 @@
1
1
  import { Link, useRouterState } from '@tanstack/react-router'
2
- import { LayoutDashboard, Flag, CheckSquare, Search } from 'lucide-react'
2
+ import { LayoutDashboard, Flag, CheckSquare, Clock, Search } from 'lucide-react'
3
3
 
4
4
  const navItems = [
5
5
  { to: '/' as const, icon: LayoutDashboard, label: 'Overview' },
6
6
  { to: '/milestones' as const, icon: Flag, label: 'Milestones' },
7
7
  { to: '/tasks' as const, icon: CheckSquare, label: 'Tasks' },
8
+ { to: '/activity' as const, icon: Clock, label: 'Activity' },
8
9
  ]
9
10
 
10
11
  export function Sidebar() {
@@ -1,31 +1,32 @@
1
+ export type ViewMode = 'table' | 'tree' | 'kanban'
2
+
1
3
  interface ViewToggleProps {
2
- value: 'table' | 'tree'
3
- onChange: (view: 'table' | 'tree') => void
4
+ value: ViewMode
5
+ onChange: (view: ViewMode) => void
4
6
  }
5
7
 
8
+ const views: Array<{ id: ViewMode; label: string }> = [
9
+ { id: 'table', label: 'Table' },
10
+ { id: 'tree', label: 'Tree' },
11
+ { id: 'kanban', label: 'Kanban' },
12
+ ]
13
+
6
14
  export function ViewToggle({ value, onChange }: ViewToggleProps) {
7
15
  return (
8
16
  <div className="flex gap-1 bg-gray-900 border border-gray-800 rounded-lg p-0.5">
9
- <button
10
- onClick={() => onChange('table')}
11
- className={`px-3 py-1 text-xs rounded-md transition-colors ${
12
- value === 'table'
13
- ? 'bg-gray-700 text-gray-100'
14
- : 'text-gray-500 hover:text-gray-300'
15
- }`}
16
- >
17
- Table
18
- </button>
19
- <button
20
- onClick={() => onChange('tree')}
21
- className={`px-3 py-1 text-xs rounded-md transition-colors ${
22
- value === 'tree'
23
- ? 'bg-gray-700 text-gray-100'
24
- : 'text-gray-500 hover:text-gray-300'
25
- }`}
26
- >
27
- Tree
28
- </button>
17
+ {views.map((v) => (
18
+ <button
19
+ key={v.id}
20
+ onClick={() => onChange(v.id)}
21
+ className={`px-3 py-1 text-xs rounded-md transition-colors ${
22
+ value === v.id
23
+ ? 'bg-gray-700 text-gray-100'
24
+ : 'text-gray-500 hover:text-gray-300'
25
+ }`}
26
+ >
27
+ {v.label}
28
+ </button>
29
+ ))}
29
30
  </div>
30
31
  )
31
32
  }
@@ -12,6 +12,7 @@ import { Route as rootRouteImport } from './routes/__root'
12
12
  import { Route as TasksRouteImport } from './routes/tasks'
13
13
  import { Route as SearchRouteImport } from './routes/search'
14
14
  import { Route as MilestonesRouteImport } from './routes/milestones'
15
+ import { Route as ActivityRouteImport } from './routes/activity'
15
16
  import { Route as IndexRouteImport } from './routes/index'
16
17
  import { Route as ApiWatchRouteImport } from './routes/api/watch'
17
18
 
@@ -30,6 +31,11 @@ const MilestonesRoute = MilestonesRouteImport.update({
30
31
  path: '/milestones',
31
32
  getParentRoute: () => rootRouteImport,
32
33
  } as any)
34
+ const ActivityRoute = ActivityRouteImport.update({
35
+ id: '/activity',
36
+ path: '/activity',
37
+ getParentRoute: () => rootRouteImport,
38
+ } as any)
33
39
  const IndexRoute = IndexRouteImport.update({
34
40
  id: '/',
35
41
  path: '/',
@@ -43,6 +49,7 @@ const ApiWatchRoute = ApiWatchRouteImport.update({
43
49
 
44
50
  export interface FileRoutesByFullPath {
45
51
  '/': typeof IndexRoute
52
+ '/activity': typeof ActivityRoute
46
53
  '/milestones': typeof MilestonesRoute
47
54
  '/search': typeof SearchRoute
48
55
  '/tasks': typeof TasksRoute
@@ -50,6 +57,7 @@ export interface FileRoutesByFullPath {
50
57
  }
51
58
  export interface FileRoutesByTo {
52
59
  '/': typeof IndexRoute
60
+ '/activity': typeof ActivityRoute
53
61
  '/milestones': typeof MilestonesRoute
54
62
  '/search': typeof SearchRoute
55
63
  '/tasks': typeof TasksRoute
@@ -58,6 +66,7 @@ export interface FileRoutesByTo {
58
66
  export interface FileRoutesById {
59
67
  __root__: typeof rootRouteImport
60
68
  '/': typeof IndexRoute
69
+ '/activity': typeof ActivityRoute
61
70
  '/milestones': typeof MilestonesRoute
62
71
  '/search': typeof SearchRoute
63
72
  '/tasks': typeof TasksRoute
@@ -65,14 +74,28 @@ export interface FileRoutesById {
65
74
  }
66
75
  export interface FileRouteTypes {
67
76
  fileRoutesByFullPath: FileRoutesByFullPath
68
- fullPaths: '/' | '/milestones' | '/search' | '/tasks' | '/api/watch'
77
+ fullPaths:
78
+ | '/'
79
+ | '/activity'
80
+ | '/milestones'
81
+ | '/search'
82
+ | '/tasks'
83
+ | '/api/watch'
69
84
  fileRoutesByTo: FileRoutesByTo
70
- to: '/' | '/milestones' | '/search' | '/tasks' | '/api/watch'
71
- id: '__root__' | '/' | '/milestones' | '/search' | '/tasks' | '/api/watch'
85
+ to: '/' | '/activity' | '/milestones' | '/search' | '/tasks' | '/api/watch'
86
+ id:
87
+ | '__root__'
88
+ | '/'
89
+ | '/activity'
90
+ | '/milestones'
91
+ | '/search'
92
+ | '/tasks'
93
+ | '/api/watch'
72
94
  fileRoutesById: FileRoutesById
73
95
  }
74
96
  export interface RootRouteChildren {
75
97
  IndexRoute: typeof IndexRoute
98
+ ActivityRoute: typeof ActivityRoute
76
99
  MilestonesRoute: typeof MilestonesRoute
77
100
  SearchRoute: typeof SearchRoute
78
101
  TasksRoute: typeof TasksRoute
@@ -102,6 +125,13 @@ declare module '@tanstack/react-router' {
102
125
  preLoaderRoute: typeof MilestonesRouteImport
103
126
  parentRoute: typeof rootRouteImport
104
127
  }
128
+ '/activity': {
129
+ id: '/activity'
130
+ path: '/activity'
131
+ fullPath: '/activity'
132
+ preLoaderRoute: typeof ActivityRouteImport
133
+ parentRoute: typeof rootRouteImport
134
+ }
105
135
  '/': {
106
136
  id: '/'
107
137
  path: '/'
@@ -121,6 +151,7 @@ declare module '@tanstack/react-router' {
121
151
 
122
152
  const rootRouteChildren: RootRouteChildren = {
123
153
  IndexRoute: IndexRoute,
154
+ ActivityRoute: ActivityRoute,
124
155
  MilestonesRoute: MilestonesRoute,
125
156
  SearchRoute: SearchRoute,
126
157
  TasksRoute: TasksRoute,
@@ -0,0 +1,97 @@
1
+ import { createFileRoute } from '@tanstack/react-router'
2
+ import { Clock } from 'lucide-react'
3
+
4
+ export const Route = createFileRoute('/activity')({
5
+ component: ActivityPage,
6
+ })
7
+
8
+ function ActivityPage() {
9
+ const { progressData } = Route.useRouteContext()
10
+
11
+ if (!progressData) {
12
+ return (
13
+ <div className="p-6">
14
+ <p className="text-gray-600 text-sm">No data loaded</p>
15
+ </div>
16
+ )
17
+ }
18
+
19
+ const entries = progressData.recent_work
20
+
21
+ return (
22
+ <div className="p-6">
23
+ <h2 className="text-lg font-semibold mb-4">Recent Activity</h2>
24
+
25
+ {entries.length === 0 ? (
26
+ <p className="text-gray-600 text-sm">No recent work entries</p>
27
+ ) : (
28
+ <div className="relative">
29
+ {/* Timeline line */}
30
+ <div className="absolute left-[15px] top-2 bottom-2 w-px bg-gray-800" />
31
+
32
+ <div className="space-y-6">
33
+ {entries.map((entry, i) => (
34
+ <div key={i} className="relative flex gap-4">
35
+ {/* Timeline dot */}
36
+ <div className="relative z-10 mt-1">
37
+ <div className="w-[9px] h-[9px] rounded-full bg-blue-500 ring-4 ring-gray-950" />
38
+ </div>
39
+
40
+ {/* Content */}
41
+ <div className="flex-1 pb-2">
42
+ <div className="flex items-center gap-3 mb-1">
43
+ <span className="text-xs text-gray-500 font-mono">
44
+ {entry.date}
45
+ </span>
46
+ </div>
47
+ <p className="text-sm text-gray-200 mb-2">
48
+ {entry.description}
49
+ </p>
50
+ {entry.items.length > 0 && (
51
+ <ul className="space-y-1">
52
+ {entry.items.map((item, j) => (
53
+ <li
54
+ key={j}
55
+ className="text-xs text-gray-400 flex items-start gap-2"
56
+ >
57
+ <span className="text-gray-600 mt-0.5">•</span>
58
+ {item}
59
+ </li>
60
+ ))}
61
+ </ul>
62
+ )}
63
+ {Object.keys(entry.extra).length > 0 && (
64
+ <div className="mt-2 text-xs text-gray-600">
65
+ {Object.entries(entry.extra).map(([k, v]) => (
66
+ <span key={k} className="mr-3">
67
+ {k}: {String(v)}
68
+ </span>
69
+ ))}
70
+ </div>
71
+ )}
72
+ </div>
73
+ </div>
74
+ ))}
75
+ </div>
76
+ </div>
77
+ )}
78
+
79
+ {/* Notes section */}
80
+ {progressData.notes.length > 0 && (
81
+ <div className="mt-8">
82
+ <h3 className="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-3">
83
+ Notes
84
+ </h3>
85
+ <ul className="space-y-1.5">
86
+ {progressData.notes.map((note, i) => (
87
+ <li key={i} className="text-sm text-gray-400 flex items-start gap-2">
88
+ <span className="text-gray-600 mt-0.5">•</span>
89
+ {note}
90
+ </li>
91
+ ))}
92
+ </ul>
93
+ </div>
94
+ )}
95
+ </div>
96
+ )
97
+ }
@@ -1,6 +1,7 @@
1
1
  import { createFileRoute } from '@tanstack/react-router'
2
2
  import { StatusBadge } from '../components/StatusBadge'
3
3
  import { ProgressBar } from '../components/ProgressBar'
4
+ import { BurndownChart } from '../components/BurndownChart'
4
5
 
5
6
  export const Route = createFileRoute('/')({
6
7
  component: HomePage,
@@ -78,6 +79,9 @@ function HomePage() {
78
79
  </div>
79
80
  </div>
80
81
 
82
+ {/* Burndown Chart */}
83
+ <BurndownChart data={data} />
84
+
81
85
  {/* Next Steps */}
82
86
  {data.next_steps.length > 0 && (
83
87
  <div>
@@ -2,7 +2,8 @@ import { createFileRoute } from '@tanstack/react-router'
2
2
  import { useState } from 'react'
3
3
  import { MilestoneTable } from '../components/MilestoneTable'
4
4
  import { MilestoneTree } from '../components/MilestoneTree'
5
- import { ViewToggle } from '../components/ViewToggle'
5
+ import { MilestoneKanban } from '../components/MilestoneKanban'
6
+ import { ViewToggle, type ViewMode } from '../components/ViewToggle'
6
7
  import { FilterBar } from '../components/FilterBar'
7
8
  import { SearchInput } from '../components/SearchInput'
8
9
  import { useFilteredData } from '../lib/useFilteredData'
@@ -14,7 +15,7 @@ export const Route = createFileRoute('/milestones')({
14
15
 
15
16
  function MilestonesPage() {
16
17
  const { progressData } = Route.useRouteContext()
17
- const [view, setView] = useState<'table' | 'tree'>('table')
18
+ const [view, setView] = useState<ViewMode>('table')
18
19
  const [status, setStatus] = useState<Status | 'all'>('all')
19
20
  const [search, setSearch] = useState('')
20
21
 
@@ -34,16 +35,20 @@ function MilestonesPage() {
34
35
  <h2 className="text-lg font-semibold">Milestones</h2>
35
36
  <ViewToggle value={view} onChange={setView} />
36
37
  </div>
37
- <div className="flex items-center gap-3 mb-4">
38
- <FilterBar status={status} onStatusChange={setStatus} />
39
- <div className="w-64">
40
- <SearchInput value={search} onChange={setSearch} placeholder="Filter milestones..." />
38
+ {view !== 'kanban' && (
39
+ <div className="flex items-center gap-3 mb-4">
40
+ <FilterBar status={status} onStatusChange={setStatus} />
41
+ <div className="w-64">
42
+ <SearchInput value={search} onChange={setSearch} placeholder="Filter milestones..." />
43
+ </div>
41
44
  </div>
42
- </div>
45
+ )}
43
46
  {view === 'table' ? (
44
47
  <MilestoneTable milestones={filtered.milestones} tasks={filtered.tasks} />
45
- ) : (
48
+ ) : view === 'tree' ? (
46
49
  <MilestoneTree milestones={filtered.milestones} tasks={filtered.tasks} />
50
+ ) : (
51
+ <MilestoneKanban milestones={filtered.milestones} tasks={filtered.tasks} />
47
52
  )}
48
53
  </div>
49
54
  )