@prmichaelsen/acp-visualizer 0.7.0 → 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.7.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
+ }
@@ -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
- {/* Milestones Summary */}
73
- <div>
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 { useState, lazy, Suspense } from 'react'
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 [view, setView] = useState<ViewMode>('table')
25
- const [status, setStatus] = useState<Status | 'all'>('all')
26
- const [search, setSearch] = useState('')
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
 
@@ -120,8 +120,8 @@ async function fetchMarkdownFromGitHub(
120
120
  export const resolveMilestoneFile = createServerFn({ method: 'GET' })
121
121
  .inputValidator((input: { milestoneId: string; github?: { owner: string; repo: string; branch?: string; token?: string } }) => input)
122
122
  .handler(async ({ data: input }): Promise<ResolveFileResult> => {
123
- // Extract numeric part: "milestone_1" → "1"
124
- const match = input.milestoneId.match(/milestone_(\d+)/)
123
+ // Extract numeric part: "milestone_1" → "1", "M2" → "2", any string with digits
124
+ const match = input.milestoneId.match(/milestone_(\d+)/) || input.milestoneId.match(/^M(\d+)$/i) || input.milestoneId.match(/(\d+)/)
125
125
  if (!match) {
126
126
  return { ok: false, filePath: null, error: `Invalid milestone id format: ${input.milestoneId}` }
127
127
  }