@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.
- package/package.json +1 -1
- package/src/components/DocumentDetail.tsx +100 -0
- package/src/components/DocumentList.tsx +90 -0
- package/src/components/Header.tsx +23 -6
- package/src/components/MilestoneKanban.tsx +14 -8
- package/src/components/MilestonePreview.tsx +162 -0
- package/src/components/MilestoneTable.tsx +18 -8
- package/src/components/MilestoneTree.tsx +19 -13
- package/src/components/PreviewButton.tsx +33 -0
- package/src/components/PriorityBadge.tsx +20 -0
- package/src/components/SidePanel.tsx +45 -0
- package/src/components/Sidebar.tsx +11 -8
- package/src/components/TaskList.tsx +9 -5
- package/src/components/TaskPreview.tsx +147 -0
- package/src/contexts/SidePanelContext.tsx +50 -0
- package/src/lib/types.ts +8 -0
- package/src/lib/useTheme.ts +27 -0
- package/src/lib/yaml-loader.ts +49 -13
- package/src/routeTree.gen.ts +211 -0
- package/src/routes/__root.tsx +14 -9
- package/src/routes/designs.$slug.tsx +18 -0
- package/src/routes/designs.index.tsx +10 -0
- package/src/routes/designs.tsx +9 -0
- package/src/routes/milestones.$milestoneId.tsx +6 -0
- package/src/routes/patterns.$slug.tsx +18 -0
- package/src/routes/patterns.index.tsx +10 -0
- package/src/routes/patterns.tsx +9 -0
- package/src/routes/reports.$slug.tsx +18 -0
- package/src/routes/reports.index.tsx +10 -0
- package/src/routes/reports.tsx +9 -0
- package/src/routes/tasks.$taskId.tsx +11 -1
- package/src/services/markdown.service.ts +102 -0
- package/src/styles.css +2 -2
|
@@ -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
|
+
}
|
package/src/lib/yaml-loader.ts
CHANGED
|
@@ -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
|
-
|
|
158
|
-
|
|
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
|
-
|
|
392
|
-
|
|
393
|
-
|
|
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:
|
|
396
|
-
name:
|
|
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:
|
|
403
|
-
tasks_total:
|
|
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
|
}
|