@prmichaelsen/acp-visualizer 0.2.1 → 0.5.2

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/src/lib/types.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /** Status values for milestones and tasks */
2
- export type Status = 'completed' | 'in_progress' | 'not_started'
2
+ export type Status = 'completed' | 'in_progress' | 'not_started' | 'wont_do'
3
3
 
4
4
  /** Unknown properties from agent-maintained YAML are preserved here */
5
5
  export type ExtraFields = Record<string, unknown>
@@ -37,11 +37,64 @@ describe('parseProgressYaml with real files', () => {
37
37
  expect(totalTasks).toBeGreaterThan(0)
38
38
 
39
39
  for (const m of result.milestones) {
40
- expect(['completed', 'in_progress', 'not_started']).toContain(m.status)
40
+ expect(['completed', 'in_progress', 'not_started', 'wont_do']).toContain(m.status)
41
41
  }
42
42
 
43
43
  console.log(
44
44
  `Parsed: ${result.milestones.length} milestones, ${totalTasks} tasks, ${result.recent_work.length} work entries`,
45
45
  )
46
46
  })
47
+
48
+ it('parses agentbase.me progress.yaml with inline milestones and standalone tasks', () => {
49
+ const path = '/home/prmichaelsen/.acp/projects/agentbase.me/agent/progress.yaml'
50
+ let raw: string
51
+ try {
52
+ raw = readFileSync(path, 'utf-8')
53
+ } catch {
54
+ console.log('Skipping — agentbase.me progress.yaml not found')
55
+ return
56
+ }
57
+
58
+ const result = parseProgressYaml(raw)
59
+
60
+ // Should find project metadata
61
+ expect(result.project.name).toBe('agentbase.me')
62
+
63
+ // Should find inline milestones (milestone_1_firebase_analytics, etc.)
64
+ expect(result.milestones.length).toBeGreaterThan(0)
65
+ const milestoneIds = result.milestones.map((m) => m.id)
66
+ expect(milestoneIds).toContain('M1') // from milestone_1_firebase_analytics
67
+ expect(milestoneIds).toContain('M47') // from milestone_47_agent_memory_system
68
+
69
+ // Should find standard milestones too
70
+ expect(milestoneIds).toContain('M19') // from milestones: array
71
+ expect(milestoneIds).toContain('M21')
72
+
73
+ // Should find tasks from inline milestones
74
+ const m1Tasks = result.tasks['M1'] || []
75
+ expect(m1Tasks.length).toBeGreaterThan(0)
76
+ // Task 79 has wont_do status
77
+ const wontDoTask = m1Tasks.find((t) => String(t.id) === '79')
78
+ if (wontDoTask) {
79
+ expect(wontDoTask.status).toBe('wont_do')
80
+ }
81
+
82
+ // Should find standalone/unassigned tasks
83
+ const unassigned = result.tasks['_unassigned'] || []
84
+ expect(unassigned.length).toBeGreaterThan(0)
85
+ // Synthetic milestone should exist
86
+ expect(milestoneIds).toContain('_unassigned')
87
+
88
+ const totalTasks = Object.values(result.tasks).reduce(
89
+ (sum, ts) => sum + ts.length,
90
+ 0,
91
+ )
92
+
93
+ // Should collect next_steps from next_immediate_steps too
94
+ expect(result.next_steps.length).toBeGreaterThan(0)
95
+
96
+ console.log(
97
+ `Parsed agentbase.me: ${result.milestones.length} milestones, ${totalTasks} tasks, ${unassigned.length} unassigned`,
98
+ )
99
+ })
47
100
  })
@@ -47,6 +47,7 @@ function normalizeStatus(value: unknown): Status {
47
47
  .replace(/[\s-]/g, '_')
48
48
  if (s === 'completed' || s === 'done' || s === 'complete') return 'completed'
49
49
  if (s === 'in_progress' || s === 'active' || s === 'wip' || s === 'started') return 'in_progress'
50
+ if (s === 'wont_do' || s === 'won_t_do' || s === 'skipped' || s === 'cancelled' || s === 'canceled') return 'wont_do'
50
51
  return 'not_started'
51
52
  }
