@prmichaelsen/acp-visualizer 0.5.3 → 0.6.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 +4 -1
- package/src/components/Breadcrumb.tsx +25 -0
- package/src/components/DetailHeader.tsx +28 -0
- package/src/components/MarkdownContent.tsx +30 -0
- package/src/components/MilestoneKanban.tsx +8 -1
- package/src/components/MilestoneTable.tsx +9 -1
- package/src/components/MilestoneTree.tsx +9 -1
- package/src/components/TaskList.tsx +7 -4
- package/src/routeTree.gen.ts +115 -11
- package/src/routes/milestones.$milestoneId.tsx +149 -0
- package/src/routes/milestones.index.tsx +67 -0
- package/src/routes/milestones.tsx +4 -62
- package/src/routes/tasks.$taskId.tsx +169 -0
- package/src/routes/tasks.index.tsx +66 -0
- package/src/routes/tasks.tsx +4 -59
- package/src/services/markdown.service.ts +239 -0
- package/src/styles.css +2 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@prmichaelsen/acp-visualizer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Browser-based dashboard for visualizing ACP progress.yaml data",
|
|
6
6
|
"bin": {
|
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
"dependencies": {
|
|
30
30
|
"@cloudflare/vite-plugin": "^1.28.0",
|
|
31
31
|
"@cloudflare/workers-types": "^4.20260313.1",
|
|
32
|
+
"@tailwindcss/typography": "^0.5.19",
|
|
32
33
|
"@tailwindcss/vite": "^4.0.6",
|
|
33
34
|
"@tanstack/react-router": "^1.132.0",
|
|
34
35
|
"@tanstack/react-start": "^1.132.0",
|
|
@@ -45,7 +46,9 @@
|
|
|
45
46
|
"lucide-react": "^0.544.0",
|
|
46
47
|
"react": "^19.0.0",
|
|
47
48
|
"react-dom": "^19.0.0",
|
|
49
|
+
"react-markdown": "^10.1.0",
|
|
48
50
|
"recharts": "^3.8.0",
|
|
51
|
+
"rehype-highlight": "^7.0.2",
|
|
49
52
|
"tailwindcss": "^4.0.6",
|
|
50
53
|
"typescript": "^5.7.2",
|
|
51
54
|
"vite": "^7.1.7",
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { Link } from '@tanstack/react-router'
|
|
2
|
+
|
|
3
|
+
interface BreadcrumbItem {
|
|
4
|
+
label: string
|
|
5
|
+
href?: string
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function Breadcrumb({ items }: { items: BreadcrumbItem[] }) {
|
|
9
|
+
return (
|
|
10
|
+
<nav className="flex items-center gap-1.5 text-xs text-gray-500 mb-4">
|
|
11
|
+
{items.map((item, i) => (
|
|
12
|
+
<span key={i} className="flex items-center gap-1.5">
|
|
13
|
+
{i > 0 && <span className="text-gray-700">/</span>}
|
|
14
|
+
{item.href ? (
|
|
15
|
+
<Link to={item.href} className="hover:text-gray-300 transition-colors">
|
|
16
|
+
{item.label}
|
|
17
|
+
</Link>
|
|
18
|
+
) : (
|
|
19
|
+
<span className="text-gray-400">{item.label}</span>
|
|
20
|
+
)}
|
|
21
|
+
</span>
|
|
22
|
+
))}
|
|
23
|
+
</nav>
|
|
24
|
+
)
|
|
25
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { StatusBadge } from './StatusBadge'
|
|
2
|
+
import type { Status } from '../lib/types'
|
|
3
|
+
|
|
4
|
+
interface DetailField {
|
|
5
|
+
label: string
|
|
6
|
+
value: React.ReactNode
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
interface DetailHeaderProps {
|
|
10
|
+
status: Status
|
|
11
|
+
fields: DetailField[]
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function DetailHeader({ status, fields }: DetailHeaderProps) {
|
|
15
|
+
return (
|
|
16
|
+
<div className="bg-gray-900/50 border border-gray-800 rounded-xl p-4 mb-6">
|
|
17
|
+
<div className="flex flex-wrap items-center gap-3">
|
|
18
|
+
<StatusBadge status={status} />
|
|
19
|
+
{fields.map((field, i) => (
|
|
20
|
+
<div key={i} className="flex items-center gap-1.5 text-xs">
|
|
21
|
+
<span className="text-gray-500">{field.label}:</span>
|
|
22
|
+
<span className="text-gray-300">{field.value}</span>
|
|
23
|
+
</div>
|
|
24
|
+
))}
|
|
25
|
+
</div>
|
|
26
|
+
</div>
|
|
27
|
+
)
|
|
28
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import ReactMarkdown from 'react-markdown'
|
|
2
|
+
import rehypeHighlight from 'rehype-highlight'
|
|
3
|
+
|
|
4
|
+
interface MarkdownContentProps {
|
|
5
|
+
content: string
|
|
6
|
+
className?: string
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function MarkdownContent({ content, className }: MarkdownContentProps) {
|
|
10
|
+
return (
|
|
11
|
+
<div
|
|
12
|
+
className={`prose prose-invert prose-sm max-w-none
|
|
13
|
+
prose-pre:bg-gray-900 prose-pre:border prose-pre:border-gray-800
|
|
14
|
+
prose-code:bg-gray-800 prose-code:px-1.5 prose-code:py-0.5 prose-code:rounded prose-code:text-gray-200
|
|
15
|
+
prose-code:before:content-none prose-code:after:content-none
|
|
16
|
+
prose-a:text-blue-400 prose-a:no-underline hover:prose-a:underline
|
|
17
|
+
prose-headings:text-gray-100
|
|
18
|
+
prose-strong:text-gray-200
|
|
19
|
+
prose-th:text-gray-300 prose-th:border-gray-700
|
|
20
|
+
prose-td:border-gray-800
|
|
21
|
+
prose-hr:border-gray-800
|
|
22
|
+
prose-blockquote:border-gray-700 prose-blockquote:text-gray-400
|
|
23
|
+
${className ?? ''}`}
|
|
24
|
+
>
|
|
25
|
+
<ReactMarkdown rehypePlugins={[rehypeHighlight]}>
|
|
26
|
+
{content}
|
|
27
|
+
</ReactMarkdown>
|
|
28
|
+
</div>
|
|
29
|
+
)
|
|
30
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { Link } from '@tanstack/react-router'
|
|
1
2
|
import { StatusBadge } from './StatusBadge'
|
|
2
3
|
import { ProgressBar } from './ProgressBar'
|
|
3
4
|
import { TaskList } from './TaskList'
|
|
@@ -29,7 +30,13 @@ function KanbanCard({
|
|
|
29
30
|
return (
|
|
30
31
|
<div className="bg-gray-900/50 border border-gray-800 rounded-lg p-3 hover:border-gray-700 transition-colors">
|
|
31
32
|
<div className="flex items-start justify-between gap-2 mb-2">
|
|
32
|
-
<
|
|
33
|
+
<Link
|
|
34
|
+
to="/milestones/$milestoneId"
|
|
35
|
+
params={{ milestoneId: milestone.id }}
|
|
36
|
+
className="text-sm font-medium leading-tight hover:text-blue-400 transition-colors"
|
|
37
|
+
>
|
|
38
|
+
{milestone.name}
|
|
39
|
+
</Link>
|
|
33
40
|
</div>
|
|
34
41
|
<div className="flex items-center gap-2 mb-2">
|
|
35
42
|
<div className="flex-1">
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Fragment, useState } from 'react'
|
|
2
|
+
import { Link } from '@tanstack/react-router'
|
|
2
3
|
import {
|
|
3
4
|
createColumnHelper,
|
|
4
5
|
useReactTable,
|
|
@@ -52,7 +53,14 @@ export function MilestoneTable({ milestones, tasks }: MilestoneTableProps) {
|
|
|
52
53
|
columnHelper.accessor('name', {
|
|
53
54
|
header: 'Milestone',
|
|
54
55
|
cell: (info) => (
|
|
55
|
-
<
|
|
56
|
+
<Link
|
|
57
|
+
to="/milestones/$milestoneId"
|
|
58
|
+
params={{ milestoneId: info.row.original.id }}
|
|
59
|
+
className="text-sm font-medium text-gray-200 hover:text-blue-400 transition-colors"
|
|
60
|
+
onClick={(e) => e.stopPropagation()}
|
|
61
|
+
>
|
|
62
|
+
{info.getValue()}
|
|
63
|
+
</Link>
|
|
56
64
|
),
|
|
57
65
|
}),
|
|
58
66
|
columnHelper.accessor('status', {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { useState } from 'react'
|
|
2
|
+
import { Link } from '@tanstack/react-router'
|
|
2
3
|
import { ChevronDown, ChevronRight } from 'lucide-react'
|
|
3
4
|
import { StatusBadge } from './StatusBadge'
|
|
4
5
|
import { ProgressBar } from './ProgressBar'
|
|
@@ -35,7 +36,14 @@ function MilestoneTreeRow({
|
|
|
35
36
|
) : (
|
|
36
37
|
<ChevronRight className="w-4 h-4 text-gray-500 shrink-0" />
|
|
37
38
|
)}
|
|
38
|
-
<
|
|
39
|
+
<Link
|
|
40
|
+
to="/milestones/$milestoneId"
|
|
41
|
+
params={{ milestoneId: milestone.id }}
|
|
42
|
+
className="flex-1 text-sm font-medium hover:text-blue-400 transition-colors"
|
|
43
|
+
onClick={(e) => e.stopPropagation()}
|
|
44
|
+
>
|
|
45
|
+
{milestone.name}
|
|
46
|
+
</Link>
|
|
39
47
|
<StatusBadge status={milestone.status} />
|
|
40
48
|
<div className="w-20">
|
|
41
49
|
<ProgressBar value={milestone.progress} size="sm" />
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { Link } from '@tanstack/react-router'
|
|
1
2
|
import { StatusDot } from './StatusDot'
|
|
2
3
|
import { ExtraFieldsBadge } from './ExtraFieldsBadge'
|
|
3
4
|
import type { Task } from '../lib/types'
|
|
@@ -16,13 +17,15 @@ export function TaskList({ tasks }: { tasks: Task[] }) {
|
|
|
16
17
|
{tasks.map((task) => (
|
|
17
18
|
<div key={task.id} className="flex items-center gap-2 py-1 text-sm">
|
|
18
19
|
<StatusDot status={task.status} />
|
|
19
|
-
<
|
|
20
|
-
|
|
20
|
+
<Link
|
|
21
|
+
to="/tasks/$taskId"
|
|
22
|
+
params={{ taskId: task.id }}
|
|
23
|
+
className={`hover:text-blue-400 transition-colors ${
|
|
21
24
|
task.status === 'completed' ? 'text-gray-500' : 'text-gray-200'
|
|
22
|
-
}
|
|
25
|
+
}`}
|
|
23
26
|
>
|
|
24
27
|
{task.name}
|
|
25
|
-
</
|
|
28
|
+
</Link>
|
|
26
29
|
{task.notes && (
|
|
27
30
|
<span className="text-xs text-gray-600 ml-auto truncate max-w-[200px]">
|
|
28
31
|
{task.notes}
|
package/src/routeTree.gen.ts
CHANGED
|
@@ -14,6 +14,10 @@ import { Route as SearchRouteImport } from './routes/search'
|
|
|
14
14
|
import { Route as MilestonesRouteImport } from './routes/milestones'
|
|
15
15
|
import { Route as ActivityRouteImport } from './routes/activity'
|
|
16
16
|
import { Route as IndexRouteImport } from './routes/index'
|
|
17
|
+
import { Route as TasksIndexRouteImport } from './routes/tasks.index'
|
|
18
|
+
import { Route as MilestonesIndexRouteImport } from './routes/milestones.index'
|
|
19
|
+
import { Route as TasksTaskIdRouteImport } from './routes/tasks.$taskId'
|
|
20
|
+
import { Route as MilestonesMilestoneIdRouteImport } from './routes/milestones.$milestoneId'
|
|
17
21
|
import { Route as ApiWatchRouteImport } from './routes/api/watch'
|
|
18
22
|
|
|
19
23
|
const TasksRoute = TasksRouteImport.update({
|
|
@@ -41,6 +45,26 @@ const IndexRoute = IndexRouteImport.update({
|
|
|
41
45
|
path: '/',
|
|
42
46
|
getParentRoute: () => rootRouteImport,
|
|
43
47
|
} as any)
|
|
48
|
+
const TasksIndexRoute = TasksIndexRouteImport.update({
|
|
49
|
+
id: '/',
|
|
50
|
+
path: '/',
|
|
51
|
+
getParentRoute: () => TasksRoute,
|
|
52
|
+
} as any)
|
|
53
|
+
const MilestonesIndexRoute = MilestonesIndexRouteImport.update({
|
|
54
|
+
id: '/',
|
|
55
|
+
path: '/',
|
|
56
|
+
getParentRoute: () => MilestonesRoute,
|
|
57
|
+
} as any)
|
|
58
|
+
const TasksTaskIdRoute = TasksTaskIdRouteImport.update({
|
|
59
|
+
id: '/$taskId',
|
|
60
|
+
path: '/$taskId',
|
|
61
|
+
getParentRoute: () => TasksRoute,
|
|
62
|
+
} as any)
|
|
63
|
+
const MilestonesMilestoneIdRoute = MilestonesMilestoneIdRouteImport.update({
|
|
64
|
+
id: '/$milestoneId',
|
|
65
|
+
path: '/$milestoneId',
|
|
66
|
+
getParentRoute: () => MilestonesRoute,
|
|
67
|
+
} as any)
|
|
44
68
|
const ApiWatchRoute = ApiWatchRouteImport.update({
|
|
45
69
|
id: '/api/watch',
|
|
46
70
|
path: '/api/watch',
|
|
@@ -50,27 +74,37 @@ const ApiWatchRoute = ApiWatchRouteImport.update({
|
|
|
50
74
|
export interface FileRoutesByFullPath {
|
|
51
75
|
'/': typeof IndexRoute
|
|
52
76
|
'/activity': typeof ActivityRoute
|
|
53
|
-
'/milestones': typeof
|
|
77
|
+
'/milestones': typeof MilestonesRouteWithChildren
|
|
54
78
|
'/search': typeof SearchRoute
|
|
55
|
-
'/tasks': typeof
|
|
79
|
+
'/tasks': typeof TasksRouteWithChildren
|
|
56
80
|
'/api/watch': typeof ApiWatchRoute
|
|
81
|
+
'/milestones/$milestoneId': typeof MilestonesMilestoneIdRoute
|
|
82
|
+
'/tasks/$taskId': typeof TasksTaskIdRoute
|
|
83
|
+
'/milestones/': typeof MilestonesIndexRoute
|
|
84
|
+
'/tasks/': typeof TasksIndexRoute
|
|
57
85
|
}
|
|
58
86
|
export interface FileRoutesByTo {
|
|
59
87
|
'/': typeof IndexRoute
|
|
60
88
|
'/activity': typeof ActivityRoute
|
|
61
|
-
'/milestones': typeof MilestonesRoute
|
|
62
89
|
'/search': typeof SearchRoute
|
|
63
|
-
'/tasks': typeof TasksRoute
|
|
64
90
|
'/api/watch': typeof ApiWatchRoute
|
|
91
|
+
'/milestones/$milestoneId': typeof MilestonesMilestoneIdRoute
|
|
92
|
+
'/tasks/$taskId': typeof TasksTaskIdRoute
|
|
93
|
+
'/milestones': typeof MilestonesIndexRoute
|
|
94
|
+
'/tasks': typeof TasksIndexRoute
|
|
65
95
|
}
|
|
66
96
|
export interface FileRoutesById {
|
|
67
97
|
__root__: typeof rootRouteImport
|
|
68
98
|
'/': typeof IndexRoute
|
|
69
99
|
'/activity': typeof ActivityRoute
|
|
70
|
-
'/milestones': typeof
|
|
100
|
+
'/milestones': typeof MilestonesRouteWithChildren
|
|
71
101
|
'/search': typeof SearchRoute
|
|
72
|
-
'/tasks': typeof
|
|
102
|
+
'/tasks': typeof TasksRouteWithChildren
|
|
73
103
|
'/api/watch': typeof ApiWatchRoute
|
|
104
|
+
'/milestones/$milestoneId': typeof MilestonesMilestoneIdRoute
|
|
105
|
+
'/tasks/$taskId': typeof TasksTaskIdRoute
|
|
106
|
+
'/milestones/': typeof MilestonesIndexRoute
|
|
107
|
+
'/tasks/': typeof TasksIndexRoute
|
|
74
108
|
}
|
|
75
109
|
export interface FileRouteTypes {
|
|
76
110
|
fileRoutesByFullPath: FileRoutesByFullPath
|
|
@@ -81,8 +115,20 @@ export interface FileRouteTypes {
|
|
|
81
115
|
| '/search'
|
|
82
116
|
| '/tasks'
|
|
83
117
|
| '/api/watch'
|
|
118
|
+
| '/milestones/$milestoneId'
|
|
119
|
+
| '/tasks/$taskId'
|
|
120
|
+
| '/milestones/'
|
|
121
|
+
| '/tasks/'
|
|
84
122
|
fileRoutesByTo: FileRoutesByTo
|
|
85
|
-
to:
|
|
123
|
+
to:
|
|
124
|
+
| '/'
|
|
125
|
+
| '/activity'
|
|
126
|
+
| '/search'
|
|
127
|
+
| '/api/watch'
|
|
128
|
+
| '/milestones/$milestoneId'
|
|
129
|
+
| '/tasks/$taskId'
|
|
130
|
+
| '/milestones'
|
|
131
|
+
| '/tasks'
|
|
86
132
|
id:
|
|
87
133
|
| '__root__'
|
|
88
134
|
| '/'
|
|
@@ -91,14 +137,18 @@ export interface FileRouteTypes {
|
|
|
91
137
|
| '/search'
|
|
92
138
|
| '/tasks'
|
|
93
139
|
| '/api/watch'
|
|
140
|
+
| '/milestones/$milestoneId'
|
|
141
|
+
| '/tasks/$taskId'
|
|
142
|
+
| '/milestones/'
|
|
143
|
+
| '/tasks/'
|
|
94
144
|
fileRoutesById: FileRoutesById
|
|
95
145
|
}
|
|
96
146
|
export interface RootRouteChildren {
|
|
97
147
|
IndexRoute: typeof IndexRoute
|
|
98
148
|
ActivityRoute: typeof ActivityRoute
|
|
99
|
-
MilestonesRoute: typeof
|
|
149
|
+
MilestonesRoute: typeof MilestonesRouteWithChildren
|
|
100
150
|
SearchRoute: typeof SearchRoute
|
|
101
|
-
TasksRoute: typeof
|
|
151
|
+
TasksRoute: typeof TasksRouteWithChildren
|
|
102
152
|
ApiWatchRoute: typeof ApiWatchRoute
|
|
103
153
|
}
|
|
104
154
|
|
|
@@ -139,6 +189,34 @@ declare module '@tanstack/react-router' {
|
|
|
139
189
|
preLoaderRoute: typeof IndexRouteImport
|
|
140
190
|
parentRoute: typeof rootRouteImport
|
|
141
191
|
}
|
|
192
|
+
'/tasks/': {
|
|
193
|
+
id: '/tasks/'
|
|
194
|
+
path: '/'
|
|
195
|
+
fullPath: '/tasks/'
|
|
196
|
+
preLoaderRoute: typeof TasksIndexRouteImport
|
|
197
|
+
parentRoute: typeof TasksRoute
|
|
198
|
+
}
|
|
199
|
+
'/milestones/': {
|
|
200
|
+
id: '/milestones/'
|
|
201
|
+
path: '/'
|
|
202
|
+
fullPath: '/milestones/'
|
|
203
|
+
preLoaderRoute: typeof MilestonesIndexRouteImport
|
|
204
|
+
parentRoute: typeof MilestonesRoute
|
|
205
|
+
}
|
|
206
|
+
'/tasks/$taskId': {
|
|
207
|
+
id: '/tasks/$taskId'
|
|
208
|
+
path: '/$taskId'
|
|
209
|
+
fullPath: '/tasks/$taskId'
|
|
210
|
+
preLoaderRoute: typeof TasksTaskIdRouteImport
|
|
211
|
+
parentRoute: typeof TasksRoute
|
|
212
|
+
}
|
|
213
|
+
'/milestones/$milestoneId': {
|
|
214
|
+
id: '/milestones/$milestoneId'
|
|
215
|
+
path: '/$milestoneId'
|
|
216
|
+
fullPath: '/milestones/$milestoneId'
|
|
217
|
+
preLoaderRoute: typeof MilestonesMilestoneIdRouteImport
|
|
218
|
+
parentRoute: typeof MilestonesRoute
|
|
219
|
+
}
|
|
142
220
|
'/api/watch': {
|
|
143
221
|
id: '/api/watch'
|
|
144
222
|
path: '/api/watch'
|
|
@@ -149,12 +227,38 @@ declare module '@tanstack/react-router' {
|
|
|
149
227
|
}
|
|
150
228
|
}
|
|
151
229
|
|
|
230
|
+
interface MilestonesRouteChildren {
|
|
231
|
+
MilestonesMilestoneIdRoute: typeof MilestonesMilestoneIdRoute
|
|
232
|
+
MilestonesIndexRoute: typeof MilestonesIndexRoute
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const MilestonesRouteChildren: MilestonesRouteChildren = {
|
|
236
|
+
MilestonesMilestoneIdRoute: MilestonesMilestoneIdRoute,
|
|
237
|
+
MilestonesIndexRoute: MilestonesIndexRoute,
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const MilestonesRouteWithChildren = MilestonesRoute._addFileChildren(
|
|
241
|
+
MilestonesRouteChildren,
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
interface TasksRouteChildren {
|
|
245
|
+
TasksTaskIdRoute: typeof TasksTaskIdRoute
|
|
246
|
+
TasksIndexRoute: typeof TasksIndexRoute
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const TasksRouteChildren: TasksRouteChildren = {
|
|
250
|
+
TasksTaskIdRoute: TasksTaskIdRoute,
|
|
251
|
+
TasksIndexRoute: TasksIndexRoute,
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const TasksRouteWithChildren = TasksRoute._addFileChildren(TasksRouteChildren)
|
|
255
|
+
|
|
152
256
|
const rootRouteChildren: RootRouteChildren = {
|
|
153
257
|
IndexRoute: IndexRoute,
|
|
154
258
|
ActivityRoute: ActivityRoute,
|
|
155
|
-
MilestonesRoute:
|
|
259
|
+
MilestonesRoute: MilestonesRouteWithChildren,
|
|
156
260
|
SearchRoute: SearchRoute,
|
|
157
|
-
TasksRoute:
|
|
261
|
+
TasksRoute: TasksRouteWithChildren,
|
|
158
262
|
ApiWatchRoute: ApiWatchRoute,
|
|
159
263
|
}
|
|
160
264
|
export const routeTree = rootRouteImport
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { createFileRoute, Link } from '@tanstack/react-router'
|
|
2
|
+
import { useState, useEffect } from 'react'
|
|
3
|
+
import { useProgressData } from '../contexts/ProgressContext'
|
|
4
|
+
import { Breadcrumb } from '../components/Breadcrumb'
|
|
5
|
+
import { DetailHeader } from '../components/DetailHeader'
|
|
6
|
+
import { ProgressBar } from '../components/ProgressBar'
|
|
7
|
+
import { StatusDot } from '../components/StatusDot'
|
|
8
|
+
import { MarkdownContent } from '../components/MarkdownContent'
|
|
9
|
+
import { getMarkdownContent, resolveMilestoneFile } from '../services/markdown.service'
|
|
10
|
+
import type { MarkdownResult, ResolveFileResult } from '../services/markdown.service'
|
|
11
|
+
|
|
12
|
+
export const Route = createFileRoute('/milestones/$milestoneId')({
|
|
13
|
+
component: MilestoneDetailPage,
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
/** Read ?repo=owner/repo from URL */
|
|
17
|
+
function getGitHubParams(): { owner: string; repo: string } | undefined {
|
|
18
|
+
if (typeof window === 'undefined') return undefined
|
|
19
|
+
const params = new URLSearchParams(window.location.search)
|
|
20
|
+
const repo = params.get('repo')
|
|
21
|
+
if (!repo) return undefined
|
|
22
|
+
const parts = repo.split('/')
|
|
23
|
+
if (parts.length < 2) return undefined
|
|
24
|
+
return { owner: parts[0], repo: parts[1] }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function MilestoneDetailPage() {
|
|
28
|
+
const { milestoneId } = Route.useParams()
|
|
29
|
+
const data = useProgressData()
|
|
30
|
+
const [markdown, setMarkdown] = useState<string | null>(null)
|
|
31
|
+
const [markdownError, setMarkdownError] = useState<string | null>(null)
|
|
32
|
+
const [loading, setLoading] = useState(true)
|
|
33
|
+
|
|
34
|
+
const milestone = data?.milestones.find((m) => m.id === milestoneId)
|
|
35
|
+
const tasks = data?.tasks[milestoneId] || []
|
|
36
|
+
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
if (!milestoneId) return
|
|
39
|
+
|
|
40
|
+
setLoading(true)
|
|
41
|
+
setMarkdown(null)
|
|
42
|
+
setMarkdownError(null)
|
|
43
|
+
|
|
44
|
+
const github = getGitHubParams()
|
|
45
|
+
|
|
46
|
+
resolveMilestoneFile({ data: { milestoneId, github } })
|
|
47
|
+
.then((resolveResult: ResolveFileResult) => {
|
|
48
|
+
if (!resolveResult.ok) {
|
|
49
|
+
setMarkdownError(resolveResult.error)
|
|
50
|
+
setLoading(false)
|
|
51
|
+
return
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return getMarkdownContent({ data: { filePath: resolveResult.filePath, github } })
|
|
55
|
+
.then((mdResult: MarkdownResult) => {
|
|
56
|
+
if (mdResult.ok) {
|
|
57
|
+
setMarkdown(mdResult.content)
|
|
58
|
+
} else {
|
|
59
|
+
setMarkdownError(mdResult.error)
|
|
60
|
+
}
|
|
61
|
+
})
|
|
62
|
+
})
|
|
63
|
+
.catch((err: Error) => {
|
|
64
|
+
setMarkdownError(err.message)
|
|
65
|
+
})
|
|
66
|
+
.finally(() => {
|
|
67
|
+
setLoading(false)
|
|
68
|
+
})
|
|
69
|
+
}, [milestoneId])
|
|
70
|
+
|
|
71
|
+
if (!data || !milestone) {
|
|
72
|
+
return (
|
|
73
|
+
<div className="p-6">
|
|
74
|
+
<p className="text-gray-500 text-sm">Milestone not found: {milestoneId}</p>
|
|
75
|
+
</div>
|
|
76
|
+
)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const fields = [
|
|
80
|
+
...(milestone.started ? [{ label: 'Started', value: milestone.started }] : []),
|
|
81
|
+
...(milestone.completed ? [{ label: 'Completed', value: milestone.completed }] : []),
|
|
82
|
+
{ label: 'Est', value: `${milestone.estimated_weeks} week${milestone.estimated_weeks === '1' ? '' : 's'}` },
|
|
83
|
+
{ label: 'Tasks', value: `${milestone.tasks_completed}/${milestone.tasks_total}` },
|
|
84
|
+
]
|
|
85
|
+
|
|
86
|
+
return (
|
|
87
|
+
<div className="p-6 max-w-4xl">
|
|
88
|
+
<Breadcrumb
|
|
89
|
+
items={[
|
|
90
|
+
{ label: 'Milestones', href: '/milestones' },
|
|
91
|
+
{ label: `${milestone.id.replace('milestone_', 'M')} — ${milestone.name}` },
|
|
92
|
+
]}
|
|
93
|
+
/>
|
|
94
|
+
|
|
95
|
+
<h1 className="text-xl font-semibold text-gray-100 mb-3">{milestone.name}</h1>
|
|
96
|
+
|
|
97
|
+
<div className="flex items-center gap-3 mb-4">
|
|
98
|
+
<div className="flex-1 max-w-xs">
|
|
99
|
+
<ProgressBar value={milestone.progress} size="sm" />
|
|
100
|
+
</div>
|
|
101
|
+
<span className="text-xs text-gray-500">{milestone.progress}%</span>
|
|
102
|
+
</div>
|
|
103
|
+
|
|
104
|
+
<DetailHeader status={milestone.status} fields={fields} />
|
|
105
|
+
|
|
106
|
+
{milestone.notes && (
|
|
107
|
+
<p className="text-sm text-gray-400 mb-6">{milestone.notes}</p>
|
|
108
|
+
)}
|
|
109
|
+
|
|
110
|
+
{/* Markdown content */}
|
|
111
|
+
{loading ? (
|
|
112
|
+
<p className="text-sm text-gray-600">Loading document...</p>
|
|
113
|
+
) : markdown ? (
|
|
114
|
+
<MarkdownContent content={markdown} />
|
|
115
|
+
) : markdownError ? (
|
|
116
|
+
<div className="bg-gray-900/50 border border-gray-800 rounded-xl p-4 text-sm text-gray-500">
|
|
117
|
+
No document found — {markdownError}
|
|
118
|
+
</div>
|
|
119
|
+
) : null}
|
|
120
|
+
|
|
121
|
+
{/* Task list */}
|
|
122
|
+
{tasks.length > 0 && (
|
|
123
|
+
<div className="mt-8">
|
|
124
|
+
<h2 className="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-3">
|
|
125
|
+
Tasks
|
|
126
|
+
</h2>
|
|
127
|
+
<div className="bg-gray-900/50 border border-gray-800 rounded-xl divide-y divide-gray-800">
|
|
128
|
+
{tasks.map((task) => (
|
|
129
|
+
<Link
|
|
130
|
+
key={task.id}
|
|
131
|
+
to="/tasks/$taskId"
|
|
132
|
+
params={{ taskId: task.id }}
|
|
133
|
+
className="flex items-center gap-2 px-4 py-2.5 text-sm hover:bg-gray-800/50 transition-colors first:rounded-t-xl last:rounded-b-xl"
|
|
134
|
+
>
|
|
135
|
+
<StatusDot status={task.status} />
|
|
136
|
+
<span className={task.status === 'completed' ? 'text-gray-500' : 'text-gray-200'}>
|
|
137
|
+
{task.name}
|
|
138
|
+
</span>
|
|
139
|
+
{task.estimated_hours && (
|
|
140
|
+
<span className="text-xs text-gray-600 ml-auto">{task.estimated_hours}h</span>
|
|
141
|
+
)}
|
|
142
|
+
</Link>
|
|
143
|
+
))}
|
|
144
|
+
</div>
|
|
145
|
+
</div>
|
|
146
|
+
)}
|
|
147
|
+
</div>
|
|
148
|
+
)
|
|
149
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { createFileRoute } from '@tanstack/react-router'
|
|
2
|
+
import { useState, lazy, Suspense } from 'react'
|
|
3
|
+
import { MilestoneTable } from '../components/MilestoneTable'
|
|
4
|
+
import { MilestoneTree } from '../components/MilestoneTree'
|
|
5
|
+
import { MilestoneKanban } from '../components/MilestoneKanban'
|
|
6
|
+
import { MilestoneGantt } from '../components/MilestoneGantt'
|
|
7
|
+
import { ViewToggle, type ViewMode } from '../components/ViewToggle'
|
|
8
|
+
import { FilterBar } from '../components/FilterBar'
|
|
9
|
+
import { SearchInput } from '../components/SearchInput'
|
|
10
|
+
import { useFilteredData } from '../lib/useFilteredData'
|
|
11
|
+
import { useProgressData } from '../contexts/ProgressContext'
|
|
12
|
+
import type { Status } from '../lib/types'
|
|
13
|
+
|
|
14
|
+
// Lazy-load DependencyGraph to keep dagre out of the SSR bundle
|
|
15
|
+
// (dagre uses CommonJS require() which fails on Cloudflare Workers)
|
|
16
|
+
const DependencyGraph = lazy(() => import('../components/DependencyGraph').then(m => ({ default: m.DependencyGraph })))
|
|
17
|
+
|
|
18
|
+
export const Route = createFileRoute('/milestones/')({
|
|
19
|
+
component: MilestonesPage,
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
function MilestonesPage() {
|
|
23
|
+
const progressData = useProgressData()
|
|
24
|
+
const [view, setView] = useState<ViewMode>('table')
|
|
25
|
+
const [status, setStatus] = useState<Status | 'all'>('all')
|
|
26
|
+
const [search, setSearch] = useState('')
|
|
27
|
+
|
|
28
|
+
const filtered = useFilteredData(progressData, { status, search })
|
|
29
|
+
|
|
30
|
+
if (!filtered) {
|
|
31
|
+
return (
|
|
32
|
+
<div className="p-6">
|
|
33
|
+
<p className="text-gray-600 text-sm">No data loaded</p>
|
|
34
|
+
</div>
|
|
35
|
+
)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<div className="p-6">
|
|
40
|
+
<div className="flex items-center justify-between mb-4">
|
|
41
|
+
<h2 className="text-lg font-semibold">Milestones</h2>
|
|
42
|
+
<ViewToggle value={view} onChange={setView} />
|
|
43
|
+
</div>
|
|
44
|
+
{view !== 'kanban' && (
|
|
45
|
+
<div className="flex items-center gap-3 mb-4">
|
|
46
|
+
<FilterBar status={status} onStatusChange={setStatus} />
|
|
47
|
+
<div className="w-64">
|
|
48
|
+
<SearchInput value={search} onChange={setSearch} placeholder="Filter milestones..." />
|
|
49
|
+
</div>
|
|
50
|
+
</div>
|
|
51
|
+
)}
|
|
52
|
+
{view === 'table' ? (
|
|
53
|
+
<MilestoneTable milestones={filtered.milestones} tasks={filtered.tasks} />
|
|
54
|
+
) : view === 'tree' ? (
|
|
55
|
+
<MilestoneTree milestones={filtered.milestones} tasks={filtered.tasks} />
|
|
56
|
+
) : view === 'kanban' ? (
|
|
57
|
+
<MilestoneKanban milestones={filtered.milestones} tasks={filtered.tasks} />
|
|
58
|
+
) : view === 'gantt' ? (
|
|
59
|
+
<MilestoneGantt milestones={filtered.milestones} tasks={filtered.tasks} />
|
|
60
|
+
) : (
|
|
61
|
+
<Suspense fallback={<p className="text-gray-500 text-sm">Loading graph...</p>}>
|
|
62
|
+
<DependencyGraph data={filtered} />
|
|
63
|
+
</Suspense>
|
|
64
|
+
)}
|
|
65
|
+
</div>
|
|
66
|
+
)
|
|
67
|
+
}
|
|
@@ -1,67 +1,9 @@
|
|
|
1
|
-
import { createFileRoute } from '@tanstack/react-router'
|
|
2
|
-
import { useState, lazy, Suspense } from 'react'
|
|
3
|
-
import { MilestoneTable } from '../components/MilestoneTable'
|
|
4
|
-
import { MilestoneTree } from '../components/MilestoneTree'
|
|
5
|
-
import { MilestoneKanban } from '../components/MilestoneKanban'
|
|
6
|
-
import { MilestoneGantt } from '../components/MilestoneGantt'
|
|
7
|
-
import { ViewToggle, type ViewMode } from '../components/ViewToggle'
|
|
8
|
-
import { FilterBar } from '../components/FilterBar'
|
|
9
|
-
import { SearchInput } from '../components/SearchInput'
|
|
10
|
-
import { useFilteredData } from '../lib/useFilteredData'
|
|
11
|
-
import { useProgressData } from '../contexts/ProgressContext'
|
|
12
|
-
import type { Status } from '../lib/types'
|
|
13
|
-
|
|
14
|
-
// Lazy-load DependencyGraph to keep dagre out of the SSR bundle
|
|
15
|
-
// (dagre uses CommonJS require() which fails on Cloudflare Workers)
|
|
16
|
-
const DependencyGraph = lazy(() => import('../components/DependencyGraph').then(m => ({ default: m.DependencyGraph })))
|
|
1
|
+
import { createFileRoute, Outlet } from '@tanstack/react-router'
|
|
17
2
|
|
|
18
3
|
export const Route = createFileRoute('/milestones')({
|
|
19
|
-
component:
|
|
4
|
+
component: MilestonesLayout,
|
|
20
5
|
})
|
|
21
6
|
|
|
22
|
-
function
|
|
23
|
-
|
|
24
|
-
const [view, setView] = useState<ViewMode>('table')
|
|
25
|
-
const [status, setStatus] = useState<Status | 'all'>('all')
|
|
26
|
-
const [search, setSearch] = useState('')
|
|
27
|
-
|
|
28
|
-
const filtered = useFilteredData(progressData, { status, search })
|
|
29
|
-
|
|
30
|
-
if (!filtered) {
|
|
31
|
-
return (
|
|
32
|
-
<div className="p-6">
|
|
33
|
-
<p className="text-gray-600 text-sm">No data loaded</p>
|
|
34
|
-
</div>
|
|
35
|
-
)
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
return (
|
|
39
|
-
<div className="p-6">
|
|
40
|
-
<div className="flex items-center justify-between mb-4">
|
|
41
|
-
<h2 className="text-lg font-semibold">Milestones</h2>
|
|
42
|
-
<ViewToggle value={view} onChange={setView} />
|
|
43
|
-
</div>
|
|
44
|
-
{view !== 'kanban' && (
|
|
45
|
-
<div className="flex items-center gap-3 mb-4">
|
|
46
|
-
<FilterBar status={status} onStatusChange={setStatus} />
|
|
47
|
-
<div className="w-64">
|
|
48
|
-
<SearchInput value={search} onChange={setSearch} placeholder="Filter milestones..." />
|
|
49
|
-
</div>
|
|
50
|
-
</div>
|
|
51
|
-
)}
|
|
52
|
-
{view === 'table' ? (
|
|
53
|
-
<MilestoneTable milestones={filtered.milestones} tasks={filtered.tasks} />
|
|
54
|
-
) : view === 'tree' ? (
|
|
55
|
-
<MilestoneTree milestones={filtered.milestones} tasks={filtered.tasks} />
|
|
56
|
-
) : view === 'kanban' ? (
|
|
57
|
-
<MilestoneKanban milestones={filtered.milestones} tasks={filtered.tasks} />
|
|
58
|
-
) : view === 'gantt' ? (
|
|
59
|
-
<MilestoneGantt milestones={filtered.milestones} tasks={filtered.tasks} />
|
|
60
|
-
) : (
|
|
61
|
-
<Suspense fallback={<p className="text-gray-500 text-sm">Loading graph...</p>}>
|
|
62
|
-
<DependencyGraph data={filtered} />
|
|
63
|
-
</Suspense>
|
|
64
|
-
)}
|
|
65
|
-
</div>
|
|
66
|
-
)
|
|
7
|
+
function MilestonesLayout() {
|
|
8
|
+
return <Outlet />
|
|
67
9
|
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { createFileRoute, Link } from '@tanstack/react-router'
|
|
2
|
+
import { useState, useEffect, useMemo } from 'react'
|
|
3
|
+
import { useProgressData } from '../contexts/ProgressContext'
|
|
4
|
+
import { Breadcrumb } from '../components/Breadcrumb'
|
|
5
|
+
import { DetailHeader } from '../components/DetailHeader'
|
|
6
|
+
import { MarkdownContent } from '../components/MarkdownContent'
|
|
7
|
+
import { getMarkdownContent } from '../services/markdown.service'
|
|
8
|
+
import { resolveTaskFile } from '../services/markdown.service'
|
|
9
|
+
import type { MarkdownResult } from '../services/markdown.service'
|
|
10
|
+
|
|
11
|
+
export const Route = createFileRoute('/tasks/$taskId')({
|
|
12
|
+
component: TaskDetailPage,
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
/** Read ?repo=owner/repo from URL */
|
|
16
|
+
function getGitHubParams(): { owner: string; repo: string } | undefined {
|
|
17
|
+
if (typeof window === 'undefined') return undefined
|
|
18
|
+
const params = new URLSearchParams(window.location.search)
|
|
19
|
+
const repo = params.get('repo')
|
|
20
|
+
if (!repo) return undefined
|
|
21
|
+
const parts = repo.split('/')
|
|
22
|
+
if (parts.length < 2) return undefined
|
|
23
|
+
return { owner: parts[0], repo: parts[1] }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function TaskDetailPage() {
|
|
27
|
+
const { taskId } = Route.useParams()
|
|
28
|
+
const data = useProgressData()
|
|
29
|
+
const [markdown, setMarkdown] = useState<string | null>(null)
|
|
30
|
+
const [markdownError, setMarkdownError] = useState<string | null>(null)
|
|
31
|
+
const [loading, setLoading] = useState(true)
|
|
32
|
+
|
|
33
|
+
// Find the task and its parent milestone
|
|
34
|
+
const { task, milestone, siblings } = useMemo(() => {
|
|
35
|
+
if (!data) return { task: null, milestone: null, siblings: { prev: null, next: null } }
|
|
36
|
+
|
|
37
|
+
for (const ms of data.milestones) {
|
|
38
|
+
const msTaskList = data.tasks[ms.id] || []
|
|
39
|
+
const idx = msTaskList.findIndex((t) => t.id === taskId)
|
|
40
|
+
if (idx !== -1) {
|
|
41
|
+
return {
|
|
42
|
+
task: msTaskList[idx],
|
|
43
|
+
milestone: ms,
|
|
44
|
+
siblings: {
|
|
45
|
+
prev: idx > 0 ? msTaskList[idx - 1] : null,
|
|
46
|
+
next: idx < msTaskList.length - 1 ? msTaskList[idx + 1] : null,
|
|
47
|
+
},
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return { task: null, milestone: null, siblings: { prev: null, next: null } }
|
|
52
|
+
}, [data, taskId])
|
|
53
|
+
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
if (!task) return
|
|
56
|
+
|
|
57
|
+
setLoading(true)
|
|
58
|
+
setMarkdown(null)
|
|
59
|
+
setMarkdownError(null)
|
|
60
|
+
|
|
61
|
+
const filePath = resolveTaskFile(task)
|
|
62
|
+
if (!filePath) {
|
|
63
|
+
setMarkdownError('No file path for this task')
|
|
64
|
+
setLoading(false)
|
|
65
|
+
return
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const github = getGitHubParams()
|
|
69
|
+
|
|
70
|
+
getMarkdownContent({ data: { filePath, github } })
|
|
71
|
+
.then((result: MarkdownResult) => {
|
|
72
|
+
if (result.ok) {
|
|
73
|
+
setMarkdown(result.content)
|
|
74
|
+
} else {
|
|
75
|
+
setMarkdownError(result.error)
|
|
76
|
+
}
|
|
77
|
+
})
|
|
78
|
+
.catch((err: Error) => {
|
|
79
|
+
setMarkdownError(err.message)
|
|
80
|
+
})
|
|
81
|
+
.finally(() => {
|
|
82
|
+
setLoading(false)
|
|
83
|
+
})
|
|
84
|
+
}, [task])
|
|
85
|
+
|
|
86
|
+
if (!data || !task || !milestone) {
|
|
87
|
+
return (
|
|
88
|
+
<div className="p-6">
|
|
89
|
+
<p className="text-gray-500 text-sm">Task not found: {taskId}</p>
|
|
90
|
+
</div>
|
|
91
|
+
)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const fields = [
|
|
95
|
+
{ label: 'Est', value: `${task.estimated_hours}h` },
|
|
96
|
+
...(task.completed_date ? [{ label: 'Completed', value: task.completed_date }] : []),
|
|
97
|
+
{
|
|
98
|
+
label: 'Milestone',
|
|
99
|
+
value: (
|
|
100
|
+
<Link
|
|
101
|
+
to="/milestones/$milestoneId"
|
|
102
|
+
params={{ milestoneId: milestone.id }}
|
|
103
|
+
className="text-blue-400 hover:underline"
|
|
104
|
+
>
|
|
105
|
+
{milestone.id.replace('milestone_', 'M')} — {milestone.name}
|
|
106
|
+
</Link>
|
|
107
|
+
),
|
|
108
|
+
},
|
|
109
|
+
]
|
|
110
|
+
|
|
111
|
+
return (
|
|
112
|
+
<div className="p-6 max-w-4xl">
|
|
113
|
+
<Breadcrumb
|
|
114
|
+
items={[
|
|
115
|
+
{ label: 'Milestones', href: '/milestones' },
|
|
116
|
+
{ label: `${milestone.id.replace('milestone_', 'M')} — ${milestone.name}`, href: `/milestones/${milestone.id}` },
|
|
117
|
+
{ label: task.name },
|
|
118
|
+
]}
|
|
119
|
+
/>
|
|
120
|
+
|
|
121
|
+
<h1 className="text-xl font-semibold text-gray-100 mb-3">{task.name}</h1>
|
|
122
|
+
|
|
123
|
+
<DetailHeader status={task.status} fields={fields} />
|
|
124
|
+
|
|
125
|
+
{task.notes && (
|
|
126
|
+
<p className="text-sm text-gray-400 mb-6">{task.notes}</p>
|
|
127
|
+
)}
|
|
128
|
+
|
|
129
|
+
{/* Markdown content */}
|
|
130
|
+
{loading ? (
|
|
131
|
+
<p className="text-sm text-gray-600">Loading document...</p>
|
|
132
|
+
) : markdown ? (
|
|
133
|
+
<MarkdownContent content={markdown} />
|
|
134
|
+
) : markdownError ? (
|
|
135
|
+
<div className="bg-gray-900/50 border border-gray-800 rounded-xl p-4 text-sm text-gray-500">
|
|
136
|
+
No document found — {markdownError}
|
|
137
|
+
</div>
|
|
138
|
+
) : null}
|
|
139
|
+
|
|
140
|
+
{/* Prev / Next navigation */}
|
|
141
|
+
{(siblings.prev || siblings.next) && (
|
|
142
|
+
<div className="mt-8 flex items-center justify-between border-t border-gray-800 pt-4">
|
|
143
|
+
{siblings.prev ? (
|
|
144
|
+
<Link
|
|
145
|
+
to="/tasks/$taskId"
|
|
146
|
+
params={{ taskId: siblings.prev.id }}
|
|
147
|
+
className="text-sm text-gray-400 hover:text-gray-200 transition-colors"
|
|
148
|
+
>
|
|
149
|
+
← {siblings.prev.name}
|
|
150
|
+
</Link>
|
|
151
|
+
) : (
|
|
152
|
+
<span />
|
|
153
|
+
)}
|
|
154
|
+
{siblings.next ? (
|
|
155
|
+
<Link
|
|
156
|
+
to="/tasks/$taskId"
|
|
157
|
+
params={{ taskId: siblings.next.id }}
|
|
158
|
+
className="text-sm text-gray-400 hover:text-gray-200 transition-colors"
|
|
159
|
+
>
|
|
160
|
+
{siblings.next.name} →
|
|
161
|
+
</Link>
|
|
162
|
+
) : (
|
|
163
|
+
<span />
|
|
164
|
+
)}
|
|
165
|
+
</div>
|
|
166
|
+
)}
|
|
167
|
+
</div>
|
|
168
|
+
)
|
|
169
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { createFileRoute, Link } from '@tanstack/react-router'
|
|
2
|
+
import { StatusDot } from '../components/StatusDot'
|
|
3
|
+
import { ExtraFieldsBadge } from '../components/ExtraFieldsBadge'
|
|
4
|
+
import { useProgressData } from '../contexts/ProgressContext'
|
|
5
|
+
import type { Task } from '../lib/types'
|
|
6
|
+
|
|
7
|
+
export const Route = createFileRoute('/tasks/')({
|
|
8
|
+
component: TasksPage,
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
function TasksPage() {
|
|
12
|
+
const progressData = useProgressData()
|
|
13
|
+
|
|
14
|
+
if (!progressData) {
|
|
15
|
+
return (
|
|
16
|
+
<div className="p-6">
|
|
17
|
+
<p className="text-gray-600 text-sm">No data loaded</p>
|
|
18
|
+
</div>
|
|
19
|
+
)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const allTasks: Array<Task & { milestoneName: string }> = []
|
|
23
|
+
for (const milestone of progressData.milestones) {
|
|
24
|
+
const tasks = progressData.tasks[milestone.id] || []
|
|
25
|
+
for (const task of tasks) {
|
|
26
|
+
allTasks.push({ ...task, milestoneName: milestone.name })
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return (
|
|
31
|
+
<div className="p-6">
|
|
32
|
+
<h2 className="text-lg font-semibold mb-4">
|
|
33
|
+
All Tasks ({allTasks.length})
|
|
34
|
+
</h2>
|
|
35
|
+
<div className="border border-gray-800 rounded-lg overflow-hidden">
|
|
36
|
+
{allTasks.map((task) => (
|
|
37
|
+
<Link
|
|
38
|
+
key={task.id}
|
|
39
|
+
to="/tasks/$taskId"
|
|
40
|
+
params={{ taskId: task.id }}
|
|
41
|
+
className="flex items-center gap-3 px-4 py-2.5 border-b border-gray-800/50 hover:bg-gray-800/30 transition-colors"
|
|
42
|
+
>
|
|
43
|
+
<StatusDot status={task.status} />
|
|
44
|
+
<span
|
|
45
|
+
className={`flex-1 text-sm ${
|
|
46
|
+
task.status === 'completed' ? 'text-gray-500' : 'text-gray-200'
|
|
47
|
+
}`}
|
|
48
|
+
>
|
|
49
|
+
{task.name}
|
|
50
|
+
</span>
|
|
51
|
+
<span className="text-xs text-gray-600">{task.milestoneName}</span>
|
|
52
|
+
<span className="text-xs text-gray-500 font-mono w-8 text-right">
|
|
53
|
+
{task.estimated_hours}h
|
|
54
|
+
</span>
|
|
55
|
+
<ExtraFieldsBadge fields={task.extra} />
|
|
56
|
+
</Link>
|
|
57
|
+
))}
|
|
58
|
+
{allTasks.length === 0 && (
|
|
59
|
+
<div className="px-4 py-6 text-center">
|
|
60
|
+
<p className="text-gray-600 text-sm">No tasks defined</p>
|
|
61
|
+
</div>
|
|
62
|
+
)}
|
|
63
|
+
</div>
|
|
64
|
+
</div>
|
|
65
|
+
)
|
|
66
|
+
}
|
package/src/routes/tasks.tsx
CHANGED
|
@@ -1,64 +1,9 @@
|
|
|
1
|
-
import { createFileRoute } from '@tanstack/react-router'
|
|
2
|
-
import { StatusDot } from '../components/StatusDot'
|
|
3
|
-
import { ExtraFieldsBadge } from '../components/ExtraFieldsBadge'
|
|
4
|
-
import { useProgressData } from '../contexts/ProgressContext'
|
|
5
|
-
import type { Task } from '../lib/types'
|
|
1
|
+
import { createFileRoute, Outlet } from '@tanstack/react-router'
|
|
6
2
|
|
|
7
3
|
export const Route = createFileRoute('/tasks')({
|
|
8
|
-
component:
|
|
4
|
+
component: TasksLayout,
|
|
9
5
|
})
|
|
10
6
|
|
|
11
|
-
function
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
if (!progressData) {
|
|
15
|
-
return (
|
|
16
|
-
<div className="p-6">
|
|
17
|
-
<p className="text-gray-600 text-sm">No data loaded</p>
|
|
18
|
-
</div>
|
|
19
|
-
)
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
const allTasks: Array<Task & { milestoneName: string }> = []
|
|
23
|
-
for (const milestone of progressData.milestones) {
|
|
24
|
-
const tasks = progressData.tasks[milestone.id] || []
|
|
25
|
-
for (const task of tasks) {
|
|
26
|
-
allTasks.push({ ...task, milestoneName: milestone.name })
|
|
27
|
-
}
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
return (
|
|
31
|
-
<div className="p-6">
|
|
32
|
-
<h2 className="text-lg font-semibold mb-4">
|
|
33
|
-
All Tasks ({allTasks.length})
|
|
34
|
-
</h2>
|
|
35
|
-
<div className="border border-gray-800 rounded-lg overflow-hidden">
|
|
36
|
-
{allTasks.map((task) => (
|
|
37
|
-
<div
|
|
38
|
-
key={task.id}
|
|
39
|
-
className="flex items-center gap-3 px-4 py-2.5 border-b border-gray-800/50 hover:bg-gray-800/30 transition-colors"
|
|
40
|
-
>
|
|
41
|
-
<StatusDot status={task.status} />
|
|
42
|
-
<span
|
|
43
|
-
className={`flex-1 text-sm ${
|
|
44
|
-
task.status === 'completed' ? 'text-gray-500' : 'text-gray-200'
|
|
45
|
-
}`}
|
|
46
|
-
>
|
|
47
|
-
{task.name}
|
|
48
|
-
</span>
|
|
49
|
-
<span className="text-xs text-gray-600">{task.milestoneName}</span>
|
|
50
|
-
<span className="text-xs text-gray-500 font-mono w-8 text-right">
|
|
51
|
-
{task.estimated_hours}h
|
|
52
|
-
</span>
|
|
53
|
-
<ExtraFieldsBadge fields={task.extra} />
|
|
54
|
-
</div>
|
|
55
|
-
))}
|
|
56
|
-
{allTasks.length === 0 && (
|
|
57
|
-
<div className="px-4 py-6 text-center">
|
|
58
|
-
<p className="text-gray-600 text-sm">No tasks defined</p>
|
|
59
|
-
</div>
|
|
60
|
-
)}
|
|
61
|
-
</div>
|
|
62
|
-
</div>
|
|
63
|
-
)
|
|
7
|
+
function TasksLayout() {
|
|
8
|
+
return <Outlet />
|
|
64
9
|
}
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import { createServerFn } from '@tanstack/react-start'
|
|
2
|
+
|
|
3
|
+
export type MarkdownResult =
|
|
4
|
+
| { ok: true; content: string; filePath: string }
|
|
5
|
+
| { ok: false; content: null; error: string }
|
|
6
|
+
|
|
7
|
+
export type ResolveFileResult =
|
|
8
|
+
| { ok: true; filePath: string }
|
|
9
|
+
| { ok: false; filePath: null; error: string }
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Derives the project base path from PROGRESS_YAML_PATH.
|
|
13
|
+
* e.g. "/home/user/project/agent/progress.yaml" → "/home/user/project"
|
|
14
|
+
*/
|
|
15
|
+
function getBasePath(): string {
|
|
16
|
+
const progressPath = process.env.PROGRESS_YAML_PATH || './agent/progress.yaml'
|
|
17
|
+
// Strip "agent/progress.yaml" (or similar trailing segment) to get project root
|
|
18
|
+
return progressPath.replace(/\/agent\/progress\.yaml$/, '') || '.'
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// ---------- getMarkdownContent ----------
|
|
22
|
+
|
|
23
|
+
export const getMarkdownContent = createServerFn({ method: 'GET' })
|
|
24
|
+
.inputValidator((input: { filePath: string; github?: { owner: string; repo: string; branch?: string; token?: string } }) => input)
|
|
25
|
+
.handler(async ({ data: input }): Promise<MarkdownResult> => {
|
|
26
|
+
// GitHub mode
|
|
27
|
+
if (input.github) {
|
|
28
|
+
return fetchMarkdownFromGitHub(input.filePath, input.github)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Local mode
|
|
32
|
+
return readMarkdownFromDisk(input.filePath)
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
async function readMarkdownFromDisk(relativePath: string): Promise<MarkdownResult> {
|
|
36
|
+
try {
|
|
37
|
+
const fs = await import('fs')
|
|
38
|
+
const path = await import('path')
|
|
39
|
+
|
|
40
|
+
const basePath = getBasePath()
|
|
41
|
+
const fullPath = path.resolve(basePath, relativePath)
|
|
42
|
+
|
|
43
|
+
if (!fs.existsSync(fullPath)) {
|
|
44
|
+
return { ok: false, content: null, error: `File not found: ${relativePath}` }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const content = fs.readFileSync(fullPath, 'utf-8')
|
|
48
|
+
return { ok: true, content, filePath: relativePath }
|
|
49
|
+
} catch (err: any) {
|
|
50
|
+
if (err?.code === 'ENOENT') {
|
|
51
|
+
return { ok: false, content: null, error: `File not found: ${relativePath}` }
|
|
52
|
+
}
|
|
53
|
+
if (err?.code === 'EACCES') {
|
|
54
|
+
return { ok: false, content: null, error: `Permission denied: ${relativePath}` }
|
|
55
|
+
}
|
|
56
|
+
return { ok: false, content: null, error: `Failed to read file: ${relativePath}` }
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function fetchMarkdownFromGitHub(
|
|
61
|
+
filePath: string,
|
|
62
|
+
github: { owner: string; repo: string; branch?: string; token?: string },
|
|
63
|
+
): Promise<MarkdownResult> {
|
|
64
|
+
try {
|
|
65
|
+
let branch = github.branch
|
|
66
|
+
if (!branch) {
|
|
67
|
+
try {
|
|
68
|
+
const metaRes = await fetch(`https://api.github.com/repos/${github.owner}/${github.repo}`, {
|
|
69
|
+
headers: github.token ? { Authorization: `token ${github.token}` } : {},
|
|
70
|
+
})
|
|
71
|
+
if (metaRes.ok) {
|
|
72
|
+
const meta = (await metaRes.json()) as { default_branch?: string }
|
|
73
|
+
branch = meta.default_branch || 'main'
|
|
74
|
+
} else {
|
|
75
|
+
branch = 'main'
|
|
76
|
+
}
|
|
77
|
+
} catch {
|
|
78
|
+
branch = 'main'
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const url = `https://api.github.com/repos/${github.owner}/${github.repo}/contents/${filePath}?ref=${branch}`
|
|
83
|
+
const headers: Record<string, string> = {
|
|
84
|
+
Accept: 'application/vnd.github.v3+json',
|
|
85
|
+
}
|
|
86
|
+
if (github.token) {
|
|
87
|
+
headers['Authorization'] = `token ${github.token}`
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const response = await fetch(url, { headers })
|
|
91
|
+
|
|
92
|
+
if (!response.ok) {
|
|
93
|
+
if (response.status === 404) {
|
|
94
|
+
return { ok: false, content: null, error: `File not found: ${filePath}` }
|
|
95
|
+
}
|
|
96
|
+
if (response.status === 403) {
|
|
97
|
+
return { ok: false, content: null, error: `GitHub rate limit or permission denied for: ${filePath}` }
|
|
98
|
+
}
|
|
99
|
+
return { ok: false, content: null, error: `GitHub returned ${response.status}: ${response.statusText}` }
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const json = (await response.json()) as { content?: string; encoding?: string }
|
|
103
|
+
if (!json.content) {
|
|
104
|
+
return { ok: false, content: null, error: `No content returned for: ${filePath}` }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const content = Buffer.from(json.content, 'base64').toString('utf-8')
|
|
108
|
+
return { ok: true, content, filePath }
|
|
109
|
+
} catch (err) {
|
|
110
|
+
return {
|
|
111
|
+
ok: false,
|
|
112
|
+
content: null,
|
|
113
|
+
error: err instanceof Error ? err.message : `Failed to fetch from GitHub: ${filePath}`,
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ---------- resolveMilestoneFile ----------
|
|
119
|
+
|
|
120
|
+
export const resolveMilestoneFile = createServerFn({ method: 'GET' })
|
|
121
|
+
.inputValidator((input: { milestoneId: string; github?: { owner: string; repo: string; branch?: string; token?: string } }) => input)
|
|
122
|
+
.handler(async ({ data: input }): Promise<ResolveFileResult> => {
|
|
123
|
+
// Extract numeric part: "milestone_1" → "1"
|
|
124
|
+
const match = input.milestoneId.match(/milestone_(\d+)/)
|
|
125
|
+
if (!match) {
|
|
126
|
+
return { ok: false, filePath: null, error: `Invalid milestone id format: ${input.milestoneId}` }
|
|
127
|
+
}
|
|
128
|
+
const num = match[1]
|
|
129
|
+
const dirPath = 'agent/milestones'
|
|
130
|
+
|
|
131
|
+
if (input.github) {
|
|
132
|
+
return resolveMilestoneFromGitHub(num, dirPath, input.github)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return resolveMilestoneFromDisk(num, dirPath)
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
async function resolveMilestoneFromDisk(num: string, dirPath: string): Promise<ResolveFileResult> {
|
|
139
|
+
try {
|
|
140
|
+
const fs = await import('fs')
|
|
141
|
+
const path = await import('path')
|
|
142
|
+
|
|
143
|
+
const basePath = getBasePath()
|
|
144
|
+
const fullDir = path.resolve(basePath, dirPath)
|
|
145
|
+
|
|
146
|
+
if (!fs.existsSync(fullDir)) {
|
|
147
|
+
return { ok: false, filePath: null, error: `Milestones directory not found: ${dirPath}` }
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const files = fs.readdirSync(fullDir)
|
|
151
|
+
const matched = files.find(
|
|
152
|
+
(f: string) => f.startsWith(`milestone-${num}-`) && f.endsWith('.md') && !f.includes('template'),
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
if (!matched) {
|
|
156
|
+
return { ok: false, filePath: null, error: `No milestone file found for milestone_${num}` }
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return { ok: true, filePath: `${dirPath}/${matched}` }
|
|
160
|
+
} catch (err: any) {
|
|
161
|
+
if (err?.code === 'EACCES') {
|
|
162
|
+
return { ok: false, filePath: null, error: `Permission denied: ${dirPath}` }
|
|
163
|
+
}
|
|
164
|
+
return { ok: false, filePath: null, error: `Failed to scan milestones directory` }
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async function resolveMilestoneFromGitHub(
|
|
169
|
+
num: string,
|
|
170
|
+
dirPath: string,
|
|
171
|
+
github: { owner: string; repo: string; branch?: string; token?: string },
|
|
172
|
+
): Promise<ResolveFileResult> {
|
|
173
|
+
try {
|
|
174
|
+
let branch = github.branch
|
|
175
|
+
if (!branch) {
|
|
176
|
+
try {
|
|
177
|
+
const metaRes = await fetch(`https://api.github.com/repos/${github.owner}/${github.repo}`, {
|
|
178
|
+
headers: github.token ? { Authorization: `token ${github.token}` } : {},
|
|
179
|
+
})
|
|
180
|
+
if (metaRes.ok) {
|
|
181
|
+
const meta = (await metaRes.json()) as { default_branch?: string }
|
|
182
|
+
branch = meta.default_branch || 'main'
|
|
183
|
+
} else {
|
|
184
|
+
branch = 'main'
|
|
185
|
+
}
|
|
186
|
+
} catch {
|
|
187
|
+
branch = 'main'
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const url = `https://api.github.com/repos/${github.owner}/${github.repo}/contents/${dirPath}?ref=${branch}`
|
|
192
|
+
const headers: Record<string, string> = {
|
|
193
|
+
Accept: 'application/vnd.github.v3+json',
|
|
194
|
+
}
|
|
195
|
+
if (github.token) {
|
|
196
|
+
headers['Authorization'] = `token ${github.token}`
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const response = await fetch(url, { headers })
|
|
200
|
+
|
|
201
|
+
if (!response.ok) {
|
|
202
|
+
if (response.status === 404) {
|
|
203
|
+
return { ok: false, filePath: null, error: `Milestones directory not found on GitHub: ${dirPath}` }
|
|
204
|
+
}
|
|
205
|
+
if (response.status === 403) {
|
|
206
|
+
return { ok: false, filePath: null, error: `GitHub rate limit or permission denied` }
|
|
207
|
+
}
|
|
208
|
+
return { ok: false, filePath: null, error: `GitHub returned ${response.status}: ${response.statusText}` }
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const entries = (await response.json()) as Array<{ name: string; path: string; type: string }>
|
|
212
|
+
const matched = entries.find(
|
|
213
|
+
(e) => e.name.startsWith(`milestone-${num}-`) && e.name.endsWith('.md') && !e.name.includes('template'),
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
if (!matched) {
|
|
217
|
+
return { ok: false, filePath: null, error: `No milestone file found for milestone_${num}` }
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return { ok: true, filePath: matched.path }
|
|
221
|
+
} catch (err) {
|
|
222
|
+
return {
|
|
223
|
+
ok: false,
|
|
224
|
+
filePath: null,
|
|
225
|
+
error: err instanceof Error ? err.message : `Failed to list milestones from GitHub`,
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// ---------- resolveTaskFile ----------
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Resolves the markdown file path for a task.
|
|
234
|
+
* This is a plain function (not a server function) since task data
|
|
235
|
+
* including the `file` field is already available client-side.
|
|
236
|
+
*/
|
|
237
|
+
export function resolveTaskFile(task: { file?: string } | null | undefined): string | null {
|
|
238
|
+
return task?.file || null
|
|
239
|
+
}
|