@prmichaelsen/acp-visualizer 0.8.2 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@prmichaelsen/acp-visualizer",
3
- "version": "0.8.2",
3
+ "version": "0.9.0",
4
4
  "type": "module",
5
5
  "description": "Browser-based dashboard for visualizing ACP progress.yaml data",
6
6
  "bin": {
@@ -1,5 +1,7 @@
1
+ import { Moon, Sun } from 'lucide-react'
1
2
  import { StatusBadge } from './StatusBadge'
2
3
  import { ProgressBar } from './ProgressBar'
4
+ import { useTheme } from '../lib/useTheme'
3
5
  import type { ProgressData } from '../lib/types'
4
6
 
5
7
  interface HeaderProps {
@@ -7,16 +9,31 @@ interface HeaderProps {
7
9
  }
8
10
 
9
11
  export function Header({ data }: HeaderProps) {
12
+ const { theme, toggleTheme } = useTheme()
13
+
10
14
  if (!data) return null
11
15
 
12
16
  return (
13
- <header className="h-14 border-b border-gray-800 flex items-center px-6 gap-4 shrink-0">
14
- <h1 className="text-sm font-medium text-gray-200">{data.project.name}</h1>
15
- <span className="text-xs text-gray-500 font-mono">v{data.project.version}</span>
17
+ <header className="h-14 border-b border-gray-200 dark:border-gray-800 flex items-center px-6 gap-4 shrink-0 bg-white dark:bg-gray-950">
18
+ <h1 className="text-sm font-medium text-gray-900 dark:text-gray-200">{data.project.name}</h1>
19
+ <span className="text-xs text-gray-500 dark:text-gray-500 font-mono">v{data.project.version}</span>
16
20
  <StatusBadge status={data.project.status} />
17
- <div className="ml-auto flex items-center gap-3 w-48">
18
- <ProgressBar value={data.progress.overall} size="sm" />
19
- <span className="text-xs text-gray-400 font-mono">{data.progress.overall}%</span>
21
+ <div className="ml-auto flex items-center gap-4">
22
+ <div className="flex items-center gap-3 w-48">
23
+ <ProgressBar value={data.progress.overall} size="sm" />
24
+ <span className="text-xs text-gray-600 dark:text-gray-400 font-mono">{data.progress.overall}%</span>
25
+ </div>
26
+ <button
27
+ onClick={toggleTheme}
28
+ className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
29
+ aria-label="Toggle theme"
30
+ >
31
+ {theme === 'dark' ? (
32
+ <Moon className="w-4 h-4 text-gray-400" />
33
+ ) : (
34
+ <Sun className="w-4 h-4 text-gray-600" />
35
+ )}
36
+ </button>
20
37
  </div>
21
38
  </header>
22
39
  )
@@ -2,6 +2,7 @@ import { Link } from '@tanstack/react-router'
2
2
  import { StatusBadge } from './StatusBadge'
3
3
  import { PriorityBadge } from './PriorityBadge'
4
4
  import { ProgressBar } from './ProgressBar'
5
+ import { PreviewButton } from './PreviewButton'
5
6
  import { TaskList } from './TaskList'
6
7
  import type { Milestone, Task, Status } from '../lib/types'
7
8
  import { useState } from 'react'
@@ -29,15 +30,18 @@ function KanbanCard({
29
30
  const [expanded, setExpanded] = useState(false)
30
31
 
31
32
  return (
32
- <div className="bg-gray-900/50 border border-gray-800 rounded-lg p-3 hover:border-gray-700 transition-colors">
33
+ <div className="bg-gray-100 dark:bg-gray-900/50 border border-gray-200 dark:border-gray-800 rounded-lg p-3 hover:border-gray-300 dark:hover:border-gray-700 transition-colors group">
33
34
  <div className="flex items-start justify-between gap-2 mb-2">
34
- <Link
35
- to="/milestones/$milestoneId"
36
- params={{ milestoneId: milestone.id }}
37
- className="text-sm font-medium leading-tight hover:text-blue-400 transition-colors"
38
- >
39
- {milestone.name}
40
- </Link>
35
+ <div className="flex items-center gap-2 flex-1">
36
+ <Link
37
+ to="/milestones/$milestoneId"
38
+ params={{ milestoneId: milestone.id }}
39
+ className="text-sm font-medium leading-tight text-gray-900 dark:text-gray-200 hover:text-blue-500 dark:hover:text-blue-400 transition-colors"
40
+ >
41
+ {milestone.name}
42
+ </Link>
43
+ <PreviewButton type="milestone" id={milestone.id} />
44
+ </div>
41
45
  <PriorityBadge priority={milestone.priority} />
42
46
  </div>
43
47
  <div className="flex items-center gap-2 mb-2">
@@ -0,0 +1,162 @@
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 { ProgressBar } from './ProgressBar'
7
+ import { StatusDot } from './StatusDot'
8
+ import { PriorityBadge } from './PriorityBadge'
9
+ import { MarkdownContent, buildLinkMap } from './MarkdownContent'
10
+ import { getMarkdownContent, resolveMilestoneFile } from '../services/markdown.service'
11
+ import type { MarkdownResult, ResolveFileResult } from '../services/markdown.service'
12
+
13
+ interface MilestonePreviewProps {
14
+ milestoneId: string
15
+ }
16
+
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
+ export function MilestonePreview({ milestoneId }: MilestonePreviewProps) {
28
+ const data = useProgressData()
29
+ const [markdown, setMarkdown] = useState<string | null>(null)
30
+ const [markdownError, setMarkdownError] = useState<string | null>(null)
31
+ const [markdownFilePath, setMarkdownFilePath] = 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
+ const linkMap = useMemo(() => (data ? buildLinkMap(data) : {}), [data])
37
+
38
+ useEffect(() => {
39
+ if (!milestoneId) return
40
+
41
+ setLoading(true)
42
+ setMarkdown(null)
43
+ setMarkdownError(null)
44
+ setMarkdownFilePath(null)
45
+
46
+ const github = getGitHubParams()
47
+
48
+ resolveMilestoneFile({ data: { milestoneId, github } })
49
+ .then((resolveResult: ResolveFileResult) => {
50
+ if (!resolveResult.ok) {
51
+ setMarkdownError(resolveResult.error)
52
+ setLoading(false)
53
+ return
54
+ }
55
+
56
+ setMarkdownFilePath(resolveResult.filePath)
57
+ return getMarkdownContent({ data: { filePath: resolveResult.filePath, github } })
58
+ .then((mdResult: MarkdownResult) => {
59
+ if (mdResult.ok) {
60
+ setMarkdown(mdResult.content)
61
+ } else {
62
+ setMarkdownError(mdResult.error)
63
+ }
64
+ })
65
+ })
66
+ .catch((err: Error) => {
67
+ setMarkdownError(err.message)
68
+ })
69
+ .finally(() => {
70
+ setLoading(false)
71
+ })
72
+ }, [milestoneId])
73
+
74
+ if (!data || !milestone) {
75
+ return (
76
+ <div className="text-center py-8">
77
+ <p className="text-gray-500 dark:text-gray-400 text-sm">Milestone not found: {milestoneId}</p>
78
+ </div>
79
+ )
80
+ }
81
+
82
+ const fields = [
83
+ ...(milestone.started ? [{ label: 'Started', value: milestone.started }] : []),
84
+ ...(milestone.completed ? [{ label: 'Completed', value: milestone.completed }] : []),
85
+ { label: 'Est', value: `${milestone.estimated_weeks} week${milestone.estimated_weeks === '1' ? '' : 's'}` },
86
+ { label: 'Tasks', value: `${milestone.tasks_completed}/${milestone.tasks_total}` },
87
+ ]
88
+
89
+ return (
90
+ <div>
91
+ <div className="flex items-start justify-between mb-4">
92
+ <h1 className="text-lg font-semibold text-gray-900 dark:text-gray-100">{milestone.name}</h1>
93
+ <Link
94
+ to="/milestones/$milestoneId"
95
+ params={{ milestoneId }}
96
+ className="p-1.5 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
97
+ title="Open full view"
98
+ >
99
+ <ExternalLink className="w-4 h-4 text-gray-600 dark:text-gray-400" />
100
+ </Link>
101
+ </div>
102
+
103
+ <div className="flex items-center gap-3 mb-4">
104
+ <div className="flex-1 max-w-xs">
105
+ <ProgressBar value={milestone.progress} size="sm" />
106
+ </div>
107
+ <span className="text-xs text-gray-600 dark:text-gray-500">{milestone.progress}%</span>
108
+ </div>
109
+
110
+ <div className="flex items-center gap-2 mb-4">
111
+ <PriorityBadge priority={milestone.priority} />
112
+ </div>
113
+
114
+ <DetailHeader status={milestone.status} fields={fields} />
115
+
116
+ {milestone.notes && (
117
+ <p className="text-sm text-gray-600 dark:text-gray-400 mb-6">{milestone.notes}</p>
118
+ )}
119
+
120
+ {/* Markdown content */}
121
+ {loading ? (
122
+ <p className="text-sm text-gray-600 dark:text-gray-500">Loading document...</p>
123
+ ) : markdown ? (
124
+ <div className="prose-sm">
125
+ <MarkdownContent content={markdown} basePath={markdownFilePath ?? undefined} linkMap={linkMap} />
126
+ </div>
127
+ ) : markdownError ? (
128
+ <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">
129
+ No document found — {markdownError}
130
+ </div>
131
+ ) : null}
132
+
133
+ {/* Task list */}
134
+ {tasks.length > 0 && (
135
+ <div className="mt-8">
136
+ <h2 className="text-sm font-semibold text-gray-700 dark:text-gray-400 uppercase tracking-wider mb-3">
137
+ Tasks
138
+ </h2>
139
+ <div className="bg-gray-100 dark:bg-gray-900/50 border border-gray-200 dark:border-gray-800 rounded-xl divide-y divide-gray-200 dark:divide-gray-800">
140
+ {tasks.map((task) => (
141
+ <Link
142
+ key={task.id}
143
+ to="/tasks/$taskId"
144
+ params={{ taskId: task.id }}
145
+ className="flex items-center gap-2 px-4 py-2.5 text-sm hover:bg-gray-200/50 dark:hover:bg-gray-800/50 transition-colors first:rounded-t-xl last:rounded-b-xl"
146
+ >
147
+ <StatusDot status={task.status} />
148
+ <span className={task.status === 'completed' ? 'text-gray-500 dark:text-gray-500' : 'text-gray-900 dark:text-gray-200'}>
149
+ {task.name}
150
+ </span>
151
+ <PriorityBadge priority={task.priority} />
152
+ {task.estimated_hours && (
153
+ <span className="text-xs text-gray-600 dark:text-gray-600 ml-auto">{task.estimated_hours}h</span>
154
+ )}
155
+ </Link>
156
+ ))}
157
+ </div>
158
+ </div>
159
+ )}
160
+ </div>
161
+ )
162
+ }
@@ -12,6 +12,7 @@ import { ChevronDown, ChevronRight, ArrowUpDown } from 'lucide-react'
12
12
  import { StatusBadge } from './StatusBadge'
13
13
  import { PriorityBadge } from './PriorityBadge'
14
14
  import { ProgressBar } from './ProgressBar'
15
+ import { PreviewButton } from './PreviewButton'
15
16
  import { TaskList } from './TaskList'
16
17
  import type { Milestone, Task } from '../lib/types'
17
18
 
@@ -54,14 +55,17 @@ export function MilestoneTable({ milestones, tasks }: MilestoneTableProps) {
54
55
  columnHelper.accessor('name', {
55
56
  header: 'Milestone',
56
57
  cell: (info) => (
57
- <Link
58
- to="/milestones/$milestoneId"
59
- params={{ milestoneId: info.row.original.id }}
60
- className="text-sm font-medium text-gray-200 hover:text-blue-400 transition-colors"
61
- onClick={(e) => e.stopPropagation()}
62
- >
63
- {info.getValue()}
64
- </Link>
58
+ <div className="flex items-center gap-2 group">
59
+ <Link
60
+ to="/milestones/$milestoneId"
61
+ params={{ milestoneId: info.row.original.id }}
62
+ className="text-sm font-medium text-gray-900 dark:text-gray-200 hover:text-blue-500 dark:hover:text-blue-400 transition-colors"
63
+ onClick={(e) => e.stopPropagation()}
64
+ >
65
+ {info.getValue()}
66
+ </Link>
67
+ <PreviewButton type="milestone" id={info.row.original.id} />
68
+ </div>
65
69
  ),
66
70
  }),
67
71
  columnHelper.accessor('status', {
@@ -4,6 +4,7 @@ import { ChevronDown, ChevronRight } from 'lucide-react'
4
4
  import { StatusBadge } from './StatusBadge'
5
5
  import { PriorityBadge } from './PriorityBadge'
6
6
  import { ProgressBar } from './ProgressBar'
7
+ import { PreviewButton } from './PreviewButton'
7
8
  import { TaskList } from './TaskList'
8
9
  import { useCollapse } from '../lib/useCollapse'
9
10
  import type { Milestone, Task } from '../lib/types'
@@ -27,30 +28,33 @@ function MilestoneTreeRow({
27
28
  const collapse = useCollapse(expanded)
28
29
 
29
30
  return (
30
- <div className="border-b border-gray-800/50">
31
+ <div className="border-b border-gray-200 dark:border-gray-800/50 group">
31
32
  <button
32
33
  onClick={onToggle}
33
- className="w-full flex items-center gap-3 px-4 py-3 hover:bg-gray-800/30 transition-colors text-left"
34
+ className="w-full flex items-center gap-3 px-4 py-3 hover:bg-gray-200/50 dark:hover:bg-gray-800/30 transition-colors text-left"
34
35
  >
35
36
  {expanded ? (
36
- <ChevronDown className="w-4 h-4 text-gray-500 shrink-0" />
37
+ <ChevronDown className="w-4 h-4 text-gray-500 dark:text-gray-500 shrink-0" />
37
38
  ) : (
38
- <ChevronRight className="w-4 h-4 text-gray-500 shrink-0" />
39
+ <ChevronRight className="w-4 h-4 text-gray-500 dark:text-gray-500 shrink-0" />
39
40
  )}
40
- <Link
41
- to="/milestones/$milestoneId"
42
- params={{ milestoneId: milestone.id }}
43
- className="flex-1 text-sm font-medium hover:text-blue-400 transition-colors"
44
- onClick={(e) => e.stopPropagation()}
45
- >
46
- {milestone.name}
47
- </Link>
41
+ <div className="flex items-center gap-2 flex-1">
42
+ <Link
43
+ to="/milestones/$milestoneId"
44
+ params={{ milestoneId: milestone.id }}
45
+ className="text-sm font-medium text-gray-900 dark:text-gray-200 hover:text-blue-500 dark:hover:text-blue-400 transition-colors"
46
+ onClick={(e) => e.stopPropagation()}
47
+ >
48
+ {milestone.name}
49
+ </Link>
50
+ <PreviewButton type="milestone" id={milestone.id} />
51
+ </div>
48
52
  <StatusBadge status={milestone.status} />
49
53
  <PriorityBadge priority={milestone.priority} />
50
54
  <div className="w-20">
51
55
  <ProgressBar value={milestone.progress} size="sm" />
52
56
  </div>
53
- <span className="text-xs text-gray-500 font-mono w-12 text-right">
57
+ <span className="text-xs text-gray-500 dark:text-gray-500 font-mono w-12 text-right">
54
58
  {milestone.tasks_completed}/{milestone.tasks_total}
55
59
  </span>
56
60
  </button>
@@ -0,0 +1,33 @@
1
+ import { Eye } from 'lucide-react'
2
+ import { useSidePanel } from '../contexts/SidePanelContext'
3
+
4
+ interface PreviewButtonProps {
5
+ type: 'milestone' | 'task'
6
+ id: string
7
+ className?: string
8
+ }
9
+
10
+ export function PreviewButton({ type, id, className = '' }: PreviewButtonProps) {
11
+ const { openMilestone, openTask } = useSidePanel()
12
+
13
+ const handleClick = (e: React.MouseEvent) => {
14
+ e.preventDefault()
15
+ e.stopPropagation()
16
+ if (type === 'milestone') {
17
+ openMilestone(id)
18
+ } else {
19
+ openTask(id)
20
+ }
21
+ }
22
+
23
+ return (
24
+ <button
25
+ onClick={handleClick}
26
+ className={`p-1.5 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-800 transition-colors opacity-0 group-hover:opacity-100 ${className}`}
27
+ title={`Preview ${type}`}
28
+ aria-label={`Preview ${type}`}
29
+ >
30
+ <Eye className="w-3.5 h-3.5 text-gray-500 dark:text-gray-400" />
31
+ </button>
32
+ )
33
+ }
@@ -0,0 +1,45 @@
1
+ import { X } from 'lucide-react'
2
+ import { useSidePanel } from '../contexts/SidePanelContext'
3
+ import { MilestonePreview } from './MilestonePreview'
4
+ import { TaskPreview } from './TaskPreview'
5
+
6
+ export function SidePanel() {
7
+ const { content, isOpen, close } = useSidePanel()
8
+
9
+ return (
10
+ <>
11
+ {/* Backdrop */}
12
+ <div
13
+ className={`fixed inset-0 bg-black/30 backdrop-blur-sm z-40 transition-opacity duration-300 ${
14
+ isOpen ? 'opacity-100' : 'opacity-0 pointer-events-none'
15
+ }`}
16
+ onClick={close}
17
+ />
18
+
19
+ {/* Panel */}
20
+ <div
21
+ className={`fixed top-0 right-0 h-full w-full max-w-2xl bg-white dark:bg-gray-900 border-l border-gray-200 dark:border-gray-800 shadow-2xl z-50 transition-transform duration-300 overflow-auto ${
22
+ isOpen ? 'translate-x-0' : 'translate-x-full'
23
+ }`}
24
+ >
25
+ {/* Header */}
26
+ <div className="sticky top-0 bg-white dark:bg-gray-900 border-b border-gray-200 dark:border-gray-800 px-6 py-4 flex items-center justify-between z-10">
27
+ <h2 className="text-sm font-semibold text-gray-900 dark:text-gray-100">Preview</h2>
28
+ <button
29
+ onClick={close}
30
+ className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
31
+ aria-label="Close panel"
32
+ >
33
+ <X className="w-4 h-4 text-gray-600 dark:text-gray-400" />
34
+ </button>
35
+ </div>
36
+
37
+ {/* Content */}
38
+ <div className="p-6">
39
+ {content?.type === 'milestone' && <MilestonePreview milestoneId={content.id} />}
40
+ {content?.type === 'task' && <TaskPreview taskId={content.id} />}
41
+ </div>
42
+ </div>
43
+ </>
44
+ )
45
+ }
@@ -25,9 +25,9 @@ export function Sidebar({ projects = [], currentProject = null, onProjectSelect,
25
25
  const location = useRouterState({ select: (s) => s.location })
26
26
 
27
27
  return (
28
- <nav className="w-56 border-r border-gray-800 bg-gray-950 flex flex-col shrink-0">
29
- <div className="p-4 border-b border-gray-800">
30
- <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">
31
31
  ACP Visualizer
32
32
  </span>
33
33
  </div>
@@ -53,8 +53,8 @@ export function Sidebar({ projects = [], currentProject = null, onProjectSelect,
53
53
  to={item.to}
54
54
  className={`flex items-center gap-3 px-4 py-2 text-sm transition-colors ${
55
55
  isActive
56
- ? 'text-gray-100 bg-gray-800/50'
57
- : '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'
58
58
  }`}
59
59
  >
60
60
  <item.icon className="w-4 h-4" />
@@ -63,10 +63,10 @@ export function Sidebar({ projects = [], currentProject = null, onProjectSelect,
63
63
  )
64
64
  })}
65
65
  </div>
66
- <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">
67
67
  <Link
68
68
  to="/search"
69
- 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"
70
70
  >
71
71
  <Search className="w-4 h-4" />
72
72
  Search...
@@ -1,6 +1,7 @@
1
1
  import { Link } from '@tanstack/react-router'
2
2
  import { StatusDot } from './StatusDot'
3
3
  import { PriorityBadge } from './PriorityBadge'
4
+ import { PreviewButton } from './PreviewButton'
4
5
  import { ExtraFieldsBadge } from './ExtraFieldsBadge'
5
6
  import type { Task } from '../lib/types'
6
7
 
@@ -8,7 +9,7 @@ export function TaskList({ tasks }: { tasks: Task[] }) {
8
9
  if (tasks.length === 0) {
9
10
  return (
10
11
  <div className="pl-6 py-2">
11
- <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>
12
13
  </div>
13
14
  )
14
15
  }
@@ -16,20 +17,21 @@ export function TaskList({ tasks }: { tasks: Task[] }) {
16
17
  return (
17
18
  <div className="pl-6 py-1 space-y-0.5">
18
19
  {tasks.map((task) => (
19
- <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">
20
21
  <StatusDot status={task.status} />
21
22
  <Link
22
23
  to="/tasks/$taskId"
23
24
  params={{ taskId: task.id }}
24
- className={`hover:text-blue-400 transition-colors ${
25
- 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'
26
27
  }`}
27
28
  >
28
29
  {task.name}
29
30
  </Link>
31
+ <PreviewButton type="task" id={task.id} />
30
32
  <PriorityBadge priority={task.priority} />
31
33
  {task.notes && (
32
- <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]">
33
35
  {task.notes}
34
36
  </span>
35
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
+ }
@@ -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
+ }
@@ -3,12 +3,14 @@ import { useState, useCallback, useEffect } from 'react'
3
3
  import { useAutoRefresh } from '../lib/useAutoRefresh'
4
4
  import { Sidebar } from '../components/Sidebar'
5
5
  import { Header } from '../components/Header'
6
+ import { SidePanel } from '../components/SidePanel'
6
7
  import { getProgressData } from '../services/progress-database.service'
7
8
  import { listProjects, getProjectProgressPath } from '../services/projects.service'
8
9
  import { fetchGitHubProgress } from '../services/github.service'
9
10
  import type { ProgressData } from '../lib/types'
10
11
  import type { AcpProject } from '../services/projects.service'
11
12
  import { ProgressProvider } from '../contexts/ProgressContext'
13
+ import { SidePanelProvider } from '../contexts/SidePanelContext'
12
14
 
13
15
  import appCss from '../styles.css?url'
14
16
 
@@ -63,8 +65,8 @@ function NotFound() {
63
65
  return (
64
66
  <div className="flex items-center justify-center h-full">
65
67
  <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
+ <h2 className="text-xl font-semibold text-gray-800 dark:text-gray-200 mb-2">Page Not Found</h2>
69
+ <p className="text-sm text-gray-600 dark:text-gray-400">
68
70
  The page you're looking for doesn't exist.
69
71
  </p>
70
72
  </div>
@@ -154,7 +156,7 @@ function RootLayout() {
154
156
  return (
155
157
  <>
156
158
  <AutoRefresh />
157
- <div className="flex h-screen bg-gray-950 text-gray-100">
159
+ <div className="flex h-screen bg-white dark:bg-gray-950 text-gray-900 dark:text-gray-100">
158
160
  <Sidebar
159
161
  projects={context.projects}
160
162
  currentProject={currentProject}
@@ -162,12 +164,15 @@ function RootLayout() {
162
164
  onGitHubLoad={handleGitHubLoad}
163
165
  />
164
166
  <ProgressProvider data={progressData}>
165
- <div className="flex-1 flex flex-col overflow-hidden">
166
- <Header data={progressData} />
167
- <main className="flex-1 overflow-auto">
168
- <Outlet />
169
- </main>
170
- </div>
167
+ <SidePanelProvider>
168
+ <div className="flex-1 flex flex-col overflow-hidden">
169
+ <Header data={progressData} />
170
+ <main className="flex-1 overflow-auto bg-gray-50 dark:bg-gray-900">
171
+ <Outlet />
172
+ </main>
173
+ </div>
174
+ <SidePanel />
175
+ </SidePanelProvider>
171
176
  </ProgressProvider>
172
177
  </div>
173
178
  </>
package/src/styles.css CHANGED
@@ -3,11 +3,11 @@
3
3
  @import "highlight.js/styles/github-dark.css";
4
4
 
5
5
  html {
6
- @apply bg-gray-950;
6
+ @apply bg-white dark:bg-gray-950;
7
7
  }
8
8
 
9
9
  body {
10
- @apply m-0 bg-gray-950 min-h-screen text-gray-100;
10
+ @apply m-0 bg-white dark:bg-gray-950 min-h-screen text-gray-900 dark:text-gray-100;
11
11
  font-family: "Inter", system-ui, -apple-system, sans-serif;
12
12
  -webkit-font-smoothing: antialiased;
13
13
  -moz-osx-font-smoothing: grayscale;