@prmichaelsen/acp-visualizer 0.8.1 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,5 @@
1
1
  import { Link, useRouterState } from '@tanstack/react-router'
2
- import { LayoutDashboard, Flag, CheckSquare, Clock, Search } from 'lucide-react'
2
+ import { LayoutDashboard, Flag, CheckSquare, Clock, Search, PenTool, Puzzle, FileBarChart } from 'lucide-react'
3
3
  import { ProjectSelector } from './ProjectSelector'
4
4
  import { GitHubInput } from './GitHubInput'
5
5
  import type { AcpProject } from '../services/projects.service'
@@ -9,6 +9,9 @@ const navItems = [
9
9
  { to: '/milestones' as const, icon: Flag, label: 'Milestones' },
10
10
  { to: '/tasks' as const, icon: CheckSquare, label: 'Tasks' },
11
11
  { to: '/activity' as const, icon: Clock, label: 'Activity' },
12
+ { to: '/designs' as const, icon: PenTool, label: 'Designs' },
13
+ { to: '/patterns' as const, icon: Puzzle, label: 'Patterns' },
14
+ { to: '/reports' as const, icon: FileBarChart, label: 'Reports' },
12
15
  ]
13
16
 
14
17
  interface SidebarProps {
@@ -22,9 +25,9 @@ export function Sidebar({ projects = [], currentProject = null, onProjectSelect,
22
25
  const location = useRouterState({ select: (s) => s.location })
23
26
 
24
27
  return (
25
- <nav className="w-56 border-r border-gray-800 bg-gray-950 flex flex-col shrink-0">
26
- <div className="p-4 border-b border-gray-800">
27
- <span className="text-sm font-semibold text-gray-300 tracking-wide">
28
+ <nav className="w-56 border-r border-gray-200 dark:border-gray-800 bg-gray-100 dark:bg-gray-950 flex flex-col shrink-0">
29
+ <div className="p-4 border-b border-gray-200 dark:border-gray-800">
30
+ <span className="text-sm font-semibold text-gray-700 dark:text-gray-300 tracking-wide">
28
31
  ACP Visualizer
29
32
  </span>
30
33
  </div>
@@ -50,8 +53,8 @@ export function Sidebar({ projects = [], currentProject = null, onProjectSelect,
50
53
  to={item.to}
51
54
  className={`flex items-center gap-3 px-4 py-2 text-sm transition-colors ${
52
55
  isActive
53
- ? 'text-gray-100 bg-gray-800/50'
54
- : 'text-gray-400 hover:text-gray-200 hover:bg-gray-800/30'
56
+ ? 'text-gray-900 dark:text-gray-100 bg-gray-200 dark:bg-gray-800/50'
57
+ : 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 hover:bg-gray-200/50 dark:hover:bg-gray-800/30'
55
58
  }`}
56
59
  >
57
60
  <item.icon className="w-4 h-4" />
@@ -60,10 +63,10 @@ export function Sidebar({ projects = [], currentProject = null, onProjectSelect,
60
63
  )
61
64
  })}
62
65
  </div>
63
- <div className="p-3 border-t border-gray-800 space-y-2">
66
+ <div className="p-3 border-t border-gray-200 dark:border-gray-800 space-y-2">
64
67
  <Link
65
68
  to="/search"
66
- className="flex items-center gap-2 px-3 py-1.5 text-sm text-gray-500 bg-gray-900 border border-gray-800 rounded-md hover:text-gray-300 hover:border-gray-600 transition-colors"
69
+ className="flex items-center gap-2 px-3 py-1.5 text-sm text-gray-600 dark:text-gray-500 bg-white dark:bg-gray-900 border border-gray-300 dark:border-gray-800 rounded-md hover:text-gray-900 dark:hover:text-gray-300 hover:border-gray-400 dark:hover:border-gray-600 transition-colors"
67
70
  >
68
71
  <Search className="w-4 h-4" />
69
72
  Search...
@@ -1,5 +1,7 @@
1
1
  import { Link } from '@tanstack/react-router'
2
2
  import { StatusDot } from './StatusDot'
3
+ import { PriorityBadge } from './PriorityBadge'
4
+ import { PreviewButton } from './PreviewButton'
3
5
  import { ExtraFieldsBadge } from './ExtraFieldsBadge'
4
6
  import type { Task } from '../lib/types'
5
7
 
@@ -7,7 +9,7 @@ export function TaskList({ tasks }: { tasks: Task[] }) {
7
9
  if (tasks.length === 0) {
8
10
  return (
9
11
  <div className="pl-6 py-2">
10
- <span className="text-xs text-gray-600">No tasks</span>
12
+ <span className="text-xs text-gray-600 dark:text-gray-600">No tasks</span>
11
13
  </div>
12
14
  )
13
15
  }
@@ -15,19 +17,21 @@ export function TaskList({ tasks }: { tasks: Task[] }) {
15
17
  return (
16
18
  <div className="pl-6 py-1 space-y-0.5">
17
19
  {tasks.map((task) => (
18
- <div key={task.id} className="flex items-center gap-2 py-1 text-sm">
20
+ <div key={task.id} className="flex items-center gap-2 py-1 text-sm group">
19
21
  <StatusDot status={task.status} />
20
22
  <Link
21
23
  to="/tasks/$taskId"
22
24
  params={{ taskId: task.id }}
23
- className={`hover:text-blue-400 transition-colors ${
24
- task.status === 'completed' ? 'text-gray-500' : 'text-gray-200'
25
+ className={`hover:text-blue-500 dark:hover:text-blue-400 transition-colors ${
26
+ task.status === 'completed' ? 'text-gray-500 dark:text-gray-500' : 'text-gray-900 dark:text-gray-200'
25
27
  }`}
26
28
  >
27
29
  {task.name}
28
30
  </Link>
31
+ <PreviewButton type="task" id={task.id} />
32
+ <PriorityBadge priority={task.priority} />
29
33
  {task.notes && (
30
- <span className="text-xs text-gray-600 ml-auto truncate max-w-[200px]">
34
+ <span className="text-xs text-gray-600 dark:text-gray-600 ml-auto truncate max-w-[200px]">
31
35
  {task.notes}
32
36
  </span>
33
37
  )}
@@ -0,0 +1,147 @@
1
+ import { Link } from '@tanstack/react-router'
2
+ import { useState, useEffect, useMemo } from 'react'
3
+ import { ExternalLink } from 'lucide-react'
4
+ import { useProgressData } from '../contexts/ProgressContext'
5
+ import { DetailHeader } from './DetailHeader'
6
+ import { PriorityBadge } from './PriorityBadge'
7
+ import { MarkdownContent, buildLinkMap } from './MarkdownContent'
8
+ import { getMarkdownContent, resolveTaskFile } from '../services/markdown.service'
9
+ import type { MarkdownResult } from '../services/markdown.service'
10
+
11
+ interface TaskPreviewProps {
12
+ taskId: string
13
+ }
14
+
15
+ function getGitHubParams(): { owner: string; repo: string } | undefined {
16
+ if (typeof window === 'undefined') return undefined
17
+ const params = new URLSearchParams(window.location.search)
18
+ const repo = params.get('repo')
19
+ if (!repo) return undefined
20
+ const parts = repo.split('/')
21
+ if (parts.length < 2) return undefined
22
+ return { owner: parts[0], repo: parts[1] }
23
+ }
24
+
25
+ export function TaskPreview({ taskId }: TaskPreviewProps) {
26
+ const data = useProgressData()
27
+ const [markdown, setMarkdown] = useState<string | null>(null)
28
+ const [markdownError, setMarkdownError] = useState<string | null>(null)
29
+ const [loading, setLoading] = useState(true)
30
+
31
+ const { task, milestone } = useMemo(() => {
32
+ if (!data) return { task: null, milestone: null }
33
+
34
+ for (const ms of data.milestones) {
35
+ const msTaskList = data.tasks[ms.id] || []
36
+ const foundTask = msTaskList.find((t) => t.id === taskId)
37
+ if (foundTask) {
38
+ return { task: foundTask, milestone: ms }
39
+ }
40
+ }
41
+ return { task: null, milestone: null }
42
+ }, [data, taskId])
43
+
44
+ useEffect(() => {
45
+ if (!task) return
46
+
47
+ setLoading(true)
48
+ setMarkdown(null)
49
+ setMarkdownError(null)
50
+
51
+ const filePath = resolveTaskFile(task)
52
+ if (!filePath) {
53
+ setMarkdownError('No file path for this task')
54
+ setLoading(false)
55
+ return
56
+ }
57
+
58
+ const github = getGitHubParams()
59
+
60
+ getMarkdownContent({ data: { filePath, github } })
61
+ .then((result: MarkdownResult) => {
62
+ if (result.ok) {
63
+ setMarkdown(result.content)
64
+ } else {
65
+ setMarkdownError(result.error)
66
+ }
67
+ })
68
+ .catch((err: Error) => {
69
+ setMarkdownError(err.message)
70
+ })
71
+ .finally(() => {
72
+ setLoading(false)
73
+ })
74
+ }, [task])
75
+
76
+ const linkMap = useMemo(() => (data ? buildLinkMap(data) : {}), [data])
77
+ const taskFilePath = useMemo(() => resolveTaskFile(task), [task])
78
+
79
+ if (!data || !task || !milestone) {
80
+ return (
81
+ <div className="text-center py-8">
82
+ <p className="text-gray-500 dark:text-gray-400 text-sm">Task not found: {taskId}</p>
83
+ </div>
84
+ )
85
+ }
86
+
87
+ const hoursDisplay = task.actual_hours != null
88
+ ? `Est: ${task.estimated_hours}h | Actual: ${task.actual_hours}h`
89
+ : `${task.estimated_hours}h`
90
+
91
+ const fields = [
92
+ { label: 'Est', value: hoursDisplay },
93
+ ...(task.started ? [{ label: 'Started', value: task.started }] : []),
94
+ ...(task.completed_date ? [{ label: 'Completed', value: task.completed_date }] : []),
95
+ {
96
+ label: 'Milestone',
97
+ value: (
98
+ <Link
99
+ to="/milestones/$milestoneId"
100
+ params={{ milestoneId: milestone.id }}
101
+ className="text-blue-500 dark:text-blue-400 hover:underline"
102
+ >
103
+ {milestone.id.replace('milestone_', 'M')} — {milestone.name}
104
+ </Link>
105
+ ),
106
+ },
107
+ ]
108
+
109
+ return (
110
+ <div>
111
+ <div className="flex items-start justify-between mb-4">
112
+ <h1 className="text-lg font-semibold text-gray-900 dark:text-gray-100">{task.name}</h1>
113
+ <Link
114
+ to="/tasks/$taskId"
115
+ params={{ taskId }}
116
+ className="p-1.5 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
117
+ title="Open full view"
118
+ >
119
+ <ExternalLink className="w-4 h-4 text-gray-600 dark:text-gray-400" />
120
+ </Link>
121
+ </div>
122
+
123
+ <div className="flex items-center gap-2 mb-4">
124
+ <PriorityBadge priority={task.priority} />
125
+ </div>
126
+
127
+ <DetailHeader status={task.status} fields={fields} />
128
+
129
+ {task.notes && (
130
+ <p className="text-sm text-gray-600 dark:text-gray-400 mb-6">{task.notes}</p>
131
+ )}
132
+
133
+ {/* Markdown content */}
134
+ {loading ? (
135
+ <p className="text-sm text-gray-600 dark:text-gray-500">Loading document...</p>
136
+ ) : markdown ? (
137
+ <div className="prose-sm">
138
+ <MarkdownContent content={markdown} basePath={taskFilePath ?? undefined} linkMap={linkMap} />
139
+ </div>
140
+ ) : markdownError ? (
141
+ <div className="bg-gray-100 dark:bg-gray-900/50 border border-gray-200 dark:border-gray-800 rounded-xl p-4 text-sm text-gray-600 dark:text-gray-500">
142
+ No document found — {markdownError}
143
+ </div>
144
+ ) : null}
145
+ </div>
146
+ )
147
+ }
@@ -0,0 +1,50 @@
1
+ import { createContext, useContext, useState, ReactNode } from 'react'
2
+
3
+ type PanelContent =
4
+ | { type: 'milestone'; id: string }
5
+ | { type: 'task'; id: string }
6
+ | null
7
+
8
+ interface SidePanelContextValue {
9
+ content: PanelContent
10
+ isOpen: boolean
11
+ openMilestone: (id: string) => void
12
+ openTask: (id: string) => void
13
+ close: () => void
14
+ }
15
+
16
+ const SidePanelContext = createContext<SidePanelContextValue | undefined>(undefined)
17
+
18
+ export function SidePanelProvider({ children }: { children: ReactNode }) {
19
+ const [content, setContent] = useState<PanelContent>(null)
20
+ const [isOpen, setIsOpen] = useState(false)
21
+
22
+ const openMilestone = (id: string) => {
23
+ setContent({ type: 'milestone', id })
24
+ setIsOpen(true)
25
+ }
26
+
27
+ const openTask = (id: string) => {
28
+ setContent({ type: 'task', id })
29
+ setIsOpen(true)
30
+ }
31
+
32
+ const close = () => {
33
+ setIsOpen(false)
34
+ setTimeout(() => setContent(null), 300) // Wait for animation
35
+ }
36
+
37
+ return (
38
+ <SidePanelContext.Provider value={{ content, isOpen, openMilestone, openTask, close }}>
39
+ {children}
40
+ </SidePanelContext.Provider>
41
+ )
42
+ }
43
+
44
+ export function useSidePanel() {
45
+ const context = useContext(SidePanelContext)
46
+ if (!context) {
47
+ throw new Error('useSidePanel must be used within SidePanelProvider')
48
+ }
49
+ return context
50
+ }
package/src/lib/types.ts CHANGED
@@ -1,6 +1,9 @@
1
1
  /** Status values for milestones and tasks */
2
2
  export type Status = 'completed' | 'in_progress' | 'not_started' | 'wont_do'
3
3
 
4
+ /** Priority values for milestones and tasks (ACP 6.0.0+) */
5
+ export type Priority = 'critical' | 'high' | 'medium' | 'low'
6
+
4
7
  /** Unknown properties from agent-maintained YAML are preserved here */
5
8
  export type ExtraFields = Record<string, unknown>
6
9
 
@@ -29,6 +32,8 @@ export interface ProjectMetadata {
29
32
  export interface Milestone {
30
33
  id: string
31
34
  name: string
35
+ priority: Priority
36
+ file: string
32
37
  status: Status
33
38
  progress: number // 0-100
34
39
  started: string | null
@@ -43,10 +48,13 @@ export interface Milestone {
43
48
  export interface Task {
44
49
  id: string
45
50
  name: string
51
+ priority: Priority
46
52
  status: Status
47
53
  milestone_id: string
48
54
  file: string
49
55
  estimated_hours: string
56
+ actual_hours: number | null
57
+ started: string | null
50
58
  completed_date: string | null
51
59
  notes: string
52
60
  extra: ExtraFields
@@ -0,0 +1,27 @@
1
+ import { useState, useEffect } from 'react'
2
+
3
+ type Theme = 'dark' | 'light'
4
+
5
+ export function useTheme() {
6
+ const [theme, setTheme] = useState<Theme>(() => {
7
+ if (typeof window === 'undefined') return 'dark'
8
+ const stored = localStorage.getItem('theme')
9
+ return (stored === 'light' || stored === 'dark') ? stored : 'dark'
10
+ })
11
+
12
+ useEffect(() => {
13
+ const root = document.documentElement
14
+ if (theme === 'dark') {
15
+ root.classList.add('dark')
16
+ } else {
17
+ root.classList.remove('dark')
18
+ }
19
+ localStorage.setItem('theme', theme)
20
+ }, [theme])
21
+
22
+ const toggleTheme = () => {
23
+ setTheme(prev => prev === 'dark' ? 'light' : 'dark')
24
+ }
25
+
26
+ return { theme, toggleTheme }
27
+ }
@@ -8,6 +8,7 @@ import type {
8
8
  DocumentationStats,
9
9
  ProgressSummary,
10
10
  Status,
11
+ Priority,
11
12
  ExtraFields,
12
13
  } from './types'
13
14
 
@@ -51,6 +52,14 @@ function normalizeStatus(value: unknown): Status {
51
52
  return 'not_started'
52
53
  }
53
54
 
55
+ function normalizePriority(value: unknown): Priority {
56
+ const s = String(value || 'medium').toLowerCase()
57
+ if (s === 'critical') return 'critical'
58
+ if (s === 'high') return 'high'
59
+ if (s === 'low') return 'low'
60
+ return 'medium'
61
+ }
62
+
54
63
  function safeString(value: unknown, fallback = ''): string {
55
64
  if (value == null) return fallback
56
65
  return String(value)
@@ -127,7 +136,7 @@ function normalizeProject(raw: unknown): ProjectMetadata {
127
136
  }
128
137
 
129
138
  const MILESTONE_KEYS = [
130
- 'id', 'name', 'status', 'progress', 'started', 'completed',
139
+ 'id', 'name', 'priority', 'file', 'status', 'progress', 'started', 'completed',
131
140
  'estimated_weeks', 'tasks_completed', 'tasks_total', 'notes',
132
141
  ]
133
142
 
@@ -137,6 +146,8 @@ function normalizeMilestone(raw: unknown, index: number): Milestone {
137
146
  return {
138
147
  id: safeString(known.id, `milestone_${index + 1}`),
139
148
  name: safeString(known.name, `Milestone ${index + 1}`),
149
+ priority: normalizePriority(known.priority),
150
+ file: safeString(known.file),
140
151
  status: normalizeStatus(known.status),
141
152
  progress: known.progress != null
142
153
  ? safeNumber(known.progress)
@@ -154,13 +165,25 @@ function normalizeMilestone(raw: unknown, index: number): Milestone {
154
165
  }
155
166
 
156
167
  function normalizeMilestones(raw: unknown): Milestone[] {
157
- if (!Array.isArray(raw)) return []
158
- return raw.map((item, i) => normalizeMilestone(item, i))
168
+ // v6 format: milestones is a map keyed by ID (e.g. { M1: {...}, M2: {...} })
169
+ if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
170
+ const obj = raw as Record<string, unknown>
171
+ return Object.entries(obj).map(([key, value], i) => {
172
+ const m = normalizeMilestone(value, i)
173
+ // The map key IS the milestone ID — override whatever was parsed
174
+ return { ...m, id: key }
175
+ })
176
+ }
177
+ // Pre-v6 format: milestones is an array
178
+ if (Array.isArray(raw)) {
179
+ return raw.map((item, i) => normalizeMilestone(item, i))
180
+ }
181
+ return []
159
182
  }
160
183
 
161
184
  const TASK_KEYS = [
162
- 'id', 'name', 'status', 'milestone_id', 'file',
163
- 'estimated_hours', 'completed_date', 'notes',
185
+ 'id', 'name', 'priority', 'status', 'milestone_id', 'file',
186
+ 'estimated_hours', 'actual_hours', 'started', 'completed_date', 'notes',
164
187
  ]
165
188
 
166
189
  function normalizeTask(raw: unknown, milestoneId: string, index: number): Task {
@@ -169,10 +192,13 @@ function normalizeTask(raw: unknown, milestoneId: string, index: number): Task {
169
192
  return {
170
193
  id: safeString(known.id, `task_${index + 1}`),
171
194
  name: safeString(known.name, `Task ${index + 1}`),
195
+ priority: normalizePriority(known.priority),
172
196
  status: normalizeStatus(known.status),
173
197
  milestone_id: safeString(known.milestone_id, milestoneId),
174
198
  file: safeString(known.file),
175
199
  estimated_hours: safeString(known.estimated_hours, '0'),
200
+ actual_hours: known.actual_hours != null ? safeNumber(known.actual_hours) : null,
201
+ started: known.started ? safeString(known.started) : null,
176
202
  completed_date: known.completed_date ? safeString(known.completed_date) : null,
177
203
  notes: safeString(known.notes),
178
204
  extra,
@@ -388,20 +414,30 @@ export function parseProgressYaml(raw: string): ProgressData {
388
414
  if (looseTasks.length > 0) {
389
415
  const existing = allTasks['_unassigned'] || []
390
416
  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')
417
+ }
418
+
419
+ // Create synthetic milestones for any task keys without a matching milestone.
420
+ // This handles `unassigned:` (or any other key) inside the `tasks:` object
421
+ // that doesn't correspond to a declared milestone.
422
+ for (const key of Object.keys(allTasks)) {
423
+ if (!seenIds.has(key)) {
424
+ seenIds.add(key)
425
+ const tasks = allTasks[key]
426
+ const isUnassigned = key === '_unassigned' || key.toLowerCase() === 'unassigned'
427
+ const displayName = isUnassigned ? 'Unassigned Tasks' : key
394
428
  allMilestones.push({
395
- id: '_unassigned',
396
- name: 'Unassigned Tasks',
429
+ id: key,
430
+ name: displayName,
431
+ priority: 'medium',
432
+ file: '',
397
433
  status: 'in_progress',
398
434
  progress: 0,
399
435
  started: null,
400
436
  completed: null,
401
437
  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',
438
+ tasks_completed: tasks.filter((t) => t.status === 'completed').length,
439
+ tasks_total: tasks.length,
440
+ notes: isUnassigned ? 'Tasks not assigned to a specific milestone' : `Tasks under "${key}"`,
405
441
  extra: { synthetic: true },
406
442
  })
407
443
  }