@prmichaelsen/acp-visualizer 0.7.1 → 0.8.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.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Browser-based dashboard for visualizing ACP progress.yaml data",
|
|
6
6
|
"bin": {
|
|
@@ -24,7 +24,8 @@
|
|
|
24
24
|
"cf-secrets:e1": "tsx scripts/upload-cloudflare-secrets.ts -- --env .env.e1.local",
|
|
25
25
|
"cf-secrets:prod": "tsx scripts/upload-cloudflare-secrets.ts -- --env .env.prod.local",
|
|
26
26
|
"typecheck": "tsc --noEmit",
|
|
27
|
-
"cf-typegen": "wrangler types"
|
|
27
|
+
"cf-typegen": "wrangler types",
|
|
28
|
+
"visualizer": "visualizer"
|
|
28
29
|
},
|
|
29
30
|
"dependencies": {
|
|
30
31
|
"@cloudflare/vite-plugin": "^1.28.0",
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import {
|
|
2
|
+
BarChart,
|
|
3
|
+
Bar,
|
|
4
|
+
XAxis,
|
|
5
|
+
YAxis,
|
|
6
|
+
CartesianGrid,
|
|
7
|
+
Tooltip,
|
|
8
|
+
ResponsiveContainer,
|
|
9
|
+
Legend,
|
|
10
|
+
Cell,
|
|
11
|
+
} from 'recharts'
|
|
12
|
+
import type { ProgressData } from '../lib/types'
|
|
13
|
+
|
|
14
|
+
interface EstimateChartProps {
|
|
15
|
+
data: ProgressData
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface MilestoneEstimate {
|
|
19
|
+
name: string
|
|
20
|
+
estimated: number
|
|
21
|
+
actual: number | null
|
|
22
|
+
status: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function buildEstimateData(data: ProgressData): MilestoneEstimate[] {
|
|
26
|
+
return data.milestones.map((m) => {
|
|
27
|
+
const tasks = data.tasks[m.id] || []
|
|
28
|
+
|
|
29
|
+
// Sum estimated hours from tasks
|
|
30
|
+
const estimated = tasks.reduce((sum, t) => {
|
|
31
|
+
const h = parseFloat(t.estimated_hours)
|
|
32
|
+
return sum + (isNaN(h) ? 0 : h)
|
|
33
|
+
}, 0)
|
|
34
|
+
|
|
35
|
+
// Calculate actual hours from milestone dates if completed
|
|
36
|
+
let actual: number | null = null
|
|
37
|
+
if (m.status === 'completed' && m.started && m.completed) {
|
|
38
|
+
const start = new Date(m.started)
|
|
39
|
+
const end = new Date(m.completed)
|
|
40
|
+
const days = Math.max(1, (end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24))
|
|
41
|
+
// Assume ~6 productive hours per day
|
|
42
|
+
actual = Math.round(days * 6 * 10) / 10
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Truncate long names
|
|
46
|
+
const label = m.name.length > 30 ? m.name.slice(0, 28) + '...' : m.name
|
|
47
|
+
|
|
48
|
+
return { name: label, estimated, actual, status: m.status }
|
|
49
|
+
})
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function EstimateChart({ data }: EstimateChartProps) {
|
|
53
|
+
const chartData = buildEstimateData(data)
|
|
54
|
+
|
|
55
|
+
if (chartData.length === 0) {
|
|
56
|
+
return null
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const hasActuals = chartData.some((d) => d.actual !== null)
|
|
60
|
+
|
|
61
|
+
return (
|
|
62
|
+
<div className="bg-gray-900/50 border border-gray-800 rounded-xl p-5">
|
|
63
|
+
<h3 className="text-sm font-semibold text-gray-300 mb-4">
|
|
64
|
+
Estimated vs Actual Hours
|
|
65
|
+
</h3>
|
|
66
|
+
<ResponsiveContainer width="100%" height={Math.max(200, chartData.length * 50 + 40)}>
|
|
67
|
+
<BarChart
|
|
68
|
+
data={chartData}
|
|
69
|
+
layout="vertical"
|
|
70
|
+
margin={{ top: 5, right: 20, bottom: 5, left: 10 }}
|
|
71
|
+
>
|
|
72
|
+
<CartesianGrid strokeDasharray="3 3" stroke="#1f2937" horizontal={false} />
|
|
73
|
+
<XAxis
|
|
74
|
+
type="number"
|
|
75
|
+
tick={{ fill: '#6b7280', fontSize: 11 }}
|
|
76
|
+
tickLine={false}
|
|
77
|
+
axisLine={{ stroke: '#374151' }}
|
|
78
|
+
label={{ value: 'Hours', position: 'insideBottomRight', offset: -5, fill: '#6b7280', fontSize: 11 }}
|
|
79
|
+
/>
|
|
80
|
+
<YAxis
|
|
81
|
+
type="category"
|
|
82
|
+
dataKey="name"
|
|
83
|
+
width={180}
|
|
84
|
+
tick={{ fill: '#9ca3af', fontSize: 11 }}
|
|
85
|
+
tickLine={false}
|
|
86
|
+
axisLine={{ stroke: '#374151' }}
|
|
87
|
+
/>
|
|
88
|
+
<Tooltip
|
|
89
|
+
contentStyle={{
|
|
90
|
+
backgroundColor: '#111827',
|
|
91
|
+
border: '1px solid #374151',
|
|
92
|
+
borderRadius: '8px',
|
|
93
|
+
fontSize: '12px',
|
|
94
|
+
color: '#e5e7eb',
|
|
95
|
+
}}
|
|
96
|
+
formatter={(value: unknown, name: unknown) => [
|
|
97
|
+
`${value}h`,
|
|
98
|
+
name === 'estimated' ? 'Estimated' : 'Actual',
|
|
99
|
+
]}
|
|
100
|
+
/>
|
|
101
|
+
<Legend
|
|
102
|
+
wrapperStyle={{ fontSize: '12px', color: '#9ca3af' }}
|
|
103
|
+
formatter={(value: string) => (value === 'estimated' ? 'Estimated' : 'Actual')}
|
|
104
|
+
/>
|
|
105
|
+
<Bar dataKey="estimated" fill="#3b82f6" barSize={14} radius={[0, 4, 4, 0]} />
|
|
106
|
+
{hasActuals && (
|
|
107
|
+
<Bar dataKey="actual" barSize={14} radius={[0, 4, 4, 0]}>
|
|
108
|
+
{chartData.map((entry, i) => (
|
|
109
|
+
<Cell
|
|
110
|
+
key={i}
|
|
111
|
+
fill={
|
|
112
|
+
entry.actual !== null && entry.actual > entry.estimated
|
|
113
|
+
? '#ef4444'
|
|
114
|
+
: '#22c55e'
|
|
115
|
+
}
|
|
116
|
+
/>
|
|
117
|
+
))}
|
|
118
|
+
</Bar>
|
|
119
|
+
)}
|
|
120
|
+
</BarChart>
|
|
121
|
+
</ResponsiveContainer>
|
|
122
|
+
{!hasActuals && (
|
|
123
|
+
<p className="text-xs text-gray-600 text-center mt-2">
|
|
124
|
+
Actual hours will appear once milestones have start and completion dates
|
|
125
|
+
</p>
|
|
126
|
+
)}
|
|
127
|
+
</div>
|
|
128
|
+
)
|
|
129
|
+
}
|
package/src/routes/index.tsx
CHANGED
|
@@ -2,6 +2,7 @@ import { createFileRoute } from '@tanstack/react-router'
|
|
|
2
2
|
import { StatusBadge } from '../components/StatusBadge'
|
|
3
3
|
import { ProgressBar } from '../components/ProgressBar'
|
|
4
4
|
import { BurndownChart } from '../components/BurndownChart'
|
|
5
|
+
import { EstimateChart } from '../components/EstimateChart'
|
|
5
6
|
import { useProgressData } from '../contexts/ProgressContext'
|
|
6
7
|
|
|
7
8
|
export const Route = createFileRoute('/')({
|
|
@@ -69,32 +70,8 @@ function HomePage() {
|
|
|
69
70
|
</div>
|
|
70
71
|
</div>
|
|
71
72
|
|
|
72
|
-
{/*
|
|
73
|
-
<
|
|
74
|
-
<h3 className="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-3">
|
|
75
|
-
Milestones ({data.milestones.length})
|
|
76
|
-
</h3>
|
|
77
|
-
<div className="space-y-2">
|
|
78
|
-
{data.milestones.map((m) => (
|
|
79
|
-
<div
|
|
80
|
-
key={m.id}
|
|
81
|
-
className="bg-gray-900/50 border border-gray-800 rounded-lg px-4 py-3 flex items-center gap-4"
|
|
82
|
-
>
|
|
83
|
-
<span className="flex-1 text-sm font-medium">{m.name}</span>
|
|
84
|
-
<StatusBadge status={m.status} />
|
|
85
|
-
<div className="w-24">
|
|
86
|
-
<ProgressBar value={m.progress} size="sm" />
|
|
87
|
-
</div>
|
|
88
|
-
<span className="text-xs text-gray-500 font-mono w-16 text-right">
|
|
89
|
-
{m.tasks_completed}/{m.tasks_total}
|
|
90
|
-
</span>
|
|
91
|
-
</div>
|
|
92
|
-
))}
|
|
93
|
-
{data.milestones.length === 0 && (
|
|
94
|
-
<p className="text-gray-600 text-sm">No milestones defined</p>
|
|
95
|
-
)}
|
|
96
|
-
</div>
|
|
97
|
-
</div>
|
|
73
|
+
{/* Estimated vs Actual */}
|
|
74
|
+
<EstimateChart data={data} />
|
|
98
75
|
|
|
99
76
|
{/* Burndown Chart */}
|
|
100
77
|
<BurndownChart data={data} />
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { createFileRoute } from '@tanstack/react-router'
|
|
2
|
-
import {
|
|
1
|
+
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
|
2
|
+
import { lazy, Suspense, useCallback } from 'react'
|
|
3
3
|
import { MilestoneTable } from '../components/MilestoneTable'
|
|
4
4
|
import { MilestoneTree } from '../components/MilestoneTree'
|
|
5
5
|
import { MilestoneKanban } from '../components/MilestoneKanban'
|
|
@@ -10,20 +10,40 @@ import { SearchInput } from '../components/SearchInput'
|
|
|
10
10
|
import { useFilteredData } from '../lib/useFilteredData'
|
|
11
11
|
import { useProgressData } from '../contexts/ProgressContext'
|
|
12
12
|
import type { Status } from '../lib/types'
|
|
13
|
+
import { z } from 'zod'
|
|
13
14
|
|
|
14
15
|
// Lazy-load DependencyGraph to keep dagre out of the SSR bundle
|
|
15
16
|
// (dagre uses CommonJS require() which fails on Cloudflare Workers)
|
|
16
17
|
const DependencyGraph = lazy(() => import('../components/DependencyGraph').then(m => ({ default: m.DependencyGraph })))
|
|
17
18
|
|
|
19
|
+
const viewModes = ['table', 'tree', 'kanban', 'gantt', 'graph'] as const
|
|
20
|
+
const statuses = ['all', 'completed', 'in_progress', 'not_started', 'wont_do'] as const
|
|
21
|
+
|
|
18
22
|
export const Route = createFileRoute('/milestones/')({
|
|
19
23
|
component: MilestonesPage,
|
|
24
|
+
validateSearch: z.object({
|
|
25
|
+
view: z.enum(viewModes).default('tree'),
|
|
26
|
+
status: z.enum(statuses).default('all'),
|
|
27
|
+
q: z.string().default(''),
|
|
28
|
+
}),
|
|
20
29
|
})
|
|
21
30
|
|
|
22
31
|
function MilestonesPage() {
|
|
23
32
|
const progressData = useProgressData()
|
|
24
|
-
const
|
|
25
|
-
const
|
|
26
|
-
|
|
33
|
+
const { view, status, q: search } = Route.useSearch()
|
|
34
|
+
const navigate = useNavigate({ from: Route.fullPath })
|
|
35
|
+
|
|
36
|
+
const setView = useCallback((v: ViewMode) => {
|
|
37
|
+
navigate({ search: (prev) => ({ ...prev, view: v }), replace: true })
|
|
38
|
+
}, [navigate])
|
|
39
|
+
|
|
40
|
+
const setStatus = useCallback((s: Status | 'all') => {
|
|
41
|
+
navigate({ search: (prev) => ({ ...prev, status: s }), replace: true })
|
|
42
|
+
}, [navigate])
|
|
43
|
+
|
|
44
|
+
const setSearch = useCallback((q: string) => {
|
|
45
|
+
navigate({ search: (prev) => ({ ...prev, q: q || undefined }), replace: true })
|
|
46
|
+
}, [navigate])
|
|
27
47
|
|
|
28
48
|
const filtered = useFilteredData(progressData, { status, search })
|
|
29
49
|
|