@prmichaelsen/acp-visualizer 0.1.7 → 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 +2 -1
- package/src/components/BurndownChart.tsx +148 -0
- package/src/components/MilestoneKanban.tsx +104 -0
- package/src/components/Sidebar.tsx +2 -1
- package/src/components/ViewToggle.tsx +23 -22
- package/src/lib/yaml-loader.ts +5 -1
- package/src/routeTree.gen.ts +34 -3
- package/src/routes/activity.tsx +97 -0
- package/src/routes/index.tsx +4 -0
- package/src/routes/milestones.tsx +13 -8
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@prmichaelsen/acp-visualizer",
|
|
3
|
-
"version": "0.1
|
|
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:
|
|
3
|
-
onChange: (view:
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
}
|
package/src/lib/yaml-loader.ts
CHANGED
|
@@ -135,7 +135,11 @@ function normalizeMilestone(raw: unknown, index: number): Milestone {
|
|
|
135
135
|
id: safeString(known.id, `milestone_${index + 1}`),
|
|
136
136
|
name: safeString(known.name, `Milestone ${index + 1}`),
|
|
137
137
|
status: normalizeStatus(known.status),
|
|
138
|
-
progress:
|
|
138
|
+
progress: known.progress != null
|
|
139
|
+
? safeNumber(known.progress)
|
|
140
|
+
: safeNumber(known.tasks_total) > 0
|
|
141
|
+
? Math.round((safeNumber(known.tasks_completed) / safeNumber(known.tasks_total)) * 100)
|
|
142
|
+
: normalizeStatus(known.status) === 'completed' ? 100 : 0,
|
|
139
143
|
started: known.started ? safeString(known.started) : null,
|
|
140
144
|
completed: known.completed ? safeString(known.completed) : null,
|
|
141
145
|
estimated_weeks: safeString(known.estimated_weeks, '0'),
|
package/src/routeTree.gen.ts
CHANGED
|
@@ -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:
|
|
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:
|
|
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
|
+
}
|
package/src/routes/index.tsx
CHANGED
|
@@ -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 {
|
|
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<
|
|
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
|
-
|
|
38
|
-
<
|
|
39
|
-
|
|
40
|
-
<
|
|
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
|
-
|
|
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
|
)
|