@prmichaelsen/acp-visualizer 0.1.8 → 0.5.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.1.8",
3
+ "version": "0.5.0",
4
4
  "type": "module",
5
5
  "description": "Browser-based dashboard for visualizing ACP progress.yaml data",
6
6
  "bin": {
@@ -16,25 +16,36 @@
16
16
  "dev": "vite dev --port 3400 --host",
17
17
  "build": "vite build",
18
18
  "serve": "vite preview",
19
+ "deploy": "export $(cat .env.cloudflare.local | xargs) && npm run build && wrangler deploy",
20
+ "tail": "export $(cat .env.cloudflare.local | xargs) && wrangler tail",
19
21
  "test": "vitest",
20
22
  "test:run": "vitest run",
21
- "test:coverage": "vitest run --coverage"
23
+ "test:coverage": "vitest run --coverage",
24
+ "cf-secrets:e1": "tsx scripts/upload-cloudflare-secrets.ts -- --env .env.e1.local",
25
+ "cf-secrets:prod": "tsx scripts/upload-cloudflare-secrets.ts -- --env .env.prod.local",
26
+ "typecheck": "tsc --noEmit",
27
+ "cf-typegen": "wrangler types"
22
28
  },
23
29
  "dependencies": {
30
+ "@cloudflare/vite-plugin": "^1.28.0",
31
+ "@cloudflare/workers-types": "^4.20260313.1",
24
32
  "@tailwindcss/vite": "^4.0.6",
25
33
  "@tanstack/react-router": "^1.132.0",
26
34
  "@tanstack/react-start": "^1.132.0",
27
35
  "@tanstack/react-table": "^8.21.3",
28
36
  "@tanstack/router-plugin": "^1.132.0",
37
+ "@types/dagre": "^0.7.54",
29
38
  "@types/js-yaml": "^4.0.9",
30
39
  "@types/react": "^19.0.8",
31
40
  "@types/react-dom": "^19.0.3",
32
41
  "@vitejs/plugin-react": "^5.0.4",
42
+ "dagre": "^0.8.5",
33
43
  "fuse.js": "^7.1.0",
34
44
  "js-yaml": "^4.1.0",
35
45
  "lucide-react": "^0.544.0",
36
46
  "react": "^19.0.0",
37
47
  "react-dom": "^19.0.0",
48
+ "recharts": "^3.8.0",
38
49
  "tailwindcss": "^4.0.6",
39
50
  "typescript": "^5.7.2",
40
51
  "vite": "^7.1.7",
@@ -0,0 +1,148 @@
1
+ import {
2
+ AreaChart,
3
+ Area,
4
+ XAxis,
5
+ YAxis,
6
+ CartesianGrid,
7
+ Tooltip,
8
+ ResponsiveContainer,
9
+ } from 'recharts'
10
+ import type { ProgressData } from '../lib/types'
11
+
12
+ interface BurndownChartProps {
13
+ data: ProgressData
14
+ }
15
+
16
+ interface ChartPoint {
17
+ date: string
18
+ completed: number
19
+ total: number
20
+ remaining: number
21
+ }
22
+
23
+ function buildBurndownData(data: ProgressData): ChartPoint[] {
24
+ // Collect all task completion dates
25
+ const allTasks: Array<{ completed_date: string | null; status: string }> = []
26
+ for (const tasks of Object.values(data.tasks)) {
27
+ for (const t of tasks) {
28
+ allTasks.push({ completed_date: t.completed_date, status: t.status })
29
+ }
30
+ }
31
+
32
+ const total = allTasks.length
33
+ if (total === 0) return []
34
+
35
+ // Get sorted unique completion dates
36
+ const completionDates = allTasks
37
+ .map((t) => t.completed_date)
38
+ .filter((d): d is string => d != null)
39
+ .sort()
40
+
41
+ if (completionDates.length === 0) {
42
+ // No completed tasks — show single point
43
+ return [{ date: data.project.started || 'Start', completed: 0, total, remaining: total }]
44
+ }
45
+
46
+ // Build cumulative completion over time
47
+ const points: ChartPoint[] = []
48
+
49
+ // Start point
50
+ const startDate = data.project.started || completionDates[0]
51
+ points.push({ date: startDate, completed: 0, total, remaining: total })
52
+
53
+ // Accumulate completions by date
54
+ const byDate = new Map<string, number>()
55
+ for (const d of completionDates) {
56
+ byDate.set(d, (byDate.get(d) || 0) + 1)
57
+ }
58
+
59
+ let cumulative = 0
60
+ for (const [date, count] of [...byDate.entries()].sort()) {
61
+ cumulative += count
62
+ // Skip if same as start date (already added)
63
+ if (date === startDate && points.length === 1) {
64
+ points[0] = { date, completed: cumulative, total, remaining: total - cumulative }
65
+ continue
66
+ }
67
+ points.push({ date, completed: cumulative, total, remaining: total - cumulative })
68
+ }
69
+
70
+ return points
71
+ }
72
+
73
+ export function BurndownChart({ data }: BurndownChartProps) {
74
+ const chartData = buildBurndownData(data)
75
+
76
+ if (chartData.length === 0) {
77
+ return <p className="text-gray-600 text-sm">No task data for chart</p>
78
+ }
79
+
80
+ return (
81
+ <div className="bg-gray-900/50 border border-gray-800 rounded-xl p-5">
82
+ <h3 className="text-sm font-semibold text-gray-300 mb-4">
83
+ Task Completion Over Time
84
+ </h3>
85
+ <ResponsiveContainer width="100%" height={250}>
86
+ <AreaChart data={chartData} margin={{ top: 5, right: 20, bottom: 5, left: 0 }}>
87
+ <defs>
88
+ <linearGradient id="completedGrad" x1="0" y1="0" x2="0" y2="1">
89
+ <stop offset="5%" stopColor="#22c55e" stopOpacity={0.3} />
90
+ <stop offset="95%" stopColor="#22c55e" stopOpacity={0} />
91
+ </linearGradient>
92
+ <linearGradient id="remainingGrad" x1="0" y1="0" x2="0" y2="1">
93
+ <stop offset="5%" stopColor="#3b82f6" stopOpacity={0.2} />
94
+ <stop offset="95%" stopColor="#3b82f6" stopOpacity={0} />
95
+ </linearGradient>
96
+ </defs>
97
+ <CartesianGrid strokeDasharray="3 3" stroke="#1f2937" />
98
+ <XAxis
99
+ dataKey="date"
100
+ tick={{ fill: '#6b7280', fontSize: 11 }}
101
+ tickLine={false}
102
+ axisLine={{ stroke: '#374151' }}
103
+ />
104
+ <YAxis
105
+ tick={{ fill: '#6b7280', fontSize: 11 }}
106
+ tickLine={false}
107
+ axisLine={{ stroke: '#374151' }}
108
+ />
109
+ <Tooltip
110
+ contentStyle={{
111
+ backgroundColor: '#111827',
112
+ border: '1px solid #374151',
113
+ borderRadius: '8px',
114
+ fontSize: '12px',
115
+ color: '#e5e7eb',
116
+ }}
117
+ />
118
+ <Area
119
+ type="monotone"
120
+ dataKey="completed"
121
+ name="Completed"
122
+ stroke="#22c55e"
123
+ fill="url(#completedGrad)"
124
+ strokeWidth={2}
125
+ />
126
+ <Area
127
+ type="monotone"
128
+ dataKey="remaining"
129
+ name="Remaining"
130
+ stroke="#3b82f6"
131
+ fill="url(#remainingGrad)"
132
+ strokeWidth={2}
133
+ />
134
+ </AreaChart>
135
+ </ResponsiveContainer>
136
+ <div className="flex gap-6 mt-3 justify-center">
137
+ <div className="flex items-center gap-2 text-xs text-gray-400">
138
+ <div className="w-3 h-3 rounded-sm bg-green-500/30 border border-green-500" />
139
+ Completed
140
+ </div>
141
+ <div className="flex items-center gap-2 text-xs text-gray-400">
142
+ <div className="w-3 h-3 rounded-sm bg-blue-500/20 border border-blue-500" />
143
+ Remaining
144
+ </div>
145
+ </div>
146
+ </div>
147
+ )
148
+ }
@@ -0,0 +1,196 @@
1
+ import { useMemo } from 'react'
2
+ import dagre from 'dagre'
3
+ import type { ProgressData, Task, Status } from '../lib/types'
4
+
5
+ interface DependencyGraphProps {
6
+ data: ProgressData
7
+ }
8
+
9
+ interface GraphNode {
10
+ id: string
11
+ label: string
12
+ status: Status
13
+ milestone: string
14
+ x: number
15
+ y: number
16
+ width: number
17
+ height: number
18
+ }
19
+
20
+ interface GraphEdge {
21
+ from: string
22
+ to: string
23
+ points: Array<{ x: number; y: number }>
24
+ }
25
+
26
+ const NODE_WIDTH = 180
27
+ const NODE_HEIGHT = 44
28
+
29
+ const statusColors: Record<Status, { bg: string; border: string; text: string }> = {
30
+ completed: { bg: '#22c55e15', border: '#22c55e40', text: '#4ade80' },
31
+ in_progress: { bg: '#3b82f615', border: '#3b82f640', text: '#60a5fa' },
32
+ not_started: { bg: '#6b728015', border: '#6b728040', text: '#9ca3af' },
33
+ wont_do: { bg: '#eab30815', border: '#eab30840', text: '#fbbf24' },
34
+ }
35
+
36
+ function buildGraph(data: ProgressData): { nodes: GraphNode[]; edges: GraphEdge[]; width: number; height: number } {
37
+ const g = new dagre.graphlib.Graph()
38
+ g.setGraph({ rankdir: 'LR', nodesep: 30, ranksep: 60, marginx: 20, marginy: 20 })
39
+ g.setDefaultEdgeLabel(() => ({}))
40
+
41
+ // Collect all tasks with their milestone context
42
+ const allTasks: Array<Task & { milestoneName: string }> = []
43
+ for (const milestone of data.milestones) {
44
+ const tasks = data.tasks[milestone.id] || []
45
+ for (const task of tasks) {
46
+ allTasks.push({ ...task, milestoneName: milestone.name })
47
+ }
48
+ }
49
+
50
+ if (allTasks.length === 0) {
51
+ return { nodes: [], edges: [], width: 0, height: 0 }
52
+ }
53
+
54
+ // Add nodes
55
+ for (const task of allTasks) {
56
+ g.setNode(String(task.id), { width: NODE_WIDTH, height: NODE_HEIGHT })
57
+ }
58
+
59
+ // Infer edges from task ordering within milestones (sequential dependency)
60
+ // Also check task notes/extra for explicit dependency references
61
+ for (const milestone of data.milestones) {
62
+ const tasks = data.tasks[milestone.id] || []
63
+ for (let i = 1; i < tasks.length; i++) {
64
+ g.setEdge(String(tasks[i - 1].id), String(tasks[i].id))
65
+ }
66
+ }
67
+
68
+ // Add cross-milestone edges: last task of milestone N → first task of milestone N+1
69
+ for (let i = 1; i < data.milestones.length; i++) {
70
+ const prevTasks = data.tasks[data.milestones[i - 1].id] || []
71
+ const currTasks = data.tasks[data.milestones[i].id] || []
72
+ if (prevTasks.length > 0 && currTasks.length > 0) {
73
+ g.setEdge(String(prevTasks[prevTasks.length - 1].id), String(currTasks[0].id))
74
+ }
75
+ }
76
+
77
+ dagre.layout(g)
78
+
79
+ const nodes: GraphNode[] = allTasks.map((task) => {
80
+ const node = g.node(String(task.id))
81
+ return {
82
+ id: String(task.id),
83
+ label: task.name,
84
+ status: task.status,
85
+ milestone: task.milestoneName,
86
+ x: node.x,
87
+ y: node.y,
88
+ width: NODE_WIDTH,
89
+ height: NODE_HEIGHT,
90
+ }
91
+ })
92
+
93
+ const edges: GraphEdge[] = g.edges().map((e) => {
94
+ const edge = g.edge(e)
95
+ return {
96
+ from: e.v,
97
+ to: e.w,
98
+ points: edge.points || [],
99
+ }
100
+ })
101
+
102
+ const graphInfo = g.graph()
103
+ return {
104
+ nodes,
105
+ edges,
106
+ width: (graphInfo.width || 800) + 40,
107
+ height: (graphInfo.height || 400) + 40,
108
+ }
109
+ }
110
+
111
+ function edgePathD(points: Array<{ x: number; y: number }>): string {
112
+ if (points.length < 2) return ''
113
+ const [start, ...rest] = points
114
+ return `M ${start.x} ${start.y} ${rest.map((p) => `L ${p.x} ${p.y}`).join(' ')}`
115
+ }
116
+
117
+ export function DependencyGraph({ data }: DependencyGraphProps) {
118
+ const { nodes, edges, width, height } = useMemo(() => buildGraph(data), [data])
119
+
120
+ if (nodes.length === 0) {
121
+ return (
122
+ <div className="text-center py-8">
123
+ <p className="text-gray-600 text-sm">No tasks to display in dependency graph</p>
124
+ </div>
125
+ )
126
+ }
127
+
128
+ return (
129
+ <div className="border border-gray-800 rounded-lg overflow-auto bg-gray-950/50">
130
+ <svg width={width} height={height} className="min-w-full">
131
+ <defs>
132
+ <marker
133
+ id="arrowhead"
134
+ viewBox="0 0 10 7"
135
+ refX="10"
136
+ refY="3.5"
137
+ markerWidth="8"
138
+ markerHeight="6"
139
+ orient="auto"
140
+ >
141
+ <polygon points="0 0, 10 3.5, 0 7" fill="#4b5563" />
142
+ </marker>
143
+ </defs>
144
+
145
+ {/* Edges */}
146
+ {edges.map((edge, i) => (
147
+ <path
148
+ key={i}
149
+ d={edgePathD(edge.points)}
150
+ fill="none"
151
+ stroke="#374151"
152
+ strokeWidth={1.5}
153
+ markerEnd="url(#arrowhead)"
154
+ />
155
+ ))}
156
+
157
+ {/* Nodes */}
158
+ {nodes.map((node) => {
159
+ const colors = statusColors[node.status]
160
+ return (
161
+ <g key={node.id} transform={`translate(${node.x - node.width / 2}, ${node.y - node.height / 2})`}>
162
+ <rect
163
+ width={node.width}
164
+ height={node.height}
165
+ rx={6}
166
+ ry={6}
167
+ fill={colors.bg}
168
+ stroke={colors.border}
169
+ strokeWidth={1.5}
170
+ />
171
+ <text
172
+ x={10}
173
+ y={18}
174
+ fill={colors.text}
175
+ fontSize={11}
176
+ fontFamily="Inter, system-ui, sans-serif"
177
+ fontWeight={500}
178
+ >
179
+ {node.label.length > 22 ? node.label.slice(0, 22) + '...' : node.label}
180
+ </text>
181
+ <text
182
+ x={10}
183
+ y={34}
184
+ fill="#6b7280"
185
+ fontSize={9}
186
+ fontFamily="Inter, system-ui, sans-serif"
187
+ >
188
+ {node.milestone}
189
+ </text>
190
+ </g>
191
+ )
192
+ })}
193
+ </svg>
194
+ </div>
195
+ )
196
+ }
@@ -5,6 +5,7 @@ const statusOptions: Array<{ value: Status | 'all'; label: string }> = [
5
5
  { value: 'in_progress', label: 'In Progress' },
6
6
  { value: 'not_started', label: 'Not Started' },
7
7
  { value: 'completed', label: 'Completed' },
8
+ { value: 'wont_do', label: "Won't Do" },
8
9
  ]
9
10
 
10
11
  interface FilterBarProps {
@@ -0,0 +1,65 @@
1
+ import { useState } from 'react'
2
+ import { Github, Loader2 } from 'lucide-react'
3
+
4
+ interface GitHubInputProps {
5
+ onLoad: (owner: string, repo: string) => Promise<void>
6
+ }
7
+
8
+ export function GitHubInput({ onLoad }: GitHubInputProps) {
9
+ const [value, setValue] = useState('')
10
+ const [loading, setLoading] = useState(false)
11
+ const [error, setError] = useState<string | null>(null)
12
+
13
+ const handleSubmit = async () => {
14
+ setError(null)
15
+ // Parse owner/repo from various formats
16
+ const cleaned = value.trim()
17
+ .replace(/^https?:\/\/github\.com\//, '')
18
+ .replace(/\.git$/, '')
19
+ .replace(/\/$/, '')
20
+
21
+ const parts = cleaned.split('/')
22
+ if (parts.length < 2) {
23
+ setError('Enter owner/repo (e.g. user/project)')
24
+ return
25
+ }
26
+
27
+ setLoading(true)
28
+ try {
29
+ await onLoad(parts[0], parts[1])
30
+ setValue('')
31
+ } catch {
32
+ setError('Failed to load')
33
+ } finally {
34
+ setLoading(false)
35
+ }
36
+ }
37
+
38
+ return (
39
+ <div>
40
+ <div className="flex gap-1">
41
+ <div className="relative flex-1">
42
+ <Github className="absolute left-2 top-[7px] w-3.5 h-3.5 text-gray-500" />
43
+ <input
44
+ type="text"
45
+ value={value}
46
+ onChange={(e) => { setValue(e.target.value); setError(null) }}
47
+ onKeyDown={(e) => e.key === 'Enter' && handleSubmit()}
48
+ placeholder="owner/repo"
49
+ className="w-full bg-gray-900 border border-gray-800 rounded-md pl-7 pr-2 py-1.5 text-xs text-gray-200 placeholder-gray-600 focus:outline-none focus:border-gray-600 transition-colors"
50
+ />
51
+ </div>
52
+ <button
53
+ onClick={handleSubmit}
54
+ disabled={loading || !value.trim()}
55
+ className="px-2 py-1.5 bg-gray-800 border border-gray-700 rounded-md text-xs text-gray-300 hover:bg-gray-700 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
56
+ >
57
+ {loading ? <Loader2 className="w-3 h-3 animate-spin" /> : 'Go'}
58
+ </button>
59
+ </div>
60
+ {error && (
61
+ <p className="text-xs text-red-400 mt-1">{error}</p>
62
+ )}
63
+ </div>
64
+ )
65
+ }
@@ -0,0 +1,150 @@
1
+ import { useMemo } from 'react'
2
+ import { StatusBadge } from './StatusBadge'
3
+ import type { Milestone, Task } from '../lib/types'
4
+
5
+ interface MilestoneGanttProps {
6
+ milestones: Milestone[]
7
+ tasks: Record<string, Task[]>
8
+ }
9
+
10
+ function parseDate(d: string | null): Date | null {
11
+ if (!d) return null
12
+ const parsed = new Date(d)
13
+ return isNaN(parsed.getTime()) ? null : parsed
14
+ }
15
+
16
+ function formatDate(d: Date): string {
17
+ return d.toISOString().slice(0, 10)
18
+ }
19
+
20
+ function daysBetween(a: Date, b: Date): number {
21
+ return Math.max(1, Math.round((b.getTime() - a.getTime()) / (1000 * 60 * 60 * 24)))
22
+ }
23
+
24
+ export function MilestoneGantt({ milestones, tasks }: MilestoneGanttProps) {
25
+ const { bars, minDate, maxDate, totalDays } = useMemo(() => {
26
+ const now = new Date()
27
+
28
+ // Build bars with start/end dates
29
+ const bars = milestones.map((m) => {
30
+ const start = parseDate(m.started)
31
+ const end = parseDate(m.completed) || (m.status === 'in_progress' ? now : null)
32
+
33
+ // Estimate end from start + estimated_weeks if no end date
34
+ const weeks = parseFloat(m.estimated_weeks) || 0
35
+ const estimatedEnd = start && weeks > 0
36
+ ? new Date(start.getTime() + weeks * 7 * 24 * 60 * 60 * 1000)
37
+ : null
38
+
39
+ return {
40
+ milestone: m,
41
+ tasks: tasks[m.id] || [],
42
+ start,
43
+ end: end || estimatedEnd,
44
+ }
45
+ }).filter((b) => b.start != null) // Only show milestones with dates
46
+
47
+ if (bars.length === 0) {
48
+ return { bars: [], minDate: now, maxDate: now, totalDays: 1 }
49
+ }
50
+
51
+ const allDates = bars.flatMap((b) => [b.start!, b.end!].filter(Boolean))
52
+ const minDate = new Date(Math.min(...allDates.map((d) => d.getTime())))
53
+ const maxDate = new Date(Math.max(...allDates.map((d) => d.getTime())))
54
+
55
+ // Add some padding
56
+ minDate.setDate(minDate.getDate() - 2)
57
+ maxDate.setDate(maxDate.getDate() + 2)
58
+
59
+ const totalDays = daysBetween(minDate, maxDate)
60
+
61
+ return { bars, minDate, maxDate, totalDays }
62
+ }, [milestones, tasks])
63
+
64
+ if (bars.length === 0) {
65
+ return (
66
+ <div className="text-center py-8">
67
+ <p className="text-gray-600 text-sm">No milestones with dates for timeline view</p>
68
+ <p className="text-gray-700 text-xs mt-1">
69
+ Add `started` dates to milestones in progress.yaml
70
+ </p>
71
+ </div>
72
+ )
73
+ }
74
+
75
+ // Generate month labels
76
+ const monthLabels: Array<{ label: string; left: number }> = []
77
+ const cursor = new Date(minDate)
78
+ cursor.setDate(1)
79
+ while (cursor <= maxDate) {
80
+ const daysFromStart = daysBetween(minDate, cursor)
81
+ const left = (daysFromStart / totalDays) * 100
82
+ if (left >= 0 && left <= 100) {
83
+ monthLabels.push({
84
+ label: cursor.toLocaleDateString('en-US', { month: 'short', year: '2-digit' }),
85
+ left,
86
+ })
87
+ }
88
+ cursor.setMonth(cursor.getMonth() + 1)
89
+ }
90
+
91
+ return (
92
+ <div className="border border-gray-800 rounded-lg overflow-hidden">
93
+ {/* Timeline header */}
94
+ <div className="relative h-8 bg-gray-900/50 border-b border-gray-800">
95
+ {monthLabels.map((m, i) => (
96
+ <div
97
+ key={i}
98
+ className="absolute top-0 h-full border-l border-gray-800 flex items-center"
99
+ style={{ left: `${Math.max(0, m.left)}%` }}
100
+ >
101
+ <span className="text-[10px] text-gray-500 pl-1.5">{m.label}</span>
102
+ </div>
103
+ ))}
104
+ </div>
105
+
106
+ {/* Bars */}
107
+ <div className="divide-y divide-gray-800/50">
108
+ {bars.map(({ milestone, start, end }) => {
109
+ const barStart = start ? (daysBetween(minDate, start) / totalDays) * 100 : 0
110
+ const barEnd = end ? (daysBetween(minDate, end) / totalDays) * 100 : barStart + 5
111
+ const barWidth = Math.max(2, barEnd - barStart)
112
+
113
+ const barColor =
114
+ milestone.status === 'completed'
115
+ ? 'bg-green-500/40 border-green-500/60'
116
+ : milestone.status === 'in_progress'
117
+ ? 'bg-blue-500/40 border-blue-500/60'
118
+ : 'bg-gray-500/30 border-gray-500/40'
119
+
120
+ return (
121
+ <div key={milestone.id} className="flex items-center h-12 px-3 hover:bg-gray-800/20">
122
+ {/* Label */}
123
+ <div className="w-48 shrink-0 flex items-center gap-2">
124
+ <span className="text-xs text-gray-300 truncate">{milestone.name}</span>
125
+ </div>
126
+ {/* Bar area */}
127
+ <div className="flex-1 relative h-6">
128
+ <div
129
+ className={`absolute top-1 h-4 rounded-sm border ${barColor} transition-all`}
130
+ style={{ left: `${barStart}%`, width: `${barWidth}%` }}
131
+ title={`${milestone.name}: ${start ? formatDate(start) : '?'} → ${end ? formatDate(end) : '?'} (${milestone.progress}%)`}
132
+ >
133
+ {/* Progress fill within bar */}
134
+ <div
135
+ className={`h-full rounded-sm ${
136
+ milestone.status === 'completed'
137
+ ? 'bg-green-500/60'
138
+ : 'bg-blue-500/60'
139
+ }`}
140
+ style={{ width: `${milestone.progress}%` }}
141
+ />
142
+ </div>
143
+ </div>
144
+ </div>
145
+ )
146
+ })}
147
+ </div>
148
+ </div>
149
+ )
150
+ }