tlc-claude-code 1.2.24 → 1.2.26
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/README.md +1 -1
- package/dashboard/dist/App.js +7 -5
- package/dashboard/dist/App.test.js +23 -16
- package/dashboard/dist/components/PlanView.d.ts +32 -0
- package/dashboard/dist/components/PlanView.js +292 -0
- package/dashboard/dist/components/PlanView.test.d.ts +1 -0
- package/dashboard/dist/components/PlanView.test.js +335 -0
- package/docker-compose.dev.yml +9 -6
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -164,7 +164,7 @@ tlc init
|
|
|
164
164
|
|-----|---------|
|
|
165
165
|
| http://localhost:3147 | Dashboard — Live preview, logs, tasks |
|
|
166
166
|
| http://localhost:5001 | App — Your running application |
|
|
167
|
-
| http://localhost:
|
|
167
|
+
| http://localhost:8081 | DB Studio — pgweb (or Drizzle/Prisma Studio) |
|
|
168
168
|
| localhost:5433 | Database — PostgreSQL |
|
|
169
169
|
|
|
170
170
|
**Features:**
|
package/dashboard/dist/App.js
CHANGED
|
@@ -2,7 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
2
2
|
import { Box, Text, useApp, useInput } from 'ink';
|
|
3
3
|
import { useState, useCallback } from 'react';
|
|
4
4
|
import { ChatPane } from './components/ChatPane.js';
|
|
5
|
-
import {
|
|
5
|
+
import { PlanView } from './components/PlanView.js';
|
|
6
6
|
import { PreviewPane } from './components/PreviewPane.js';
|
|
7
7
|
import { AgentsPane } from './components/AgentsPane.js';
|
|
8
8
|
import { GitHubPane } from './components/GitHubPane.js';
|
|
@@ -17,7 +17,7 @@ export function App({ isTTY = true }) {
|
|
|
17
17
|
}
|
|
18
18
|
if (key.tab) {
|
|
19
19
|
setActivePane(prev => {
|
|
20
|
-
const panes = ['chat', 'github', 'agents', 'preview'];
|
|
20
|
+
const panes = ['chat', 'plan', 'github', 'agents', 'preview'];
|
|
21
21
|
const idx = panes.indexOf(prev);
|
|
22
22
|
return panes[(idx + 1) % panes.length];
|
|
23
23
|
});
|
|
@@ -26,10 +26,12 @@ export function App({ isTTY = true }) {
|
|
|
26
26
|
if (input === '1')
|
|
27
27
|
setActivePane('chat');
|
|
28
28
|
if (input === '2')
|
|
29
|
-
setActivePane('
|
|
29
|
+
setActivePane('plan');
|
|
30
30
|
if (input === '3')
|
|
31
|
-
setActivePane('
|
|
31
|
+
setActivePane('github');
|
|
32
32
|
if (input === '4')
|
|
33
|
+
setActivePane('agents');
|
|
34
|
+
if (input === '5')
|
|
33
35
|
setActivePane('preview');
|
|
34
36
|
}, { isActive: isTTY });
|
|
35
37
|
const handleAssignToAgent = useCallback(async (issue) => {
|
|
@@ -45,5 +47,5 @@ export function App({ isTTY = true }) {
|
|
|
45
47
|
return next;
|
|
46
48
|
});
|
|
47
49
|
}, []);
|
|
48
|
-
return (_jsxs(Box, { flexDirection: "column", width: "100%", height: "100%", children: [_jsxs(Box, { borderStyle: "single", paddingX: 1, justifyContent: "space-between", children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, color: "cyan", children: "
|
|
50
|
+
return (_jsxs(Box, { flexDirection: "column", width: "100%", height: "100%", children: [_jsxs(Box, { borderStyle: "single", paddingX: 1, justifyContent: "space-between", children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, color: "cyan", children: "TLC Dashboard" }), _jsx(Text, { color: "gray", children: " | " }), _jsx(Text, { color: activePane === 'chat' ? 'cyan' : 'gray', children: "[1]Chat " }), _jsx(Text, { color: activePane === 'plan' ? 'cyan' : 'gray', children: "[2]Plan " }), _jsx(Text, { color: activePane === 'github' ? 'cyan' : 'gray', children: "[3]GitHub " }), _jsx(Text, { color: activePane === 'agents' ? 'cyan' : 'gray', children: "[4]Agents " }), _jsx(Text, { color: activePane === 'preview' ? 'cyan' : 'gray', children: "[5]Preview" })] }), _jsx(Box, { children: _jsx(Text, { color: "cyan", bold: true, children: "| TLC |" }) })] }), _jsxs(Box, { flexGrow: 1, flexDirection: "row", children: [_jsxs(Box, { flexDirection: "column", width: "60%", borderStyle: "single", borderColor: activePane === 'chat' ? 'cyan' : 'gray', children: [_jsx(Box, { paddingX: 1, borderStyle: "single", borderBottom: true, borderLeft: false, borderRight: false, borderTop: false, children: _jsx(Text, { bold: true, color: activePane === 'chat' ? 'cyan' : 'white', children: "Chat" }) }), _jsx(ChatPane, { isActive: activePane === 'chat', isTTY: isTTY })] }), _jsxs(Box, { flexDirection: "column", width: "40%", children: [_jsxs(Box, { flexDirection: "column", height: "30%", borderStyle: "single", borderColor: activePane === 'github' ? 'cyan' : 'gray', children: [_jsx(Box, { paddingX: 1, borderStyle: "single", borderBottom: true, borderLeft: false, borderRight: false, borderTop: false, children: _jsx(Text, { bold: true, color: activePane === 'github' ? 'cyan' : 'white', children: "GitHub Issues" }) }), _jsx(GitHubPane, { isActive: activePane === 'github', isTTY: isTTY, onAssignToAgent: handleAssignToAgent })] }), _jsxs(Box, { flexDirection: "column", height: "30%", borderStyle: "single", borderColor: activePane === 'agents' ? 'cyan' : 'gray', children: [_jsx(Box, { paddingX: 1, borderStyle: "single", borderBottom: true, borderLeft: false, borderRight: false, borderTop: false, children: _jsx(Text, { bold: true, color: activePane === 'agents' ? 'cyan' : 'white', children: "Agents" }) }), _jsx(AgentsPane, { isActive: activePane === 'agents', isTTY: isTTY, onTaskComplete: handleTaskComplete })] }), _jsxs(Box, { flexDirection: "column", height: "20%", borderStyle: "single", borderColor: activePane === 'plan' ? 'cyan' : 'gray', children: [_jsx(Box, { paddingX: 1, borderStyle: "single", borderBottom: true, borderLeft: false, borderRight: false, borderTop: false, children: _jsx(Text, { bold: true, color: activePane === 'plan' ? 'cyan' : 'white', children: "Plan" }) }), _jsx(PlanView, {})] }), _jsxs(Box, { flexDirection: "column", flexGrow: 1, borderStyle: "single", borderColor: activePane === 'preview' ? 'cyan' : 'gray', children: [_jsx(Box, { paddingX: 1, borderStyle: "single", borderBottom: true, borderLeft: false, borderRight: false, borderTop: false, children: _jsx(Text, { bold: true, color: activePane === 'preview' ? 'cyan' : 'white', children: "Preview" }) }), _jsx(PreviewPane, { isActive: activePane === 'preview', isTTY: isTTY })] })] })] }), _jsx(Box, { borderStyle: "single", paddingX: 1, children: _jsx(Text, { dimColor: true, children: "Tab: cycle panes | 1-5: jump to pane | Ctrl+Q: quit" }) })] }));
|
|
49
51
|
}
|
|
@@ -44,18 +44,19 @@ describe('App', () => {
|
|
|
44
44
|
const { lastFrame } = render(_jsx(App, {}));
|
|
45
45
|
expect(lastFrame()).toBeDefined();
|
|
46
46
|
});
|
|
47
|
-
it('shows
|
|
47
|
+
it('shows TLC Dashboard header', () => {
|
|
48
48
|
const { lastFrame } = render(_jsx(App, {}));
|
|
49
49
|
const output = lastFrame();
|
|
50
|
-
expect(output).toContain('
|
|
50
|
+
expect(output).toContain('TLC Dashboard');
|
|
51
51
|
});
|
|
52
52
|
it('shows all pane labels in header', () => {
|
|
53
53
|
const { lastFrame } = render(_jsx(App, {}));
|
|
54
54
|
const output = lastFrame();
|
|
55
55
|
expect(output).toContain('[1]Chat');
|
|
56
|
-
expect(output).toContain('[2]
|
|
57
|
-
expect(output).toContain('[3]
|
|
58
|
-
expect(output).toContain('[4]
|
|
56
|
+
expect(output).toContain('[2]Plan');
|
|
57
|
+
expect(output).toContain('[3]GitHub');
|
|
58
|
+
expect(output).toContain('[4]Agents');
|
|
59
|
+
expect(output).toContain('[5]Preview');
|
|
59
60
|
});
|
|
60
61
|
it('shows footer with keyboard hints', () => {
|
|
61
62
|
const { lastFrame } = render(_jsx(App, {}));
|
|
@@ -80,10 +81,10 @@ describe('App', () => {
|
|
|
80
81
|
const output = lastFrame();
|
|
81
82
|
expect(output).toContain('Agents');
|
|
82
83
|
});
|
|
83
|
-
it('shows
|
|
84
|
+
it('shows Plan pane', () => {
|
|
84
85
|
const { lastFrame } = render(_jsx(App, {}));
|
|
85
86
|
const output = lastFrame();
|
|
86
|
-
expect(output).toContain('
|
|
87
|
+
expect(output).toContain('Plan');
|
|
87
88
|
});
|
|
88
89
|
it('shows Preview pane', () => {
|
|
89
90
|
const { lastFrame } = render(_jsx(App, {}));
|
|
@@ -92,23 +93,29 @@ describe('App', () => {
|
|
|
92
93
|
});
|
|
93
94
|
});
|
|
94
95
|
describe('keyboard navigation', () => {
|
|
95
|
-
it('switches to
|
|
96
|
+
it('switches to plan pane when pressing 2', () => {
|
|
96
97
|
const { lastFrame, stdin } = render(_jsx(App, {}));
|
|
97
98
|
stdin.write('2');
|
|
98
99
|
const output = lastFrame();
|
|
99
|
-
//
|
|
100
|
-
expect(output).toContain('
|
|
100
|
+
// Plan pane should now be active (shown by highlighting)
|
|
101
|
+
expect(output).toContain('Plan');
|
|
101
102
|
});
|
|
102
|
-
it('switches to
|
|
103
|
+
it('switches to github pane when pressing 3', () => {
|
|
103
104
|
const { lastFrame, stdin } = render(_jsx(App, {}));
|
|
104
105
|
stdin.write('3');
|
|
105
106
|
const output = lastFrame();
|
|
106
|
-
expect(output).toContain('
|
|
107
|
+
expect(output).toContain('GitHub');
|
|
107
108
|
});
|
|
108
|
-
it('switches to
|
|
109
|
+
it('switches to agents pane when pressing 4', () => {
|
|
109
110
|
const { lastFrame, stdin } = render(_jsx(App, {}));
|
|
110
111
|
stdin.write('4');
|
|
111
112
|
const output = lastFrame();
|
|
113
|
+
expect(output).toContain('Agents');
|
|
114
|
+
});
|
|
115
|
+
it('switches to preview pane when pressing 5', () => {
|
|
116
|
+
const { lastFrame, stdin } = render(_jsx(App, {}));
|
|
117
|
+
stdin.write('5');
|
|
118
|
+
const output = lastFrame();
|
|
112
119
|
expect(output).toContain('Preview');
|
|
113
120
|
});
|
|
114
121
|
it('switches back to chat pane when pressing 1', () => {
|
|
@@ -127,11 +134,11 @@ describe('App', () => {
|
|
|
127
134
|
expect(output).toBeDefined();
|
|
128
135
|
});
|
|
129
136
|
});
|
|
130
|
-
describe('
|
|
131
|
-
it('shows
|
|
137
|
+
describe('TLC branding', () => {
|
|
138
|
+
it('shows TLC indicator in header', () => {
|
|
132
139
|
const { lastFrame } = render(_jsx(App, {}));
|
|
133
140
|
const output = lastFrame();
|
|
134
|
-
expect(output).toContain('
|
|
141
|
+
expect(output).toContain('TLC');
|
|
135
142
|
});
|
|
136
143
|
});
|
|
137
144
|
});
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export interface Task {
|
|
2
|
+
number: number;
|
|
3
|
+
name: string;
|
|
4
|
+
status: 'completed' | 'in_progress' | 'pending';
|
|
5
|
+
owner?: string;
|
|
6
|
+
criteriaDone: number;
|
|
7
|
+
criteriaTotal: number;
|
|
8
|
+
}
|
|
9
|
+
export interface Phase {
|
|
10
|
+
number: number;
|
|
11
|
+
name: string;
|
|
12
|
+
status: 'completed' | 'in_progress' | 'pending';
|
|
13
|
+
tasksDone: number;
|
|
14
|
+
tasksInProgress: number;
|
|
15
|
+
tasksTotal: number;
|
|
16
|
+
progress: number;
|
|
17
|
+
tasks: Task[];
|
|
18
|
+
}
|
|
19
|
+
export interface Milestone {
|
|
20
|
+
name: string;
|
|
21
|
+
status: 'completed' | 'in_progress' | 'pending';
|
|
22
|
+
phaseNumbers: number[];
|
|
23
|
+
phases: Phase[];
|
|
24
|
+
}
|
|
25
|
+
export interface PlanViewProps {
|
|
26
|
+
expandedPhase?: number;
|
|
27
|
+
filter?: 'all' | 'in_progress' | 'pending' | 'completed';
|
|
28
|
+
}
|
|
29
|
+
export declare function PlanView({ expandedPhase, filter }?: PlanViewProps): import("react/jsx-runtime").JSX.Element;
|
|
30
|
+
export declare function parseMilestones(content: string): Milestone[];
|
|
31
|
+
export declare function parsePhases(roadmapContent: string, planContents: Record<number, string>): Phase[];
|
|
32
|
+
export declare function parseTasks(content: string): Task[];
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
import { useState, useEffect } from 'react';
|
|
4
|
+
import { readFile, readdir } from 'fs/promises';
|
|
5
|
+
import { existsSync } from 'fs';
|
|
6
|
+
import { join } from 'path';
|
|
7
|
+
export function PlanView({ expandedPhase, filter = 'all' } = {}) {
|
|
8
|
+
const [milestones, setMilestones] = useState([]);
|
|
9
|
+
const [loading, setLoading] = useState(true);
|
|
10
|
+
const [collapsedMilestones, setCollapsedMilestones] = useState(new Set());
|
|
11
|
+
useEffect(() => {
|
|
12
|
+
async function loadPlan() {
|
|
13
|
+
const roadmapPath = join(process.cwd(), '.planning', 'ROADMAP.md');
|
|
14
|
+
if (!existsSync(roadmapPath)) {
|
|
15
|
+
setMilestones([]);
|
|
16
|
+
setLoading(false);
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
try {
|
|
20
|
+
const content = await readFile(roadmapPath, 'utf-8');
|
|
21
|
+
const planContents = await loadPlanFiles();
|
|
22
|
+
const parsedMilestones = parseMilestones(content);
|
|
23
|
+
const phases = parsePhases(content, planContents);
|
|
24
|
+
// Attach phases to milestones
|
|
25
|
+
const milestonesWithPhases = parsedMilestones.map(m => ({
|
|
26
|
+
...m,
|
|
27
|
+
phases: phases.filter(p => m.phaseNumbers.includes(p.number))
|
|
28
|
+
}));
|
|
29
|
+
// Auto-collapse completed milestones (but not if there's only one milestone)
|
|
30
|
+
const completed = new Set();
|
|
31
|
+
if (milestonesWithPhases.length > 1) {
|
|
32
|
+
milestonesWithPhases.forEach(m => {
|
|
33
|
+
if (m.status === 'completed') {
|
|
34
|
+
completed.add(m.name);
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
setCollapsedMilestones(completed);
|
|
39
|
+
setMilestones(milestonesWithPhases);
|
|
40
|
+
}
|
|
41
|
+
catch (e) {
|
|
42
|
+
setMilestones([]);
|
|
43
|
+
}
|
|
44
|
+
setLoading(false);
|
|
45
|
+
}
|
|
46
|
+
loadPlan();
|
|
47
|
+
const interval = setInterval(loadPlan, 5000);
|
|
48
|
+
return () => clearInterval(interval);
|
|
49
|
+
}, []);
|
|
50
|
+
if (loading) {
|
|
51
|
+
return (_jsx(Box, { padding: 1, children: _jsx(Text, { color: "gray", children: "Loading..." }) }));
|
|
52
|
+
}
|
|
53
|
+
if (milestones.length === 0) {
|
|
54
|
+
return (_jsxs(Box, { padding: 1, flexDirection: "column", children: [_jsx(Text, { color: "gray", children: "No roadmap found." }), _jsx(Text, { color: "gray", dimColor: true, children: "Run /tlc:new-project or /tlc:init" })] }));
|
|
55
|
+
}
|
|
56
|
+
return (_jsx(Box, { padding: 1, flexDirection: "column", children: milestones.map((milestone, idx) => (_jsx(MilestoneView, { milestone: milestone, collapsed: collapsedMilestones.has(milestone.name), expandedPhase: expandedPhase, filter: filter }, milestone.name))) }));
|
|
57
|
+
}
|
|
58
|
+
function MilestoneView({ milestone, collapsed, expandedPhase, filter }) {
|
|
59
|
+
const statusIcon = milestone.status === 'completed' ? '✓' :
|
|
60
|
+
milestone.status === 'in_progress' ? '▶' : '○';
|
|
61
|
+
const statusColor = milestone.status === 'completed' ? 'green' :
|
|
62
|
+
milestone.status === 'in_progress' ? 'yellow' : 'gray';
|
|
63
|
+
const completedPhases = milestone.phases.filter(p => p.status === 'completed').length;
|
|
64
|
+
const totalPhases = milestone.phases.length;
|
|
65
|
+
const progress = totalPhases > 0 ? Math.round((completedPhases / totalPhases) * 100) : 0;
|
|
66
|
+
const filteredPhases = milestone.phases.filter(p => {
|
|
67
|
+
if (filter === 'all')
|
|
68
|
+
return true;
|
|
69
|
+
return p.status === filter;
|
|
70
|
+
});
|
|
71
|
+
return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Box, { children: [_jsxs(Text, { color: statusColor, children: [statusIcon, " "] }), _jsx(Text, { bold: true, color: milestone.status === 'in_progress' ? 'cyan' : 'white', children: milestone.name }), _jsxs(Text, { color: "gray", children: [" (", completedPhases, "/", totalPhases, " phases"] }), progress > 0 && _jsxs(Text, { color: "gray", children: [" \u00B7 ", progress, "%"] }), _jsx(Text, { color: "gray", children: ")" }), collapsed && _jsx(Text, { color: "gray", dimColor: true, children: " [collapsed]" })] }), !collapsed && filteredPhases.map(phase => (_jsx(PhaseView, { phase: phase, expanded: expandedPhase === phase.number }, phase.number)))] }));
|
|
72
|
+
}
|
|
73
|
+
function PhaseView({ phase, expanded }) {
|
|
74
|
+
const statusIcon = phase.status === 'completed' ? '[x]' :
|
|
75
|
+
phase.status === 'in_progress' ? '[>]' : '[ ]';
|
|
76
|
+
const statusColor = phase.status === 'completed' ? 'green' :
|
|
77
|
+
phase.status === 'in_progress' ? 'yellow' : 'gray';
|
|
78
|
+
const progressBar = renderProgressBar(phase.progress, 10);
|
|
79
|
+
const taskInfo = phase.tasksTotal > 0
|
|
80
|
+
? `${phase.tasksDone}/${phase.tasksTotal}`
|
|
81
|
+
: '0/0';
|
|
82
|
+
// Truncate long phase names
|
|
83
|
+
const maxNameLength = 30;
|
|
84
|
+
const displayName = phase.name.length > maxNameLength
|
|
85
|
+
? phase.name.slice(0, maxNameLength - 1) + '…'
|
|
86
|
+
: phase.name;
|
|
87
|
+
return (_jsxs(Box, { flexDirection: "column", marginLeft: 2, children: [_jsxs(Box, { children: [_jsxs(Text, { color: statusColor, children: [statusIcon, " "] }), _jsxs(Text, { color: phase.status === 'in_progress' ? 'cyan' : 'white', children: [phase.number, ". ", displayName] }), _jsx(Text, { color: "gray", children: " " }), _jsx(Text, { color: phase.progress === 100 ? 'green' : 'gray', children: progressBar }), _jsxs(Text, { color: "gray", children: [" ", taskInfo] }), phase.tasksInProgress > 0 && (_jsxs(Text, { color: "yellow", children: [" (", phase.tasksInProgress, " active)"] }))] }), expanded && phase.tasks.length > 0 && (_jsx(Box, { flexDirection: "column", marginLeft: 2, marginTop: 0, children: phase.tasks.map(task => (_jsx(TaskView, { task: task }, task.number))) }))] }));
|
|
88
|
+
}
|
|
89
|
+
function TaskView({ task }) {
|
|
90
|
+
const statusIcon = task.status === 'completed' ? '✓' :
|
|
91
|
+
task.status === 'in_progress' ? '▶' : '○';
|
|
92
|
+
const statusColor = task.status === 'completed' ? 'green' :
|
|
93
|
+
task.status === 'in_progress' ? 'yellow' : 'gray';
|
|
94
|
+
const criteriaInfo = task.criteriaTotal > 0
|
|
95
|
+
? ` (${task.criteriaDone}/${task.criteriaTotal} criteria)`
|
|
96
|
+
: '';
|
|
97
|
+
return (_jsxs(Box, { children: [_jsxs(Text, { color: statusColor, children: [statusIcon, " "] }), _jsx(Text, { color: task.status === 'in_progress' ? 'cyan' : 'white', children: task.name }), task.owner && _jsxs(Text, { color: "magenta", children: [" @", task.owner] }), criteriaInfo && _jsx(Text, { color: "gray", children: criteriaInfo })] }));
|
|
98
|
+
}
|
|
99
|
+
function renderProgressBar(percent, width) {
|
|
100
|
+
const filled = Math.round((percent / 100) * width);
|
|
101
|
+
const empty = width - filled;
|
|
102
|
+
return '█'.repeat(filled) + '░'.repeat(empty);
|
|
103
|
+
}
|
|
104
|
+
async function loadPlanFiles() {
|
|
105
|
+
const phasesDir = join(process.cwd(), '.planning', 'phases');
|
|
106
|
+
const planContents = {};
|
|
107
|
+
if (!existsSync(phasesDir)) {
|
|
108
|
+
return planContents;
|
|
109
|
+
}
|
|
110
|
+
try {
|
|
111
|
+
const entries = await readdir(phasesDir, { withFileTypes: true });
|
|
112
|
+
for (const entry of entries) {
|
|
113
|
+
if (entry.isDirectory()) {
|
|
114
|
+
// Extract phase number from directory name (e.g., "01-core" -> 1)
|
|
115
|
+
const match = entry.name.match(/^(\d+)/);
|
|
116
|
+
if (match) {
|
|
117
|
+
const phaseNum = parseInt(match[1], 10);
|
|
118
|
+
const planPath = join(phasesDir, entry.name, `${match[1]}-PLAN.md`);
|
|
119
|
+
if (existsSync(planPath)) {
|
|
120
|
+
try {
|
|
121
|
+
planContents[phaseNum] = await readFile(planPath, 'utf-8');
|
|
122
|
+
}
|
|
123
|
+
catch (e) {
|
|
124
|
+
// Skip unreadable files
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
catch (e) {
|
|
132
|
+
// Return empty if can't read directory
|
|
133
|
+
}
|
|
134
|
+
return planContents;
|
|
135
|
+
}
|
|
136
|
+
export function parseMilestones(content) {
|
|
137
|
+
const milestones = [];
|
|
138
|
+
const lines = content.split('\n');
|
|
139
|
+
let currentMilestone = null;
|
|
140
|
+
let hasExplicitMilestones = false;
|
|
141
|
+
// Track phase statuses per milestone for status calculation
|
|
142
|
+
const milestonePhaseStatuses = new Map();
|
|
143
|
+
let milestoneIndex = -1;
|
|
144
|
+
for (const line of lines) {
|
|
145
|
+
// Match milestone headers like "## Milestone: v1.0 - Release" or "## Milestone: v1.0 [x]"
|
|
146
|
+
const milestoneMatch = line.match(/^##\s*Milestone:\s*(.+?)(?:\s*\[([x>]?)\])?\s*$/i);
|
|
147
|
+
if (milestoneMatch) {
|
|
148
|
+
hasExplicitMilestones = true;
|
|
149
|
+
if (currentMilestone) {
|
|
150
|
+
milestones.push(currentMilestone);
|
|
151
|
+
}
|
|
152
|
+
milestoneIndex++;
|
|
153
|
+
milestonePhaseStatuses.set(milestoneIndex, []);
|
|
154
|
+
currentMilestone = {
|
|
155
|
+
name: milestoneMatch[1].trim(),
|
|
156
|
+
status: milestoneMatch[2] === 'x' ? 'completed' : 'pending',
|
|
157
|
+
phaseNumbers: [],
|
|
158
|
+
phases: []
|
|
159
|
+
};
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
// Match phase headers like "### Phase 1: Setup [x]"
|
|
163
|
+
const phaseMatch = line.match(/^###\s*(?:Phase\s+)?(\d+)[.:]?\s*(.+?)(?:\s*\[([x>]?)\])?\s*$/i);
|
|
164
|
+
if (phaseMatch) {
|
|
165
|
+
const phaseNum = parseInt(phaseMatch[1], 10);
|
|
166
|
+
const phaseStatus = phaseMatch[3] === 'x' ? 'completed' :
|
|
167
|
+
phaseMatch[3] === '>' ? 'in_progress' : 'pending';
|
|
168
|
+
if (currentMilestone) {
|
|
169
|
+
currentMilestone.phaseNumbers.push(phaseNum);
|
|
170
|
+
milestonePhaseStatuses.get(milestoneIndex)?.push(phaseStatus);
|
|
171
|
+
}
|
|
172
|
+
else if (!hasExplicitMilestones) {
|
|
173
|
+
// Create implicit "Current" milestone for roadmaps without explicit milestones
|
|
174
|
+
milestoneIndex = 0;
|
|
175
|
+
milestonePhaseStatuses.set(milestoneIndex, [phaseStatus]);
|
|
176
|
+
currentMilestone = {
|
|
177
|
+
name: 'Current',
|
|
178
|
+
status: 'pending',
|
|
179
|
+
phaseNumbers: [phaseNum],
|
|
180
|
+
phases: []
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
// Add the last milestone
|
|
186
|
+
if (currentMilestone) {
|
|
187
|
+
milestones.push(currentMilestone);
|
|
188
|
+
}
|
|
189
|
+
// Determine milestone statuses from their phases
|
|
190
|
+
milestones.forEach((milestone, idx) => {
|
|
191
|
+
const statuses = milestonePhaseStatuses.get(idx) || [];
|
|
192
|
+
if (statuses.length === 0) {
|
|
193
|
+
milestone.status = 'pending';
|
|
194
|
+
}
|
|
195
|
+
else if (statuses.every(s => s === 'completed')) {
|
|
196
|
+
milestone.status = 'completed';
|
|
197
|
+
}
|
|
198
|
+
else if (statuses.some(s => s === 'in_progress')) {
|
|
199
|
+
milestone.status = 'in_progress';
|
|
200
|
+
}
|
|
201
|
+
else {
|
|
202
|
+
milestone.status = 'pending';
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
return milestones;
|
|
206
|
+
}
|
|
207
|
+
export function parsePhases(roadmapContent, planContents) {
|
|
208
|
+
const phases = [];
|
|
209
|
+
const lines = roadmapContent.split('\n');
|
|
210
|
+
for (const line of lines) {
|
|
211
|
+
const match = line.match(/^###\s*(?:Phase\s+)?(\d+)[.:]?\s*(.+?)(?:\s*\[([x>]?)\])?\s*$/i);
|
|
212
|
+
if (match) {
|
|
213
|
+
const phaseNum = parseInt(match[1], 10);
|
|
214
|
+
const phaseName = match[2].replace(/\s*\[.*?\]\s*$/, '').trim();
|
|
215
|
+
const status = match[3] === 'x' ? 'completed' :
|
|
216
|
+
match[3] === '>' ? 'in_progress' : 'pending';
|
|
217
|
+
// Parse tasks from PLAN file if available
|
|
218
|
+
const planContent = planContents[phaseNum] || '';
|
|
219
|
+
const tasks = parseTasks(planContent);
|
|
220
|
+
const tasksDone = tasks.filter(t => t.status === 'completed').length;
|
|
221
|
+
const tasksInProgress = tasks.filter(t => t.status === 'in_progress').length;
|
|
222
|
+
const tasksTotal = tasks.length;
|
|
223
|
+
const progress = tasksTotal > 0 ? Math.round((tasksDone / tasksTotal) * 100) : 0;
|
|
224
|
+
phases.push({
|
|
225
|
+
number: phaseNum,
|
|
226
|
+
name: phaseName,
|
|
227
|
+
status,
|
|
228
|
+
tasksDone,
|
|
229
|
+
tasksInProgress,
|
|
230
|
+
tasksTotal,
|
|
231
|
+
progress,
|
|
232
|
+
tasks
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
return phases;
|
|
237
|
+
}
|
|
238
|
+
export function parseTasks(content) {
|
|
239
|
+
const tasks = [];
|
|
240
|
+
const lines = content.split('\n');
|
|
241
|
+
let currentTask = null;
|
|
242
|
+
let inAcceptanceCriteria = false;
|
|
243
|
+
for (let i = 0; i < lines.length; i++) {
|
|
244
|
+
const line = lines[i];
|
|
245
|
+
// Match task headers like "### Task 1: Setup Project [x@alice]" or "### Task 1: Name [ ]"
|
|
246
|
+
// Also match without brackets for incomplete lines
|
|
247
|
+
const taskMatch = line.match(/^###\s*Task\s+(\d+)[.:]?\s*(.+?)\s*\[([x> ]?)(?:@(\w+))?\]\s*$/i);
|
|
248
|
+
if (taskMatch) {
|
|
249
|
+
// Save previous task
|
|
250
|
+
if (currentTask) {
|
|
251
|
+
tasks.push(currentTask);
|
|
252
|
+
}
|
|
253
|
+
const statusChar = taskMatch[3].trim();
|
|
254
|
+
const status = statusChar === 'x' ? 'completed' :
|
|
255
|
+
statusChar === '>' ? 'in_progress' : 'pending';
|
|
256
|
+
currentTask = {
|
|
257
|
+
number: parseInt(taskMatch[1], 10),
|
|
258
|
+
name: taskMatch[2].trim(),
|
|
259
|
+
status,
|
|
260
|
+
owner: taskMatch[4] || undefined,
|
|
261
|
+
criteriaDone: 0,
|
|
262
|
+
criteriaTotal: 0
|
|
263
|
+
};
|
|
264
|
+
inAcceptanceCriteria = false;
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
// Track when we enter acceptance criteria section
|
|
268
|
+
if (line.match(/\*\*Acceptance Criteria\*\*|Acceptance Criteria:/i)) {
|
|
269
|
+
inAcceptanceCriteria = true;
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
// Count acceptance criteria checkboxes
|
|
273
|
+
if (currentTask && (inAcceptanceCriteria || line.match(/^-\s*\[[ x]\]/))) {
|
|
274
|
+
const checkboxMatch = line.match(/^-\s*\[([ x])\]/);
|
|
275
|
+
if (checkboxMatch) {
|
|
276
|
+
currentTask.criteriaTotal++;
|
|
277
|
+
if (checkboxMatch[1] === 'x') {
|
|
278
|
+
currentTask.criteriaDone++;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
// Reset criteria tracking on new section
|
|
283
|
+
if (line.match(/^###\s/) && !line.match(/^###\s*Task/i)) {
|
|
284
|
+
inAcceptanceCriteria = false;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
// Don't forget the last task
|
|
288
|
+
if (currentTask) {
|
|
289
|
+
tasks.push(currentTask);
|
|
290
|
+
}
|
|
291
|
+
return tasks;
|
|
292
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
3
|
+
import { render } from 'ink-testing-library';
|
|
4
|
+
import { PlanView, parseMilestones, parsePhases, parseTasks } from './PlanView.js';
|
|
5
|
+
import { vol } from 'memfs';
|
|
6
|
+
// Mock fs modules
|
|
7
|
+
vi.mock('fs', async () => {
|
|
8
|
+
const memfs = await import('memfs');
|
|
9
|
+
return memfs.fs;
|
|
10
|
+
});
|
|
11
|
+
vi.mock('fs/promises', async () => {
|
|
12
|
+
const memfs = await import('memfs');
|
|
13
|
+
return memfs.fs.promises;
|
|
14
|
+
});
|
|
15
|
+
describe('PlanView', () => {
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
vol.reset();
|
|
18
|
+
});
|
|
19
|
+
describe('parseMilestones', () => {
|
|
20
|
+
it('extracts milestones from roadmap', () => {
|
|
21
|
+
const content = `# Roadmap
|
|
22
|
+
|
|
23
|
+
## Milestone: v1.0 - Initial Release
|
|
24
|
+
|
|
25
|
+
### Phase 1: Setup [x]
|
|
26
|
+
### Phase 2: Core [>]
|
|
27
|
+
|
|
28
|
+
## Milestone: v1.1 - Improvements
|
|
29
|
+
|
|
30
|
+
### Phase 3: Polish
|
|
31
|
+
### Phase 4: Docs`;
|
|
32
|
+
const milestones = parseMilestones(content);
|
|
33
|
+
expect(milestones).toHaveLength(2);
|
|
34
|
+
expect(milestones[0].name).toBe('v1.0 - Initial Release');
|
|
35
|
+
expect(milestones[0].phaseNumbers).toEqual([1, 2]);
|
|
36
|
+
expect(milestones[1].name).toBe('v1.1 - Improvements');
|
|
37
|
+
expect(milestones[1].phaseNumbers).toEqual([3, 4]);
|
|
38
|
+
});
|
|
39
|
+
it('handles roadmap with no explicit milestones', () => {
|
|
40
|
+
const content = `# Roadmap
|
|
41
|
+
|
|
42
|
+
### Phase 1: Setup [x]
|
|
43
|
+
### Phase 2: Core`;
|
|
44
|
+
const milestones = parseMilestones(content);
|
|
45
|
+
expect(milestones).toHaveLength(1);
|
|
46
|
+
expect(milestones[0].name).toBe('Current');
|
|
47
|
+
expect(milestones[0].phaseNumbers).toEqual([1, 2]);
|
|
48
|
+
});
|
|
49
|
+
it('detects milestone status from phase statuses', () => {
|
|
50
|
+
const content = `## Milestone: v1.0
|
|
51
|
+
|
|
52
|
+
### Phase 1: Setup [x]
|
|
53
|
+
### Phase 2: Core [x]
|
|
54
|
+
|
|
55
|
+
## Milestone: v1.1
|
|
56
|
+
|
|
57
|
+
### Phase 3: Polish [>]
|
|
58
|
+
### Phase 4: Docs`;
|
|
59
|
+
const milestones = parseMilestones(content);
|
|
60
|
+
expect(milestones[0].status).toBe('completed');
|
|
61
|
+
expect(milestones[1].status).toBe('in_progress');
|
|
62
|
+
});
|
|
63
|
+
it('marks milestone as pending if no phases started', () => {
|
|
64
|
+
const content = `## Milestone: v1.0
|
|
65
|
+
|
|
66
|
+
### Phase 1: Setup [x]
|
|
67
|
+
|
|
68
|
+
## Milestone: v1.1
|
|
69
|
+
|
|
70
|
+
### Phase 2: Core
|
|
71
|
+
### Phase 3: Docs`;
|
|
72
|
+
const milestones = parseMilestones(content);
|
|
73
|
+
expect(milestones[0].status).toBe('completed');
|
|
74
|
+
expect(milestones[1].status).toBe('pending');
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
describe('parsePhases', () => {
|
|
78
|
+
it('parses phases with task counts from PLAN files', () => {
|
|
79
|
+
const roadmapContent = `### Phase 1: Setup [x]
|
|
80
|
+
### Phase 2: Core [>]`;
|
|
81
|
+
const planContents = {
|
|
82
|
+
1: `# Phase 1
|
|
83
|
+
### Task 1: Init [x@alice]
|
|
84
|
+
### Task 2: Config [x@bob]`,
|
|
85
|
+
2: `# Phase 2
|
|
86
|
+
### Task 1: API [>@alice]
|
|
87
|
+
### Task 2: DB [ ]
|
|
88
|
+
### Task 3: Auth [ ]`
|
|
89
|
+
};
|
|
90
|
+
const phases = parsePhases(roadmapContent, planContents);
|
|
91
|
+
expect(phases).toHaveLength(2);
|
|
92
|
+
expect(phases[0].tasksDone).toBe(2);
|
|
93
|
+
expect(phases[0].tasksTotal).toBe(2);
|
|
94
|
+
expect(phases[1].tasksDone).toBe(0);
|
|
95
|
+
expect(phases[1].tasksTotal).toBe(3);
|
|
96
|
+
expect(phases[1].tasksInProgress).toBe(1);
|
|
97
|
+
});
|
|
98
|
+
it('calculates progress percentage', () => {
|
|
99
|
+
const roadmapContent = `### Phase 1: Core`;
|
|
100
|
+
const planContents = {
|
|
101
|
+
1: `### Task 1: A [x@u]
|
|
102
|
+
### Task 2: B [x@u]
|
|
103
|
+
### Task 3: C [ ]
|
|
104
|
+
### Task 4: D [ ]`
|
|
105
|
+
};
|
|
106
|
+
const phases = parsePhases(roadmapContent, planContents);
|
|
107
|
+
expect(phases[0].progress).toBe(50);
|
|
108
|
+
});
|
|
109
|
+
it('handles phases with no PLAN file', () => {
|
|
110
|
+
const roadmapContent = `### Phase 1: Setup [x]
|
|
111
|
+
### Phase 2: Planned`;
|
|
112
|
+
const planContents = {
|
|
113
|
+
1: `### Task 1: Done [x@u]`
|
|
114
|
+
};
|
|
115
|
+
const phases = parsePhases(roadmapContent, planContents);
|
|
116
|
+
expect(phases).toHaveLength(2);
|
|
117
|
+
expect(phases[1].tasksTotal).toBe(0);
|
|
118
|
+
expect(phases[1].progress).toBe(0);
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
describe('parseTasks', () => {
|
|
122
|
+
it('extracts tasks from PLAN content', () => {
|
|
123
|
+
const content = `# Phase 1
|
|
124
|
+
|
|
125
|
+
### Task 1: Setup Project [x@alice]
|
|
126
|
+
Some description
|
|
127
|
+
|
|
128
|
+
### Task 2: Add Config [>@bob]
|
|
129
|
+
Working on it
|
|
130
|
+
|
|
131
|
+
### Task 3: Write Tests [ ]
|
|
132
|
+
Not started`;
|
|
133
|
+
const tasks = parseTasks(content);
|
|
134
|
+
expect(tasks).toHaveLength(3);
|
|
135
|
+
expect(tasks[0]).toMatchObject({
|
|
136
|
+
number: 1,
|
|
137
|
+
name: 'Setup Project',
|
|
138
|
+
status: 'completed',
|
|
139
|
+
owner: 'alice'
|
|
140
|
+
});
|
|
141
|
+
expect(tasks[1]).toMatchObject({
|
|
142
|
+
number: 2,
|
|
143
|
+
name: 'Add Config',
|
|
144
|
+
status: 'in_progress',
|
|
145
|
+
owner: 'bob'
|
|
146
|
+
});
|
|
147
|
+
expect(tasks[2]).toMatchObject({
|
|
148
|
+
number: 3,
|
|
149
|
+
name: 'Write Tests',
|
|
150
|
+
status: 'pending',
|
|
151
|
+
owner: undefined
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
it('parses acceptance criteria counts', () => {
|
|
155
|
+
const content = `### Task 1: Feature [>@dev]
|
|
156
|
+
|
|
157
|
+
**Acceptance Criteria:**
|
|
158
|
+
- [x] First item done
|
|
159
|
+
- [x] Second item done
|
|
160
|
+
- [ ] Third item pending
|
|
161
|
+
- [ ] Fourth item pending`;
|
|
162
|
+
const tasks = parseTasks(content);
|
|
163
|
+
expect(tasks[0].criteriaDone).toBe(2);
|
|
164
|
+
expect(tasks[0].criteriaTotal).toBe(4);
|
|
165
|
+
});
|
|
166
|
+
it('handles tasks without acceptance criteria', () => {
|
|
167
|
+
const content = `### Task 1: Simple [x@dev]
|
|
168
|
+
Just a simple task`;
|
|
169
|
+
const tasks = parseTasks(content);
|
|
170
|
+
expect(tasks[0].criteriaDone).toBe(0);
|
|
171
|
+
expect(tasks[0].criteriaTotal).toBe(0);
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
describe('component rendering', () => {
|
|
175
|
+
it('shows loading state initially', () => {
|
|
176
|
+
const { lastFrame } = render(_jsx(PlanView, {}));
|
|
177
|
+
expect(lastFrame()).toContain('Loading');
|
|
178
|
+
});
|
|
179
|
+
it('shows empty state when no roadmap', async () => {
|
|
180
|
+
const { lastFrame } = render(_jsx(PlanView, {}));
|
|
181
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
182
|
+
expect(lastFrame()).toContain('No roadmap found');
|
|
183
|
+
});
|
|
184
|
+
it('renders milestones with phases', async () => {
|
|
185
|
+
vol.fromJSON({
|
|
186
|
+
[process.cwd() + '/.planning/ROADMAP.md']: `# Roadmap
|
|
187
|
+
|
|
188
|
+
## Milestone: v1.0 - Release
|
|
189
|
+
|
|
190
|
+
### Phase 1: Setup [x]
|
|
191
|
+
### Phase 2: Core [>]
|
|
192
|
+
### Phase 3: Polish`
|
|
193
|
+
});
|
|
194
|
+
const { lastFrame } = render(_jsx(PlanView, {}));
|
|
195
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
196
|
+
const output = lastFrame();
|
|
197
|
+
expect(output).toContain('v1.0');
|
|
198
|
+
expect(output).toContain('Setup');
|
|
199
|
+
expect(output).toContain('Core');
|
|
200
|
+
expect(output).toContain('Polish');
|
|
201
|
+
});
|
|
202
|
+
it('shows progress bar for phases', async () => {
|
|
203
|
+
vol.fromJSON({
|
|
204
|
+
[process.cwd() + '/.planning/ROADMAP.md']: `### Phase 1: Core [>]`,
|
|
205
|
+
[process.cwd() + '/.planning/phases/01-core/01-PLAN.md']: `
|
|
206
|
+
### Task 1: A [x@u]
|
|
207
|
+
### Task 2: B [x@u]
|
|
208
|
+
### Task 3: C [ ]
|
|
209
|
+
### Task 4: D [ ]`
|
|
210
|
+
});
|
|
211
|
+
const { lastFrame } = render(_jsx(PlanView, {}));
|
|
212
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
213
|
+
const output = lastFrame();
|
|
214
|
+
// Should show 2/4 or 50% progress indicator
|
|
215
|
+
expect(output).toMatch(/2\/4|50%|████/);
|
|
216
|
+
});
|
|
217
|
+
it('shows task counts per phase', async () => {
|
|
218
|
+
vol.fromJSON({
|
|
219
|
+
[process.cwd() + '/.planning/ROADMAP.md']: `### Phase 1: Auth [x]`,
|
|
220
|
+
[process.cwd() + '/.planning/phases/01-auth/01-PLAN.md']: `
|
|
221
|
+
### Task 1: Login [x@a]
|
|
222
|
+
### Task 2: Logout [x@b]
|
|
223
|
+
### Task 3: Session [x@a]`
|
|
224
|
+
});
|
|
225
|
+
const { lastFrame } = render(_jsx(PlanView, {}));
|
|
226
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
227
|
+
const output = lastFrame();
|
|
228
|
+
expect(output).toContain('3/3');
|
|
229
|
+
});
|
|
230
|
+
it('collapses completed milestones by default', async () => {
|
|
231
|
+
vol.fromJSON({
|
|
232
|
+
[process.cwd() + '/.planning/ROADMAP.md']: `
|
|
233
|
+
## Milestone: v0.9 [x]
|
|
234
|
+
|
|
235
|
+
### Phase 1: Old [x]
|
|
236
|
+
|
|
237
|
+
## Milestone: v1.0
|
|
238
|
+
|
|
239
|
+
### Phase 2: Current [>]`
|
|
240
|
+
});
|
|
241
|
+
const { lastFrame } = render(_jsx(PlanView, {}));
|
|
242
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
243
|
+
const output = lastFrame();
|
|
244
|
+
// v0.9 should be collapsed (not showing "Old")
|
|
245
|
+
expect(output).toContain('v0.9');
|
|
246
|
+
expect(output).toContain('Current');
|
|
247
|
+
// Old phase should not be visible (collapsed)
|
|
248
|
+
expect(output).not.toMatch(/Old\s+\[/);
|
|
249
|
+
});
|
|
250
|
+
it('expands phase to show tasks when selected', async () => {
|
|
251
|
+
vol.fromJSON({
|
|
252
|
+
[process.cwd() + '/.planning/ROADMAP.md']: `### Phase 1: Core [>]`,
|
|
253
|
+
[process.cwd() + '/.planning/phases/01-core/01-PLAN.md']: `
|
|
254
|
+
### Task 1: API [x@alice]
|
|
255
|
+
### Task 2: DB [>@bob]`
|
|
256
|
+
});
|
|
257
|
+
const { lastFrame } = render(_jsx(PlanView, { expandedPhase: 1 }));
|
|
258
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
259
|
+
const output = lastFrame();
|
|
260
|
+
expect(output).toContain('API');
|
|
261
|
+
expect(output).toContain('alice');
|
|
262
|
+
expect(output).toContain('DB');
|
|
263
|
+
expect(output).toContain('bob');
|
|
264
|
+
});
|
|
265
|
+
it('shows filter options', async () => {
|
|
266
|
+
vol.fromJSON({
|
|
267
|
+
[process.cwd() + '/.planning/ROADMAP.md']: `
|
|
268
|
+
### Phase 1: Done [x]
|
|
269
|
+
### Phase 2: Active [>]
|
|
270
|
+
### Phase 3: Pending`
|
|
271
|
+
});
|
|
272
|
+
const { lastFrame } = render(_jsx(PlanView, { filter: "in_progress" }));
|
|
273
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
274
|
+
const output = lastFrame();
|
|
275
|
+
expect(output).toContain('Active');
|
|
276
|
+
expect(output).not.toContain('Done');
|
|
277
|
+
expect(output).not.toContain('Pending');
|
|
278
|
+
});
|
|
279
|
+
it('shows overall milestone progress', async () => {
|
|
280
|
+
vol.fromJSON({
|
|
281
|
+
[process.cwd() + '/.planning/ROADMAP.md']: `
|
|
282
|
+
## Milestone: v1.0
|
|
283
|
+
|
|
284
|
+
### Phase 1: A [x]
|
|
285
|
+
### Phase 2: B [x]
|
|
286
|
+
### Phase 3: C [>]
|
|
287
|
+
### Phase 4: D`
|
|
288
|
+
});
|
|
289
|
+
const { lastFrame } = render(_jsx(PlanView, {}));
|
|
290
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
291
|
+
const output = lastFrame();
|
|
292
|
+
// Should show 2/4 phases or 50% for milestone
|
|
293
|
+
expect(output).toMatch(/2\/4|50%/);
|
|
294
|
+
});
|
|
295
|
+
});
|
|
296
|
+
describe('edge cases', () => {
|
|
297
|
+
it('handles malformed PLAN files gracefully', async () => {
|
|
298
|
+
vol.fromJSON({
|
|
299
|
+
[process.cwd() + '/.planning/ROADMAP.md']: `### Phase 1: Test`,
|
|
300
|
+
[process.cwd() + '/.planning/phases/01-test/01-PLAN.md']: `
|
|
301
|
+
This is not a proper plan file
|
|
302
|
+
No tasks here
|
|
303
|
+
Just random text`
|
|
304
|
+
});
|
|
305
|
+
const { lastFrame } = render(_jsx(PlanView, {}));
|
|
306
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
307
|
+
const output = lastFrame();
|
|
308
|
+
expect(output).toContain('Test');
|
|
309
|
+
expect(output).toContain('0/0');
|
|
310
|
+
});
|
|
311
|
+
it('handles very long phase names', async () => {
|
|
312
|
+
vol.fromJSON({
|
|
313
|
+
[process.cwd() + '/.planning/ROADMAP.md']: `### Phase 1: This Is A Very Long Phase Name That Should Be Truncated [>]`
|
|
314
|
+
});
|
|
315
|
+
const { lastFrame } = render(_jsx(PlanView, {}));
|
|
316
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
317
|
+
// Should not crash, should render something
|
|
318
|
+
expect(lastFrame()).toBeTruthy();
|
|
319
|
+
});
|
|
320
|
+
it('handles phases numbered non-sequentially', async () => {
|
|
321
|
+
vol.fromJSON({
|
|
322
|
+
[process.cwd() + '/.planning/ROADMAP.md']: `
|
|
323
|
+
### Phase 1: First [x]
|
|
324
|
+
### Phase 3: Third [>]
|
|
325
|
+
### Phase 5: Fifth`
|
|
326
|
+
});
|
|
327
|
+
const { lastFrame } = render(_jsx(PlanView, {}));
|
|
328
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
329
|
+
const output = lastFrame();
|
|
330
|
+
expect(output).toContain('First');
|
|
331
|
+
expect(output).toContain('Third');
|
|
332
|
+
expect(output).toContain('Fifth');
|
|
333
|
+
});
|
|
334
|
+
});
|
|
335
|
+
});
|
package/docker-compose.dev.yml
CHANGED
|
@@ -58,17 +58,20 @@ services:
|
|
|
58
58
|
condition: service_healthy
|
|
59
59
|
restart: on-failure
|
|
60
60
|
|
|
61
|
-
# Database GUI (
|
|
61
|
+
# Database GUI (pgweb - clean, modern)
|
|
62
|
+
# Note: If using Drizzle/Prisma, run their studios instead:
|
|
63
|
+
# npx drizzle-kit studio (port 4983)
|
|
64
|
+
# npx prisma studio (port 5555)
|
|
62
65
|
dbadmin:
|
|
63
|
-
image:
|
|
66
|
+
image: sosedoff/pgweb:latest
|
|
64
67
|
container_name: tlc-${COMPOSE_PROJECT_NAME:-dev}-dbadmin
|
|
65
68
|
ports:
|
|
66
|
-
- "${DBADMIN_PORT:-
|
|
69
|
+
- "${DBADMIN_PORT:-8081}:8081"
|
|
67
70
|
environment:
|
|
68
|
-
-
|
|
69
|
-
- ADMINER_DESIGN=nette
|
|
71
|
+
- PGWEB_DATABASE_URL=postgres://postgres:postgres@db:5432/app?sslmode=disable
|
|
70
72
|
depends_on:
|
|
71
|
-
|
|
73
|
+
db:
|
|
74
|
+
condition: service_healthy
|
|
72
75
|
restart: on-failure
|
|
73
76
|
|
|
74
77
|
# MinIO S3-Compatible Storage
|