52
53
 
@@ -56,7 +57,9 @@ function safeString(value: unknown, fallback = ''): string {
56
57
  }
57
58
 
58
59
  function safeNumber(value: unknown, fallback = 0): number {
59
- const n = Number(value)
60
+ // Strip trailing '%' (e.g. "100%" → "100") before parsing
61
+ const cleaned = typeof value === 'string' ? value.replace(/%$/, '') : value
62
+ const n = Number(cleaned)
60
63
  return Number.isFinite(n) ? n : fallback
61
64
  }
62
65
 
@@ -267,6 +270,84 @@ const EMPTY_PROGRESS_DATA: ProgressData = {
267
270
  progress: { planning: 0, implementation: 0, overall: 0 },
268
271
  }
269
272
 
273
+ // --- Inline milestone extraction ---
274
+ // Some progress.yaml files use top-level keys like `milestone_1_firebase_analytics:`
275
+ // containing milestone metadata + inline `tasks:` arrays.
276
+
277
+ const INLINE_MILESTONE_PATTERN = /^milestone_(\d+)_/
278
+
279
+ function extractInlineMilestones(d: Record<string, unknown>): {
280
+ milestones: Milestone[]
281
+ tasks: Record<string, Task[]>
282
+ } {
283
+ const milestones: Milestone[] = []
284
+ const tasks: Record<string, Task[]> = {}
285
+
286
+ for (const [key, value] of Object.entries(d)) {
287
+ const match = key.match(INLINE_MILESTONE_PATTERN)
288
+ if (!match) continue
289
+ const obj = asRecord(value)
290
+ // Extract milestone ID from the name field (e.g. "M1 - ...") or synthesize from key
291
+ const nameStr = safeString(obj.name)
292
+ const idFromName = nameStr.match(/^(M\d+)\b/)?.[1]
293
+ const milestoneId = idFromName || `M${match[1]}`
294
+
295
+ const milestone = normalizeMilestone(
296
+ { ...obj, id: milestoneId },
297
+ milestones.length,
298
+ )
299
+ milestones.push(milestone)
300
+
301
+ // Extract inline tasks if present
302
+ if (Array.isArray(obj.tasks)) {
303
+ tasks[milestoneId] = obj.tasks.map((t, i) =>
304
+ normalizeTask(t, milestoneId, i),
305
+ )
306
+ }
307
+ }
308
+
309
+ return { milestones, tasks }
310
+ }
311
+
312
+ // --- Standalone / unassigned task extraction ---
313
+
314
+ function extractLooseTasks(
315
+ d: Record<string, unknown>,
316
+ keys: string[],
317
+ ): Task[] {
318
+ const result: Task[] = []
319
+ for (const key of keys) {
320
+ const arr = d[key]
321
+ if (Array.isArray(arr)) {
322
+ result.push(
323
+ ...arr.map((t, i) => normalizeTask(t, '_unassigned', result.length + i)),
324
+ )
325
+ }
326
+ }
327
+ return result
328
+ }
329
+
330
+ // --- next_steps from multiple sources ---
331
+
332
+ function collectNextSteps(d: Record<string, unknown>): string[] {
333
+ const steps = normalizeStringArray(d.next_steps)
334
+ // next_immediate_steps may be a structured object with sub-keys or an array
335
+ const immediate = d.next_immediate_steps
336
+ if (immediate) {
337
+ if (Array.isArray(immediate)) {
338
+ steps.push(...immediate.map(String))
339
+ } else if (typeof immediate === 'object' && immediate !== null) {
340
+ // Structured object like { ready_to_implement: [...], security: [...] }
341
+ for (const arr of Object.values(immediate)) {
342
+ if (Array.isArray(arr)) {
343
+ steps.push(...arr.map(String))
344
+ }
345
+ }
346
+ }
347
+ }
348
+ return steps
349
+ }
350
+
270
351
  export function parseProgressYaml(raw: string): ProgressData {
271
352
  try {
272
353
  // json: true allows duplicated keys (last wins) — common in agent-maintained YAML
@@ -276,14 +357,62 @@ export function parseProgressYaml(raw: string): ProgressData {
276
357
  }
277
358
  const d = doc as Record<string, unknown>
278
359
 
279
- const milestones = normalizeMilestones(d.milestones)
360
+ // Standard milestones array
361
+ const standardMilestones = normalizeMilestones(d.milestones)
362
+
363
+ // Inline milestone_N_* top-level keys
364
+ const inline = extractInlineMilestones(d)
365
+
366
+ // Merge: inline milestones first (they tend to be older/lower-numbered),
367
+ // then standard milestones. Deduplicate by ID (standard wins).
368
+ const seenIds = new Set<string>()
369
+ const allMilestones: Milestone[] = []
370
+ for (const m of [...inline.milestones, ...standardMilestones]) {
371
+ if (!seenIds.has(m.id)) {
372
+ seenIds.add(m.id)
373
+ allMilestones.push(m)
374
+ }
375
+ }
376
+
377
+ // Standard tasks (from top-level `tasks:` key — last-wins with json: true)
378
+ const standardTasks = normalizeTasks(d.tasks, allMilestones)
379
+
380
+ // Merge inline tasks with standard tasks (standard wins on conflict)
381
+ const allTasks: Record<string, Task[]> = { ...inline.tasks }
382
+ for (const [key, tasks] of Object.entries(standardTasks)) {
383
+ allTasks[key] = tasks // standard overwrites inline for same key
384
+ }
385
+
386
+ // Standalone and unassigned tasks
387
+ const looseTasks = extractLooseTasks(d, ['standalone_tasks', 'unassigned_tasks'])
388
+ if (looseTasks.length > 0) {
389
+ const existing = allTasks['_unassigned'] || []
390
+ allTasks['_unassigned'] = [...existing, ...looseTasks]
391
+ // Add a synthetic milestone for unassigned tasks if not already present
392
+ if (!seenIds.has('_unassigned')) {
393
+ seenIds.add('_unassigned')
394
+ allMilestones.push({
395
+ id: '_unassigned',
396
+ name: 'Unassigned Tasks',
397
+ status: 'in_progress',
398
+ progress: 0,
399
+ started: null,
400
+ completed: null,
401
+ estimated_weeks: '0',
402
+ tasks_completed: looseTasks.filter((t) => t.status === 'completed').length,
403
+ tasks_total: looseTasks.length,
404
+ notes: 'Tasks not assigned to a specific milestone',
405
+ extra: { synthetic: true },
406
+ })
407
+ }
408
+ }
280
409
 
281
410
  return {
282
411
  project: normalizeProject(d.project),
283
- milestones,
284
- tasks: normalizeTasks(d.tasks, milestones),
412
+ milestones: allMilestones,
413
+ tasks: allTasks,
285
414
  recent_work: normalizeWorkEntries(d.recent_work),
286
- next_steps: normalizeStringArray(d.next_steps),
415
+ next_steps: collectNextSteps(d),
287
416
  notes: normalizeStringArray(d.notes),
288
417
  current_blockers: normalizeStringArray(d.current_blockers),
289
418
  documentation: normalizeDocStats(d.documentation),
@@ -1,26 +1,43 @@
1
- import { HeadContent, Scripts, createRootRoute, Outlet } from '@tanstack/react-router'
1
+ import { HeadContent, Scripts, createRootRoute, Outlet, useRouter, useRouterState } from '@tanstack/react-router'
2
+ import { useState, useCallback, useEffect } from 'react'
2
3
  import { useAutoRefresh } from '../lib/useAutoRefresh'
3
4
  import { Sidebar } from '../components/Sidebar'
4
5
  import { Header } from '../components/Header'
5
6
  import { getProgressData } from '../services/progress-database.service'
7
+ import { listProjects, getProjectProgressPath } from '../services/projects.service'
8
+ import { fetchGitHubProgress } from '../services/github.service'
6
9
  import type { ProgressData } from '../lib/types'
10
+ import type { AcpProject } from '../services/projects.service'
11
+ import { ProgressProvider } from '../contexts/ProgressContext'
7
12
 
8
13
  import appCss from '../styles.css?url'
9
14
 
10
15
  export const Route = createRootRoute({
16
+ component: RootLayout,
17
+ notFoundComponent: NotFound,
18
+ shellComponent: RootDocument,
19
+
11
20
  beforeLoad: async () => {
12
21
  let progressData: ProgressData | null = null
22
+ let projects: AcpProject[] = []
13
23
 
14
- try {
15
- const result = await getProgressData()
16
- if (result.ok) {
17
- progressData = result.data
24
+ // VITE_HOSTED mode: skip filesystem access entirely (Cloudflare Workers)
25
+ if (!import.meta.env.VITE_HOSTED) {
26
+ try {
27
+ const [result, projectList] = await Promise.all([
28
+ getProgressData({ data: {} }),
29
+ listProjects(),
30
+ ])
31
+ if (result.ok) {
32
+ progressData = result.data
33
+ }
34
+ projects = projectList
35
+ } catch {
36
+ // No filesystem available
18
37
  }
19
- } catch (error) {
20
- console.error('[Root] Failed to load progress data:', error)
21
38
  }
22
39
 
23
- return { progressData }
40
+ return { progressData, projects }
24
41
  },
25
42
 
26
43
  head: () => ({
@@ -40,34 +57,131 @@ export const Route = createRootRoute({
40
57
  { rel: 'stylesheet', href: appCss },
41
58
  ],
42
59
  }),
43
-
44
- shellComponent: RootDocument,
45
60
  })
46
61
 
62
+ function NotFound() {
63
+ return (
64
+ <div className="flex items-center justify-center h-full">
65
+ <div className="text-center">
66
+ <h2 className="text-xl font-semibold text-gray-200 mb-2">Page Not Found</h2>
67
+ <p className="text-sm text-gray-400">
68
+ The page you're looking for doesn't exist.
69
+ </p>
70
+ </div>
71
+ </div>
72
+ )
73
+ }
74
+
47
75
  function AutoRefresh() {
48
76
  useAutoRefresh()
49
77
  return null
50
78
  }
51
79
 
52
- function RootDocument({ children }: { children: React.ReactNode }) {
53
- const { progressData } = Route.useRouteContext()
80
+ /** Read ?repo=owner/repo from current URL search params */
81
+ function getRepoFromUrl(): { owner: string; repo: string } | null {
82
+ if (typeof window === 'undefined') return null
83
+ const params = new URLSearchParams(window.location.search)
84
+ const repo = params.get('repo')
85
+ if (!repo) return null
86
+ const parts = repo.split('/')
87
+ if (parts.length < 2) return null
88
+ return { owner: parts[0], repo: parts[1] }
89
+ }
90
+
91
+ /** Update ?repo= search param without full navigation */
92
+ function setRepoInUrl(ownerRepo: string | null) {
93
+ if (typeof window === 'undefined') return
94
+ const url = new URL(window.location.href)
95
+ if (ownerRepo) {
96
+ url.searchParams.set('repo', ownerRepo)
97
+ } else {
98
+ url.searchParams.delete('repo')
99
+ }
100
+ window.history.replaceState({}, '', url.toString())
101
+ }
102
+
103
+ function RootLayout() {
104
+ const context = Route.useRouteContext()
105
+ const [progressData, setProgressData] = useState(context.progressData)
106
+ const [currentProject, setCurrentProject] = useState<string | null>(
107
+ context.progressData?.project.name || null,
108
+ )
109
+ const [initialLoadDone, setInitialLoadDone] = useState(false)
110
+
111
+ // On mount, check for ?repo= param and auto-load
112
+ useEffect(() => {
113
+ if (initialLoadDone) return
114
+ setInitialLoadDone(true)
115
+
116
+ const repoParam = getRepoFromUrl()
117
+ if (repoParam && !progressData) {
118
+ fetchGitHubProgress({ data: repoParam }).then((result) => {
119
+ if (result.ok) {
120
+ setProgressData(result.data)
121
+ setCurrentProject(`${repoParam.owner}/${repoParam.repo}`)
122
+ }
123
+ })
124
+ }
125
+ }, [initialLoadDone, progressData])
126
+
127
+ const handleGitHubLoad = useCallback(async (owner: string, repo: string) => {
128
+ const result = await fetchGitHubProgress({ data: { owner, repo } })
129
+ if (result.ok) {
130
+ setProgressData(result.data)
131
+ setCurrentProject(`${owner}/${repo}`)
132
+ setRepoInUrl(`${owner}/${repo}`)
133
+ } else {
134
+ throw new Error(result.message)
135
+ }
136
+ }, [])
137
+
138
+ const handleProjectSwitch = useCallback(async (projectId: string) => {
139
+ try {
140
+ const path = await getProjectProgressPath({ data: { projectId } })
141
+ if (path) {
142
+ const result = await getProgressData({ data: { path } })
143
+ if (result.ok) {
144
+ setProgressData(result.data)
145
+ setCurrentProject(projectId)
146
+ setRepoInUrl(null) // Clear repo param for local projects
147
+ }
148
+ }
149
+ } catch {
150
+ // Project switch failed — likely no filesystem access
151
+ }
152
+ }, [])
54
153
 
55
154
  return (
56
- <html lang="en">
57
- <head>
58
- <HeadContent />
59
- </head>
60
- <body>
61
- <AutoRefresh />
62
- <div className="flex h-screen bg-gray-950 text-gray-100">
63
- <Sidebar />
155
+ <>
156
+ <AutoRefresh />
157
+ <div className="flex h-screen bg-gray-950 text-gray-100">
158
+ <Sidebar
159
+ projects={context.projects}
160
+ currentProject={currentProject}
161
+ onProjectSelect={handleProjectSwitch}
162
+ onGitHubLoad={handleGitHubLoad}
163
+ />
164
+ <ProgressProvider data={progressData}>
64
165
  <div className="flex-1 flex flex-col overflow-hidden">
65
166
  <Header data={progressData} />
66
167
  <main className="flex-1 overflow-auto">
67
- {children}
168
+ <Outlet />
68
169
  </main>
69
170
  </div>
70
- </div>
171
+ </ProgressProvider>
172
+ </div>
173
+ </>
174
+ )
175
+ }
176
+
177
+ function RootDocument({ children }: { children: React.ReactNode }) {
178
+ return (
179
+ <html lang="en">
180
+ <head>
181
+ <HeadContent />
182
+ </head>
183
+ <body>
184
+ {children}
71
185
  <Scripts />
72
186
  </body>
73
187
  </html>
@@ -1,12 +1,12 @@
1
1
  import { createFileRoute } from '@tanstack/react-router'
2
- import { Clock } from 'lucide-react'
2
+ import { useProgressData } from '../contexts/ProgressContext'
3
3
 
4
4
  export const Route = createFileRoute('/activity')({
5
5
  component: ActivityPage,
6
6
  })
7
7
 
8
8
  function ActivityPage() {
9
- const { progressData } = Route.useRouteContext()
9
+ const progressData = useProgressData()
10
10
 
11
11
  if (!progressData) {
12
12
  return (
@@ -2,22 +2,39 @@ import { createFileRoute } from '@tanstack/react-router'
2
2
  import { StatusBadge } from '../components/StatusBadge'
3
3
  import { ProgressBar } from '../components/ProgressBar'
4
4
  import { BurndownChart } from '../components/BurndownChart'
5
+ import { useProgressData } from '../contexts/ProgressContext'
5
6
 
6
7
  export const Route = createFileRoute('/')({
7
8
  component: HomePage,
8
9
  })
9
10
 
10
11
  function HomePage() {
11
- const { progressData } = Route.useRouteContext()
12
+ const progressData = useProgressData()
12
13
 
13
14
  if (!progressData) {
15
+ const isHosted = import.meta.env.VITE_HOSTED
16
+
14
17
  return (
15
18
  <div className="p-6">
16
- <div className="p-4 bg-red-900/20 border border-red-800 rounded-lg">
17
- <p className="text-red-400 font-medium">Failed to load progress data</p>
18
- <p className="text-red-400/70 text-sm mt-1">
19
- Check that progress.yaml exists and is valid YAML
20
- </p>
19
+ <div className="p-5 bg-gray-900/50 border border-gray-800 rounded-xl text-center max-w-md mx-auto mt-12">
20
+ <h2 className="text-lg font-semibold mb-2">ACP Progress Visualizer</h2>
21
+ {isHosted ? (
22
+ <>
23
+ <p className="text-gray-400 text-sm mb-4">
24
+ Enter a GitHub repository in the sidebar to visualize its ACP progress.
25
+ </p>
26
+ <div className="text-xs text-gray-600 space-y-1">
27
+ <p>Example: <span className="font-mono text-gray-400">prmichaelsen/agent-context-protocol</span></p>
28
+ <p>The repo must have <span className="font-mono text-gray-400">agent/progress.yaml</span></p>
29
+ </div>
30
+ </>
31
+ ) : (
32
+ <>
33
+ <p className="text-red-400/70 text-sm">
34
+ No progress.yaml found. Make sure you're in an ACP project directory.
35
+ </p>
36
+ </>
37
+ )}
21
38
  </div>
22
39
  </div>
23
40
  )
@@ -1,20 +1,26 @@
1
1
  import { createFileRoute } from '@tanstack/react-router'
2
- import { useState } from 'react'
2
+ import { useState, lazy, Suspense } from 'react'
3
3
  import { MilestoneTable } from '../components/MilestoneTable'
4
4
  import { MilestoneTree } from '../components/MilestoneTree'
5
5
  import { MilestoneKanban } from '../components/MilestoneKanban'
6
+ import { MilestoneGantt } from '../components/MilestoneGantt'
6
7
  import { ViewToggle, type ViewMode } from '../components/ViewToggle'
7
8
  import { FilterBar } from '../components/FilterBar'
8
9
  import { SearchInput } from '../components/SearchInput'
9
10
  import { useFilteredData } from '../lib/useFilteredData'
11
+ import { useProgressData } from '../contexts/ProgressContext'
10
12
  import type { Status } from '../lib/types'
11
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
+
12
18
  export const Route = createFileRoute('/milestones')({
13
19
  component: MilestonesPage,
14
20
  })
15
21
 
16
22
  function MilestonesPage() {
17
- const { progressData } = Route.useRouteContext()
23
+ const progressData = useProgressData()
18
24
  const [view, setView] = useState<ViewMode>('table')
19
25
  const [status, setStatus] = useState<Status | 'all'>('all')
20
26
  const [search, setSearch] = useState('')
@@ -47,8 +53,14 @@ function MilestonesPage() {
47
53
  <MilestoneTable milestones={filtered.milestones} tasks={filtered.tasks} />
48
54
  ) : view === 'tree' ? (
49
55
  <MilestoneTree milestones={filtered.milestones} tasks={filtered.tasks} />
50
- ) : (
56
+ ) : view === 'kanban' ? (
51
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>
52
64
  )}
53
65
  </div>
54
66
  )
@@ -4,13 +4,14 @@ import { SearchInput } from '../components/SearchInput'
4
4
  import { StatusBadge } from '../components/StatusBadge'
5
5
  import { StatusDot } from '../components/StatusDot'
6
6
  import { buildSearchIndex } from '../lib/search'
7
+ import { useProgressData } from '../contexts/ProgressContext'
7
8
 
8
9
  export const Route = createFileRoute('/search')({
9
10
  component: SearchPage,
10
11
  })
11
12
 
12
13
  function SearchPage() {
13
- const { progressData } = Route.useRouteContext()
14
+ const progressData = useProgressData()
14
15
  const [query, setQuery] = useState('')
15
16
 
16
17
  const results = useMemo(() => {
@@ -1,6 +1,7 @@
1
1
  import { createFileRoute } from '@tanstack/react-router'
2
2
  import { StatusDot } from '../components/StatusDot'
3
3
  import { ExtraFieldsBadge } from '../components/ExtraFieldsBadge'
4
+ import { useProgressData } from '../contexts/ProgressContext'
4
5
  import type { Task } from '../lib/types'
5
6
 
6
7
  export const Route = createFileRoute('/tasks')({
@@ -8,7 +9,7 @@ export const Route = createFileRoute('/tasks')({
8
9
  })
9
10
 
10
11
  function TasksPage() {
11
- const { progressData } = Route.useRouteContext()
12
+ const progressData = useProgressData()
12
13
 
13
14
  if (!progressData) {
14
15
  return (
package/src/server.ts ADDED
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Cloudflare Workers Entry Point
3
+ *
4
+ * Minimal server — passes all requests to TanStack Start.
5
+ */
6
+
7
+ import startServer from '@tanstack/react-start/server-entry'
8
+
9
+ export default {
10
+ async fetch(request: Request, env: unknown, ctx: unknown) {
11
+ return (startServer as any).fetch(request, env, ctx)
12
+ },
13
+ }
@@ -0,0 +1,51 @@
1
+ import { createServerFn } from '@tanstack/react-start'
2
+ import type { ProgressData } from '../lib/types'
3
+
4
+ export type GitHubResult =
5
+ | { ok: true; data: ProgressData }
6
+ | { ok: false; error: string; message: string }
7
+
8
+ export const fetchGitHubProgress = createServerFn({ method: 'GET' })
9
+ .inputValidator((input: { owner: string; repo: string; branch?: string; token?: string }) => input)
10
+ .handler(async ({ data: input }): Promise<GitHubResult> => {
11
+ const { parseProgressYaml } = await import('../lib/yaml-loader')
12
+
13
+ const branch = input.branch || 'main'
14
+ const url = `https://raw.githubusercontent.com/${input.owner}/${input.repo}/${branch}/agent/progress.yaml`
15
+
16
+ try {
17
+ const headers: Record<string, string> = {
18
+ 'Accept': 'text/plain',
19
+ }
20
+ if (input.token) {
21
+ headers['Authorization'] = `token ${input.token}`
22
+ }
23
+
24
+ const response = await fetch(url, { headers })
25
+
26
+ if (!response.ok) {
27
+ if (response.status === 404) {
28
+ return {
29
+ ok: false,
30
+ error: 'NOT_FOUND',
31
+ message: `No progress.yaml found at ${input.owner}/${input.repo} (branch: ${branch})`,
32
+ }
33
+ }
34
+ return {
35
+ ok: false,
36
+ error: 'FETCH_ERROR',
37
+ message: `GitHub returned ${response.status}: ${response.statusText}`,
38
+ }
39
+ }
40
+
41
+ const raw = await response.text()
42
+ const data = parseProgressYaml(raw)
43
+ return { ok: true, data }
44
+ } catch (err) {
45
+ return {
46
+ ok: false,
47
+ error: 'NETWORK_ERROR',
48
+ message: err instanceof Error ? err.message : 'Failed to fetch from GitHub',
49
+ }
50
+ }
51
+ })