@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@prmichaelsen/acp-visualizer",
3
- "version": "0.5.3",
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
- <h4 className="text-sm font-medium leading-tight">{milestone.name}</h4>
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
- <span className="text-sm font-medium">{info.getValue()}</span>
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
- <span className="flex-1 text-sm font-medium">{milestone.name}</span>
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
- <span
20
- className={
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
- </span>
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}
@@ -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 MilestonesRoute
77
+ '/milestones': typeof MilestonesRouteWithChildren
54
78
  '/search': typeof SearchRoute
55
- '/tasks': typeof TasksRoute
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 MilestonesRoute
100
+ '/milestones': typeof MilestonesRouteWithChildren
71
101
  '/search': typeof SearchRoute
72
- '/tasks': typeof TasksRoute
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: '/' | '/activity' | '/milestones' | '/search' | '/tasks' | '/api/watch'
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 MilestonesRoute
149
+ MilestonesRoute: typeof MilestonesRouteWithChildren
100
150
  SearchRoute: typeof SearchRoute
101
- TasksRoute: typeof TasksRoute
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: MilestonesRoute,
259
+ MilestonesRoute: MilestonesRouteWithChildren,
156
260
  SearchRoute: SearchRoute,
157
- TasksRoute: 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: MilestonesPage,
4
+ component: MilestonesLayout,
20
5
  })
21
6
 
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
- )
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
+ }
@@ -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: TasksPage,
4
+ component: TasksLayout,
9
5
  })
10
6
 
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
- <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
+ }
package/src/styles.css CHANGED
@@ -1,4 +1,6 @@
1
1
  @import "tailwindcss";
2
+ @plugin "@tailwindcss/typography";
3
+ @import "highlight.js/styles/github-dark.css";
2
4
 
3
5
  html {
4
6
  @apply bg-gray-950;