cctd-web 0.1.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.
@@ -0,0 +1,32 @@
1
+ #!/usr/bin/env node
2
+
3
+ const { createServer } = require('../lib/server');
4
+
5
+ const args = process.argv.slice(2);
6
+
7
+ if (args.includes('-h') || args.includes('--help')) {
8
+ console.log(`
9
+ Usage: cctd-web [options]
10
+
11
+ Options:
12
+ -p, --port <number> Port number (default: 3456)
13
+ -d, --dir <path> .tasks/ directory path (default: ./.tasks)
14
+ --no-open Don't auto-open browser
15
+ -h, --help Show help
16
+ `);
17
+ process.exit(0);
18
+ }
19
+
20
+ function getArg(flags, defaultVal) {
21
+ for (const flag of flags) {
22
+ const idx = args.indexOf(flag);
23
+ if (idx !== -1 && idx + 1 < args.length) return args[idx + 1];
24
+ }
25
+ return defaultVal;
26
+ }
27
+
28
+ const port = parseInt(getArg(['-p', '--port'], '3456'), 10);
29
+ const dir = getArg(['-d', '--dir'], './.tasks');
30
+ const noOpen = args.includes('--no-open');
31
+
32
+ createServer({ port, dir, noOpen });
@@ -0,0 +1,110 @@
1
+ const MAX_EVENTS = 500;
2
+
3
+ class ActivityTracker {
4
+ constructor() {
5
+ this.events = [];
6
+ this.prevState = null;
7
+ }
8
+
9
+ update(newState) {
10
+ if (!this.prevState) {
11
+ this.prevState = deepCopy(newState);
12
+ return [];
13
+ }
14
+
15
+ const now = new Date().toISOString();
16
+ const changes = [];
17
+
18
+ const prevTaskMap = new Map(this.prevState.tasks.map(t => [t.id, t]));
19
+ const newTaskMap = new Map(newState.tasks.map(t => [t.id, t]));
20
+ const prevStoryMap = new Map(this.prevState.stories.map(s => [s.id, s]));
21
+ const newStoryMap = new Map(newState.stories.map(s => [s.id, s]));
22
+
23
+ // Detect task changes
24
+ for (const [id, task] of newTaskMap) {
25
+ const prev = prevTaskMap.get(id);
26
+ if (!prev) {
27
+ changes.push({ ts: now, type: 'task_added', taskId: id, title: task.title, status: task.status });
28
+ continue;
29
+ }
30
+
31
+ // Status change
32
+ if (prev.status !== task.status) {
33
+ changes.push({
34
+ ts: now, type: 'status_changed', taskId: id, title: task.title,
35
+ from: prev.status, to: task.status,
36
+ });
37
+ }
38
+
39
+ // Work log additions
40
+ if (task.workLog && task.workLog !== prev.workLog) {
41
+ const newLines = diffWorkLog(prev.workLog || '', task.workLog);
42
+ if (newLines.length) {
43
+ changes.push({
44
+ ts: now, type: 'worklog_added', taskId: id, title: task.title,
45
+ lines: newLines,
46
+ });
47
+ }
48
+ }
49
+
50
+ // Spec change
51
+ if (task.spec && task.spec !== prev.spec) {
52
+ changes.push({ ts: now, type: 'spec_updated', taskId: id, title: task.title });
53
+ }
54
+
55
+ // Deps change
56
+ if (JSON.stringify(task.deps || []) !== JSON.stringify(prev.deps || [])) {
57
+ changes.push({
58
+ ts: now, type: 'deps_changed', taskId: id, title: task.title,
59
+ from: prev.deps || [], to: task.deps || [],
60
+ });
61
+ }
62
+ }
63
+
64
+ // Detect removed tasks
65
+ for (const [id, prev] of prevTaskMap) {
66
+ if (!newTaskMap.has(id)) {
67
+ changes.push({ ts: now, type: 'task_removed', taskId: id, title: prev.title });
68
+ }
69
+ }
70
+
71
+ // Detect story changes
72
+ for (const [id, story] of newStoryMap) {
73
+ const prev = prevStoryMap.get(id);
74
+ if (!prev) {
75
+ changes.push({ ts: now, type: 'story_added', storyId: id, title: story.title, status: story.status });
76
+ continue;
77
+ }
78
+ if (prev.status !== story.status) {
79
+ changes.push({
80
+ ts: now, type: 'story_status_changed', storyId: id, title: story.title,
81
+ from: prev.status, to: story.status,
82
+ });
83
+ }
84
+ }
85
+
86
+ // Store and trim
87
+ this.events.push(...changes);
88
+ if (this.events.length > MAX_EVENTS) {
89
+ this.events = this.events.slice(-MAX_EVENTS);
90
+ }
91
+
92
+ this.prevState = deepCopy(newState);
93
+ return changes;
94
+ }
95
+
96
+ getAll() {
97
+ return this.events;
98
+ }
99
+ }
100
+
101
+ function diffWorkLog(oldLog, newLog) {
102
+ const oldLines = new Set(oldLog.split('\n').map(l => l.trim()).filter(Boolean));
103
+ return newLog.split('\n').map(l => l.trim()).filter(l => l && !oldLines.has(l));
104
+ }
105
+
106
+ function deepCopy(obj) {
107
+ return JSON.parse(JSON.stringify(obj));
108
+ }
109
+
110
+ module.exports = { ActivityTracker };
package/lib/parser.js ADDED
@@ -0,0 +1,193 @@
1
+ const fs = require('node:fs');
2
+ const path = require('node:path');
3
+
4
+ function parseIndex(tasksDir) {
5
+ const indexPath = path.join(tasksDir, 'index.md');
6
+ if (!fs.existsSync(indexPath)) return { stories: [], tasks: [] };
7
+
8
+ const content = fs.readFileSync(indexPath, 'utf-8');
9
+ const lines = content.split('\n');
10
+
11
+ const stories = [];
12
+ const tasks = [];
13
+ let section = null;
14
+
15
+ for (const line of lines) {
16
+ const trimmed = line.trim();
17
+ if (!trimmed || trimmed.startsWith('#')) {
18
+ if (/^#\s*stories/i.test(trimmed)) section = 'stories';
19
+ else if (/^#\s*tasks/i.test(trimmed)) section = 'tasks';
20
+ else if (/^#\s*task\s+index/i.test(trimmed)) section = 'tasks';
21
+ continue;
22
+ }
23
+
24
+ const parts = trimmed.split('|').map(s => s.trim());
25
+ if (parts.length < 4) continue;
26
+
27
+ if (section === 'stories' || (!section && !parts[0].includes('-') && parts.length === 6)) {
28
+ stories.push({
29
+ id: parts[0],
30
+ status: parts[1],
31
+ title: parts[2],
32
+ labels: parts[3] ? parts[3].split(',').map(s => s.trim()).filter(Boolean) : [],
33
+ priority: parts[4] || 'medium',
34
+ created: parts[5] || '',
35
+ });
36
+ } else {
37
+ // Tasks: ID|Status|Title|Agent(or Labels)|Deps(or Priority)|Created
38
+ const isFullFormat = parts[0].includes('-');
39
+ if (isFullFormat) {
40
+ tasks.push({
41
+ id: parts[0],
42
+ status: parts[1],
43
+ title: parts[2],
44
+ agent: parts[3] || '',
45
+ deps: parts[4] && parts[4] !== '-' ? parts[4].split(',').map(s => s.trim()) : [],
46
+ created: parts[5] || '',
47
+ });
48
+ } else {
49
+ tasks.push({
50
+ id: parts[0],
51
+ status: parts[1],
52
+ title: parts[2],
53
+ labels: parts[3] ? parts[3].split(',').map(s => s.trim()).filter(Boolean) : [],
54
+ priority: parts[4] || 'medium',
55
+ created: parts[5] || '',
56
+ });
57
+ }
58
+ }
59
+ }
60
+
61
+ return { stories, tasks };
62
+ }
63
+
64
+ function parseMdMeta(content) {
65
+ const meta = {};
66
+ const lines = content.split('\n');
67
+ let i = 0;
68
+
69
+ // Title from first H1
70
+ for (; i < lines.length; i++) {
71
+ const m = lines[i].match(/^#\s+(.+)/);
72
+ if (m) { meta.title = m[1].trim(); i++; break; }
73
+ }
74
+
75
+ // Key-value pairs: "- Key: Value"
76
+ for (; i < lines.length; i++) {
77
+ const m = lines[i].match(/^-\s+(.+?):\s*(.+)/);
78
+ if (m) {
79
+ const key = m[1].trim().toLowerCase().replace(/[- ]/g, '_');
80
+ meta[key] = m[2].trim();
81
+ } else if (lines[i].match(/^##/)) {
82
+ break;
83
+ }
84
+ }
85
+
86
+ // Sections: ## Name -> content
87
+ const sections = {};
88
+ let currentSection = null;
89
+ let sectionLines = [];
90
+
91
+ for (; i < lines.length; i++) {
92
+ const heading = lines[i].match(/^##\s+(.+)/);
93
+ if (heading) {
94
+ if (currentSection) sections[currentSection] = sectionLines.join('\n').trim();
95
+ currentSection = heading[1].trim().toLowerCase().replace(/\s+/g, '_');
96
+ sectionLines = [];
97
+ } else if (currentSection) {
98
+ sectionLines.push(lines[i]);
99
+ }
100
+ }
101
+ if (currentSection) sections[currentSection] = sectionLines.join('\n').trim();
102
+
103
+ return { ...meta, sections };
104
+ }
105
+
106
+ function parseStory(filePath) {
107
+ if (!fs.existsSync(filePath)) return null;
108
+ const content = fs.readFileSync(filePath, 'utf-8');
109
+ const meta = parseMdMeta(content);
110
+
111
+ return {
112
+ id: meta.id || '',
113
+ title: meta.title || '',
114
+ status: meta.status || '',
115
+ labels: meta.labels ? meta.labels.split(',').map(s => s.trim()).filter(Boolean) : [],
116
+ priority: meta.priority || 'medium',
117
+ created: meta.created || '',
118
+ userStory: meta.sections?.user_story || '',
119
+ acceptanceCriteria: meta.sections?.acceptance_criteria || '',
120
+ workLog: meta.sections?.work_log || '',
121
+ };
122
+ }
123
+
124
+ function parseTask(filePath) {
125
+ if (!fs.existsSync(filePath)) return null;
126
+ const content = fs.readFileSync(filePath, 'utf-8');
127
+ const meta = parseMdMeta(content);
128
+
129
+ const parseDeps = (val) => {
130
+ if (!val || val === '-') return [];
131
+ return val.split(',').map(s => s.trim()).filter(Boolean);
132
+ };
133
+
134
+ return {
135
+ id: meta.id || '',
136
+ title: meta.title || '',
137
+ status: meta.status || '',
138
+ story: meta.story || '',
139
+ agent: meta.agent || '',
140
+ labels: meta.labels ? meta.labels.split(',').map(s => s.trim()).filter(Boolean) : [],
141
+ priority: meta.priority || 'medium',
142
+ created: meta.created || '',
143
+ deps: parseDeps(meta.deps || meta.blocked_by),
144
+ blocks: parseDeps(meta.blocks),
145
+ spec: meta.sections?.spec || meta.sections?.description || '',
146
+ workLog: meta.sections?.work_log || '',
147
+ };
148
+ }
149
+
150
+ function parseAll(tasksDir) {
151
+ const index = parseIndex(tasksDir);
152
+
153
+ // Parse story files
154
+ const storiesDir = path.join(tasksDir, 'stories');
155
+ const storyDetails = [];
156
+ if (fs.existsSync(storiesDir)) {
157
+ const files = fs.readdirSync(storiesDir).filter(f => f.endsWith('.md'));
158
+ for (const file of files) {
159
+ const story = parseStory(path.join(storiesDir, file));
160
+ if (story) storyDetails.push(story);
161
+ }
162
+ }
163
+
164
+ // Parse task files
165
+ const tasksSubDir = path.join(tasksDir, 'tasks');
166
+ const taskDetails = [];
167
+ if (fs.existsSync(tasksSubDir)) {
168
+ const files = fs.readdirSync(tasksSubDir).filter(f => f.endsWith('.md'));
169
+ for (const file of files) {
170
+ const task = parseTask(path.join(tasksSubDir, file));
171
+ if (task) taskDetails.push(task);
172
+ }
173
+ }
174
+
175
+ // Merge index data with file details
176
+ const storiesMap = new Map(storyDetails.map(s => [s.id, s]));
177
+ const tasksMap = new Map(taskDetails.map(t => [t.id, t]));
178
+
179
+ const stories = index.stories.map(s => ({ ...s, ...storiesMap.get(s.id) }));
180
+ const tasks = index.tasks.map(t => ({ ...t, ...tasksMap.get(t.id) }));
181
+
182
+ // Also include tasks from files not in index
183
+ for (const t of taskDetails) {
184
+ if (!index.tasks.find(it => it.id === t.id)) tasks.push(t);
185
+ }
186
+ for (const s of storyDetails) {
187
+ if (!index.stories.find(is => is.id === s.id)) stories.push(s);
188
+ }
189
+
190
+ return { stories, tasks };
191
+ }
192
+
193
+ module.exports = { parseIndex, parseStory, parseTask, parseAll };
package/lib/server.js ADDED
@@ -0,0 +1,118 @@
1
+ const http = require('node:http');
2
+ const fs = require('node:fs');
3
+ const path = require('node:path');
4
+ const { exec } = require('node:child_process');
5
+ const { parseAll } = require('./parser');
6
+ const { createWatcher } = require('./watcher');
7
+ const { ActivityTracker } = require('./activity');
8
+
9
+ function createServer({ port, dir, noOpen }) {
10
+ const tasksDir = path.resolve(dir);
11
+ const publicDir = path.join(__dirname, '..', 'public');
12
+
13
+ // SSE clients
14
+ const clients = new Set();
15
+ const activity = new ActivityTracker();
16
+
17
+ // Initialize activity tracker with current state
18
+ activity.update(getData());
19
+
20
+ function broadcast(data, changes) {
21
+ const payload = `data: ${JSON.stringify(data)}\n\n`;
22
+ for (const res of clients) {
23
+ res.write(payload);
24
+ }
25
+ if (changes && changes.length) {
26
+ const actPayload = `event: activity\ndata: ${JSON.stringify(changes)}\n\n`;
27
+ for (const res of clients) {
28
+ res.write(actPayload);
29
+ }
30
+ }
31
+ }
32
+
33
+ function getData() {
34
+ try {
35
+ return parseAll(tasksDir);
36
+ } catch (err) {
37
+ console.error(`Parse error: ${err.message}`);
38
+ return { stories: [], tasks: [], error: err.message };
39
+ }
40
+ }
41
+
42
+ const server = http.createServer((req, res) => {
43
+ const url = new URL(req.url, `http://localhost:${port}`);
44
+
45
+ if (url.pathname === '/events') {
46
+ res.writeHead(200, {
47
+ 'Content-Type': 'text/event-stream',
48
+ 'Cache-Control': 'no-cache',
49
+ Connection: 'keep-alive',
50
+ });
51
+ res.write(`data: ${JSON.stringify(getData())}\n\n`);
52
+ clients.add(res);
53
+ req.on('close', () => clients.delete(res));
54
+ return;
55
+ }
56
+
57
+ if (url.pathname === '/api/data') {
58
+ const data = getData();
59
+ res.writeHead(200, { 'Content-Type': 'application/json' });
60
+ res.end(JSON.stringify(data));
61
+ return;
62
+ }
63
+
64
+ if (url.pathname === '/api/activity') {
65
+ res.writeHead(200, { 'Content-Type': 'application/json' });
66
+ res.end(JSON.stringify(activity.getAll()));
67
+ return;
68
+ }
69
+
70
+ // Serve index.html for root
71
+ if (url.pathname === '/' || url.pathname === '/index.html') {
72
+ const htmlPath = path.join(publicDir, 'index.html');
73
+ if (fs.existsSync(htmlPath)) {
74
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
75
+ res.end(fs.readFileSync(htmlPath, 'utf-8'));
76
+ } else {
77
+ res.writeHead(500);
78
+ res.end('index.html not found');
79
+ }
80
+ return;
81
+ }
82
+
83
+ res.writeHead(404);
84
+ res.end('Not found');
85
+ });
86
+
87
+ // Watch .tasks/ directory
88
+ if (fs.existsSync(tasksDir)) {
89
+ createWatcher(tasksDir, () => {
90
+ const newData = getData();
91
+ const changes = activity.update(newData);
92
+ if (changes.length) {
93
+ console.log(`File change: ${changes.map(c => c.type).join(', ')}`);
94
+ }
95
+ broadcast(newData, changes);
96
+ });
97
+ } else {
98
+ console.warn(`\x1b[33mWarning: ${tasksDir} not found. Run /cctd init first.\x1b[0m`);
99
+ }
100
+
101
+ server.listen(port, () => {
102
+ const url = `http://localhost:${port}`;
103
+ console.log(`\x1b[32m✅ cctd-web running at ${url}\x1b[0m`);
104
+ console.log(` Watching: ${tasksDir}`);
105
+ console.log(` Press Ctrl+C to stop\n`);
106
+
107
+ if (!noOpen) {
108
+ const cmd = process.platform === 'darwin' ? 'open'
109
+ : process.platform === 'win32' ? 'start'
110
+ : 'xdg-open';
111
+ exec(`${cmd} ${url}`, () => {});
112
+ }
113
+ });
114
+
115
+ return server;
116
+ }
117
+
118
+ module.exports = { createServer };
package/lib/watcher.js ADDED
@@ -0,0 +1,21 @@
1
+ const fs = require('node:fs');
2
+
3
+ function createWatcher(dir, callback, debounceMs = 100) {
4
+ let timer = null;
5
+
6
+ const watcher = fs.watch(dir, { recursive: true }, (_event, _filename) => {
7
+ if (timer) clearTimeout(timer);
8
+ timer = setTimeout(() => {
9
+ timer = null;
10
+ callback();
11
+ }, debounceMs);
12
+ });
13
+
14
+ watcher.on('error', (err) => {
15
+ console.error(`Watch error: ${err.message}`);
16
+ });
17
+
18
+ return watcher;
19
+ }
20
+
21
+ module.exports = { createWatcher };
package/package.json ADDED
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "cctd-web",
3
+ "version": "0.1.0",
4
+ "description": "Lightweight web viewer for CCTD .tasks/ directory",
5
+ "bin": {
6
+ "cctd-web": "./bin/cctd-web.js"
7
+ },
8
+ "files": [
9
+ "bin/",
10
+ "lib/",
11
+ "public/"
12
+ ],
13
+ "keywords": [
14
+ "cctd",
15
+ "task-management",
16
+ "dashboard",
17
+ "cli"
18
+ ],
19
+ "license": "MIT",
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "git+https://github.com/esakat/cctd-web.git"
23
+ },
24
+ "engines": {
25
+ "node": ">=18.0.0"
26
+ }
27
+ }
@@ -0,0 +1,520 @@
1
+ <!DOCTYPE html>
2
+ <html lang="ja">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>CCTD Web</title>
7
+ <script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
8
+ <style>
9
+ *,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
10
+ :root{
11
+ --bg:#0A0F1C;--bg2:#111827;--bg3:#1F2937;--bg4:#374151;
12
+ --text:#E2E8F0;--text2:#94A3B8;--text3:#64748B;
13
+ --border:#1E293B;
14
+ --accent:#6366F1;
15
+ --backlog:#6B7280;--defined:#3B82F6;--ai_ready:#8B5CF6;
16
+ --in_progress:#EAB308;--testing:#F97316;--review:#F59E0B;--done:#22C55E;
17
+ --todo:#EAB308;--wip:#F97316;
18
+ --radius:8px;
19
+ }
20
+ html{font-size:14px}
21
+ body{font-family:system-ui,-apple-system,sans-serif;background:var(--bg);color:var(--text);min-height:100vh}
22
+ a{color:var(--accent);text-decoration:none}
23
+
24
+ /* Header */
25
+ .header{display:flex;align-items:center;justify-content:space-between;padding:12px 24px;background:var(--bg2);border-bottom:1px solid var(--border);position:sticky;top:0;z-index:100}
26
+ .header h1{font-size:1.2rem;font-weight:700;display:flex;align-items:center;gap:8px}
27
+ .header h1 span{color:var(--accent)}
28
+ .stats{display:flex;gap:16px;font-size:.85rem;color:var(--text2)}
29
+ .stats .stat{display:flex;align-items:center;gap:4px}
30
+ .stats .num{font-weight:700;color:var(--text)}
31
+ .view-toggle{display:flex;gap:4px}
32
+ .view-toggle button{background:var(--bg3);border:1px solid var(--border);color:var(--text2);padding:6px 14px;border-radius:var(--radius);cursor:pointer;font-size:.8rem;transition:all .15s}
33
+ .view-toggle button.active{background:var(--accent);color:#fff;border-color:var(--accent)}
34
+ .view-toggle button:hover:not(.active){background:var(--bg4)}
35
+
36
+ /* Filter bar */
37
+ .filter-bar{display:flex;gap:8px;padding:10px 24px;background:var(--bg2);border-bottom:1px solid var(--border);flex-wrap:wrap;align-items:center}
38
+ .filter-bar label{font-size:.8rem;color:var(--text2)}
39
+ .filter-bar select{background:var(--bg3);color:var(--text);border:1px solid var(--border);border-radius:var(--radius);padding:4px 8px;font-size:.8rem}
40
+
41
+ /* Kanban */
42
+ .kanban{display:flex;gap:12px;padding:16px 24px;overflow-x:auto;min-height:calc(100vh - 110px)}
43
+ .kanban-col{min-width:220px;max-width:280px;flex:1;display:flex;flex-direction:column;gap:8px}
44
+ .kanban-col-header{font-size:.75rem;font-weight:700;text-transform:uppercase;letter-spacing:.05em;padding:8px 12px;border-radius:var(--radius);display:flex;align-items:center;justify-content:space-between}
45
+ .kanban-col-header .count{background:rgba(255,255,255,.1);padding:2px 8px;border-radius:10px;font-size:.7rem}
46
+ .kanban-cards{display:flex;flex-direction:column;gap:6px;flex:1}
47
+
48
+ /* Card */
49
+ .card{background:var(--bg2);border:1px solid var(--border);border-radius:var(--radius);padding:12px;cursor:pointer;transition:all .15s;border-left:3px solid transparent}
50
+ .card:hover{background:var(--bg3);transform:translateY(-1px)}
51
+ .card-title{font-size:.85rem;font-weight:600;margin-bottom:6px;line-height:1.3}
52
+ .card-meta{display:flex;flex-wrap:wrap;gap:4px;font-size:.7rem}
53
+ .card-meta .tag{padding:2px 6px;border-radius:4px;background:rgba(255,255,255,.06)}
54
+ .card-meta .agent{color:var(--accent)}
55
+ .card-meta .story-ref{color:var(--text3)}
56
+ .card-meta .priority-high{color:#EF4444}
57
+ .card-meta .priority-medium{color:#EAB308}
58
+ .card-meta .priority-low{color:#6B7280}
59
+
60
+ /* List View */
61
+ .list-view{padding:16px 24px;max-width:1200px}
62
+ .story-group{margin-bottom:24px}
63
+ .story-header{display:flex;align-items:center;gap:12px;margin-bottom:8px;padding:8px 0;border-bottom:1px solid var(--border)}
64
+ .story-header h2{font-size:1rem;font-weight:600}
65
+ .story-header .badge{font-size:.7rem;padding:2px 8px;border-radius:4px}
66
+ .progress-bar{height:6px;background:var(--bg4);border-radius:3px;overflow:hidden;margin-bottom:12px;max-width:400px}
67
+ .progress-bar-fill{height:100%;background:var(--done);transition:width .3s}
68
+ .task-table{width:100%;border-collapse:collapse}
69
+ .task-table th{text-align:left;font-size:.7rem;text-transform:uppercase;color:var(--text3);padding:6px 10px;border-bottom:1px solid var(--border)}
70
+ .task-table td{padding:8px 10px;border-bottom:1px solid var(--border);font-size:.85rem}
71
+ .task-table tr{cursor:pointer;transition:background .1s}
72
+ .task-table tr:hover{background:var(--bg3)}
73
+
74
+ /* Status badge */
75
+ .status{display:inline-block;padding:2px 8px;border-radius:4px;font-size:.7rem;font-weight:600;text-transform:uppercase}
76
+ .status-backlog{background:rgba(107,114,128,.2);color:var(--backlog)}
77
+ .status-defined{background:rgba(59,130,246,.15);color:var(--defined)}
78
+ .status-ai_ready{background:rgba(139,92,246,.15);color:var(--ai_ready)}
79
+ .status-in_progress,.status-wip{background:rgba(234,179,8,.15);color:var(--in_progress)}
80
+ .status-testing{background:rgba(249,115,22,.15);color:var(--testing)}
81
+ .status-review{background:rgba(245,158,11,.15);color:var(--review)}
82
+ .status-done{background:rgba(34,197,94,.15);color:var(--done)}
83
+ .status-todo{background:rgba(234,179,8,.15);color:var(--todo)}
84
+
85
+ /* Modal */
86
+ .modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,.6);z-index:200;display:none;align-items:center;justify-content:center;padding:24px}
87
+ .modal-overlay.open{display:flex}
88
+ .modal{background:var(--bg2);border:1px solid var(--border);border-radius:12px;max-width:700px;width:100%;max-height:80vh;overflow-y:auto;padding:24px}
89
+ .modal-close{float:right;background:none;border:none;color:var(--text2);font-size:1.2rem;cursor:pointer;padding:4px 8px}
90
+ .modal-close:hover{color:var(--text)}
91
+ .modal h2{font-size:1.1rem;margin-bottom:4px}
92
+ .modal .meta-row{display:flex;flex-wrap:wrap;gap:8px;margin:8px 0 16px;font-size:.8rem;color:var(--text2)}
93
+ .modal .section{margin-top:16px}
94
+ .modal .section h3{font-size:.85rem;font-weight:700;color:var(--text2);text-transform:uppercase;margin-bottom:8px;letter-spacing:.03em}
95
+ .modal .section .content{font-size:.85rem;line-height:1.6;color:var(--text)}
96
+ .modal .section .content p{margin-bottom:8px}
97
+ .modal .section .content ul,.modal .section .content ol{margin-left:20px;margin-bottom:8px}
98
+ .modal .section .content code{background:var(--bg4);padding:1px 4px;border-radius:3px;font-size:.8rem}
99
+ .modal .section .content pre{background:var(--bg);padding:12px;border-radius:var(--radius);overflow-x:auto;margin:8px 0}
100
+ .modal .deps{display:flex;gap:6px;flex-wrap:wrap;margin-top:4px}
101
+ .modal .dep-chip{background:var(--bg3);padding:2px 8px;border-radius:4px;font-size:.75rem;color:var(--text2)}
102
+
103
+ /* Empty state */
104
+ .empty{display:flex;flex-direction:column;align-items:center;justify-content:center;padding:80px 24px;color:var(--text3);text-align:center;gap:12px}
105
+ .empty h2{font-size:1.2rem;color:var(--text2)}
106
+ .empty p{max-width:400px;line-height:1.5}
107
+ .empty code{background:var(--bg3);padding:4px 10px;border-radius:var(--radius);font-size:.9rem;color:var(--accent)}
108
+
109
+ /* Connection indicator */
110
+ .conn{width:8px;height:8px;border-radius:50%;display:inline-block}
111
+ .conn.ok{background:var(--done)}
112
+ .conn.err{background:#EF4444}
113
+
114
+ /* Activity View */
115
+ .activity-view{padding:16px 24px;max-width:900px}
116
+ .activity-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:16px}
117
+ .activity-header h2{font-size:1rem;font-weight:600}
118
+ .activity-count{font-size:.8rem;color:var(--text3)}
119
+ .activity-empty{text-align:center;padding:60px 24px;color:var(--text3)}
120
+ .activity-empty p{margin-top:8px;font-size:.85rem}
121
+ .timeline{position:relative;padding-left:24px}
122
+ .timeline::before{content:'';position:absolute;left:7px;top:4px;bottom:4px;width:2px;background:var(--border)}
123
+ .tl-item{position:relative;padding:8px 0 16px;animation:fadeSlide .3s ease}
124
+ @keyframes fadeSlide{from{opacity:0;transform:translateY(-8px)}to{opacity:1;transform:none}}
125
+ .tl-dot{position:absolute;left:-21px;top:12px;width:12px;height:12px;border-radius:50%;border:2px solid var(--bg)}
126
+ .tl-dot.status_changed{background:var(--in_progress)}
127
+ .tl-dot.worklog_added{background:var(--accent)}
128
+ .tl-dot.task_added{background:var(--done)}
129
+ .tl-dot.task_removed{background:#EF4444}
130
+ .tl-dot.spec_updated{background:var(--defined)}
131
+ .tl-dot.deps_changed{background:var(--testing)}
132
+ .tl-dot.story_added{background:var(--done)}
133
+ .tl-dot.story_status_changed{background:var(--review)}
134
+ .tl-card{background:var(--bg2);border:1px solid var(--border);border-radius:var(--radius);padding:10px 14px}
135
+ .tl-card:hover{background:var(--bg3)}
136
+ .tl-time{font-size:.7rem;color:var(--text3);margin-bottom:4px;font-family:monospace}
137
+ .tl-title{font-size:.85rem;font-weight:600;margin-bottom:4px}
138
+ .tl-detail{font-size:.8rem;color:var(--text2);line-height:1.5}
139
+ .tl-detail .arrow{color:var(--text3);margin:0 4px}
140
+ .tl-detail .val-from{color:#EF4444;text-decoration:line-through}
141
+ .tl-detail .val-to{color:var(--done);font-weight:600}
142
+ .tl-detail .wl-line{display:block;padding:2px 0;border-left:2px solid var(--accent);padding-left:8px;margin:2px 0}
143
+
144
+ /* Responsive */
145
+ @media(max-width:768px){
146
+ .header{flex-wrap:wrap;gap:8px;padding:10px 16px}
147
+ .kanban{padding:12px 8px;gap:8px}
148
+ .kanban-col{min-width:200px}
149
+ .list-view{padding:12px 16px}
150
+ .activity-view{padding:12px 16px}
151
+ }
152
+ </style>
153
+ </head>
154
+ <body>
155
+
156
+ <div class="header">
157
+ <h1><span>CCTD</span> Web <span class="conn ok" id="conn" title="Connected"></span></h1>
158
+ <div class="stats" id="stats"></div>
159
+ <div class="view-toggle">
160
+ <button class="active" data-view="kanban">Kanban</button>
161
+ <button data-view="list">List</button>
162
+ <button data-view="activity">Activity</button>
163
+ </div>
164
+ </div>
165
+
166
+ <div class="filter-bar" id="filterBar">
167
+ <label>Story:</label>
168
+ <select id="filterStory"><option value="">All</option></select>
169
+ <label>Priority:</label>
170
+ <select id="filterPriority">
171
+ <option value="">All</option>
172
+ <option value="high">High</option>
173
+ <option value="medium">Medium</option>
174
+ <option value="low">Low</option>
175
+ </select>
176
+ </div>
177
+
178
+ <main id="main"></main>
179
+
180
+ <div class="modal-overlay" id="modalOverlay">
181
+ <div class="modal" id="modal"></div>
182
+ </div>
183
+
184
+ <script>
185
+ const STATUSES = ['BACKLOG','DEFINED','AI_READY','IN_PROGRESS','TESTING','REVIEW','DONE'];
186
+ const STATUS_ALIASES = {TODO:'BACKLOG', WIP:'IN_PROGRESS'};
187
+ const STATUS_COLORS = {BACKLOG:'var(--backlog)',DEFINED:'var(--defined)',AI_READY:'var(--ai_ready)',IN_PROGRESS:'var(--in_progress)',TESTING:'var(--testing)',REVIEW:'var(--review)',DONE:'var(--done)'};
188
+
189
+ let data = {stories:[], tasks:[]};
190
+ let activityLog = [];
191
+ let view = 'kanban';
192
+ let filters = {story:'', priority:''};
193
+
194
+ // Normalize status
195
+ function norm(s){return STATUS_ALIASES[s?.toUpperCase()] || s?.toUpperCase() || 'BACKLOG';}
196
+
197
+ // SSE connection
198
+ let es;
199
+ function connect(){
200
+ es = new EventSource('/events');
201
+ es.onmessage = (e) => {
202
+ data = JSON.parse(e.data);
203
+ render();
204
+ setConn(true);
205
+ };
206
+ es.addEventListener('activity', (e) => {
207
+ const changes = JSON.parse(e.data);
208
+ activityLog.push(...changes);
209
+ if(activityLog.length > 500) activityLog = activityLog.slice(-500);
210
+ if(view === 'activity') renderActivity();
211
+ });
212
+ es.onerror = () => setConn(false);
213
+ }
214
+ function setConn(ok){
215
+ const el = document.getElementById('conn');
216
+ el.className = ok ? 'conn ok' : 'conn err';
217
+ el.title = ok ? 'Connected' : 'Disconnected';
218
+ }
219
+ connect();
220
+
221
+ // View toggle
222
+ document.querySelectorAll('.view-toggle button').forEach(btn => {
223
+ btn.addEventListener('click', () => {
224
+ document.querySelectorAll('.view-toggle button').forEach(b => b.classList.remove('active'));
225
+ btn.classList.add('active');
226
+ view = btn.dataset.view;
227
+ render();
228
+ });
229
+ });
230
+
231
+ // Filters
232
+ document.getElementById('filterStory').addEventListener('change', e => {filters.story = e.target.value; render();});
233
+ document.getElementById('filterPriority').addEventListener('change', e => {filters.priority = e.target.value; render();});
234
+
235
+ // Modal
236
+ document.getElementById('modalOverlay').addEventListener('click', e => {
237
+ if(e.target === e.currentTarget) closeModal();
238
+ });
239
+ document.addEventListener('keydown', e => {if(e.key === 'Escape') closeModal();});
240
+
241
+ function closeModal(){document.getElementById('modalOverlay').classList.remove('open');}
242
+
243
+ function openTask(taskId){
244
+ const task = data.tasks.find(t => t.id === taskId);
245
+ if(!task) return;
246
+ const story = data.stories.find(s => s.id === task.story);
247
+ const status = norm(task.status);
248
+
249
+ let html = `<button class="modal-close" onclick="closeModal()">&times;</button>`;
250
+ html += `<h2>${esc(task.title)}</h2>`;
251
+ html += `<div class="meta-row">`;
252
+ html += `<span class="status status-${status.toLowerCase()}">${status}</span>`;
253
+ if(task.id) html += `<span>ID: ${esc(task.id)}</span>`;
254
+ if(task.agent) html += `<span>Agent: ${esc(task.agent)}</span>`;
255
+ if(task.priority) html += `<span>Priority: ${esc(task.priority)}</span>`;
256
+ if(story) html += `<span>Story: ${esc(story.title || story.id)}</span>`;
257
+ if(task.created) html += `<span>${esc(task.created)}</span>`;
258
+ html += `</div>`;
259
+
260
+ if(task.labels?.length) {
261
+ html += `<div class="meta-row">${task.labels.map(l=>`<span class="tag">${esc(l)}</span>`).join('')}</div>`;
262
+ }
263
+
264
+ if(task.deps?.length){
265
+ html += `<div class="section"><h3>Dependencies</h3><div class="deps">${task.deps.map(d=>`<span class="dep-chip">${esc(d)}</span>`).join('')}</div></div>`;
266
+ }
267
+ if(task.blocks?.length){
268
+ html += `<div class="section"><h3>Blocks</h3><div class="deps">${task.blocks.map(d=>`<span class="dep-chip">${esc(d)}</span>`).join('')}</div></div>`;
269
+ }
270
+
271
+ if(task.spec){
272
+ html += `<div class="section"><h3>Spec</h3><div class="content">${renderMd(task.spec)}</div></div>`;
273
+ }
274
+ if(task.workLog){
275
+ html += `<div class="section"><h3>Work Log</h3><div class="content">${renderMd(task.workLog)}</div></div>`;
276
+ }
277
+
278
+ document.getElementById('modal').innerHTML = html;
279
+ document.getElementById('modalOverlay').classList.add('open');
280
+ }
281
+
282
+ function openStory(storyId){
283
+ const story = data.stories.find(s => s.id === storyId);
284
+ if(!story) return;
285
+
286
+ let html = `<button class="modal-close" onclick="closeModal()">&times;</button>`;
287
+ html += `<h2>${esc(story.title)}</h2>`;
288
+ html += `<div class="meta-row">`;
289
+ html += `<span class="status status-${norm(story.status).toLowerCase()}">${norm(story.status)}</span>`;
290
+ if(story.id) html += `<span>ID: ${esc(story.id)}</span>`;
291
+ if(story.priority) html += `<span>Priority: ${esc(story.priority)}</span>`;
292
+ if(story.created) html += `<span>${esc(story.created)}</span>`;
293
+ html += `</div>`;
294
+
295
+ if(story.labels?.length) {
296
+ html += `<div class="meta-row">${story.labels.map(l=>`<span class="tag">${esc(l)}</span>`).join('')}</div>`;
297
+ }
298
+ if(story.userStory){
299
+ html += `<div class="section"><h3>User Story</h3><div class="content">${renderMd(story.userStory)}</div></div>`;
300
+ }
301
+ if(story.acceptanceCriteria){
302
+ html += `<div class="section"><h3>Acceptance Criteria</h3><div class="content">${renderMd(story.acceptanceCriteria)}</div></div>`;
303
+ }
304
+ if(story.workLog){
305
+ html += `<div class="section"><h3>Work Log</h3><div class="content">${renderMd(story.workLog)}</div></div>`;
306
+ }
307
+
308
+ document.getElementById('modal').innerHTML = html;
309
+ document.getElementById('modalOverlay').classList.add('open');
310
+ }
311
+
312
+ function renderMd(text){
313
+ if(typeof marked !== 'undefined') return marked.parse(text);
314
+ return `<pre>${esc(text)}</pre>`;
315
+ }
316
+ function esc(s){
317
+ if(!s) return '';
318
+ const d = document.createElement('div');
319
+ d.textContent = String(s);
320
+ return d.innerHTML;
321
+ }
322
+
323
+ function getFiltered(){
324
+ let tasks = data.tasks;
325
+ if(filters.story) tasks = tasks.filter(t => t.story === filters.story || t.id?.startsWith(filters.story));
326
+ if(filters.priority) tasks = tasks.filter(t => (t.priority||'medium').toLowerCase() === filters.priority);
327
+ return tasks;
328
+ }
329
+
330
+ function render(){
331
+ updateStats();
332
+ updateStoryFilter();
333
+ const fb = document.getElementById('filterBar');
334
+ if(view === 'activity'){ fb.style.display='none'; renderActivity(); }
335
+ else { fb.style.display=''; if(view === 'kanban') renderKanban(); else renderList(); }
336
+ }
337
+
338
+ function updateStats(){
339
+ const tasks = data.tasks;
340
+ const done = tasks.filter(t => norm(t.status) === 'DONE').length;
341
+ const total = tasks.length;
342
+ const pct = total ? Math.round(done/total*100) : 0;
343
+ document.getElementById('stats').innerHTML = `
344
+ <div class="stat">Stories: <span class="num">${data.stories.length}</span></div>
345
+ <div class="stat">Tasks: <span class="num">${total}</span></div>
346
+ <div class="stat">Done: <span class="num">${pct}%</span></div>
347
+ `;
348
+ }
349
+
350
+ function updateStoryFilter(){
351
+ const sel = document.getElementById('filterStory');
352
+ const cur = sel.value;
353
+ const opts = data.stories.map(s => `<option value="${esc(s.id)}">${esc(s.id)} - ${esc(s.title)}</option>`);
354
+ sel.innerHTML = `<option value="">All</option>` + opts.join('');
355
+ sel.value = cur;
356
+ }
357
+
358
+ function renderKanban(){
359
+ const tasks = getFiltered();
360
+ const main = document.getElementById('main');
361
+
362
+ if(!tasks.length && !data.stories.length){
363
+ main.innerHTML = `<div class="empty"><h2>No tasks found</h2><p>Create tasks with CCTD to see them here.</p><code>/cctd init</code></div>`;
364
+ return;
365
+ }
366
+
367
+ let html = '<div class="kanban">';
368
+ for(const status of STATUSES){
369
+ const col = tasks.filter(t => norm(t.status) === status);
370
+ const color = STATUS_COLORS[status] || 'var(--text3)';
371
+ html += `<div class="kanban-col">`;
372
+ html += `<div class="kanban-col-header" style="background:${color}20;color:${color}"><span>${status.replace('_',' ')}</span><span class="count">${col.length}</span></div>`;
373
+ html += `<div class="kanban-cards">`;
374
+ for(const t of col){
375
+ const story = data.stories.find(s => s.id === t.story);
376
+ html += `<div class="card" style="border-left-color:${color}" onclick="openTask('${esc(t.id)}')">`;
377
+ html += `<div class="card-title">${esc(t.title)}</div>`;
378
+ html += `<div class="card-meta">`;
379
+ if(t.id) html += `<span class="tag">${esc(t.id)}</span>`;
380
+ if(t.agent) html += `<span class="agent">${esc(t.agent)}</span>`;
381
+ if(story) html += `<span class="story-ref">${esc(story.id)}</span>`;
382
+ if(t.priority) html += `<span class="priority-${t.priority}">${esc(t.priority)}</span>`;
383
+ if(t.labels?.length) t.labels.forEach(l => {html += `<span class="tag">${esc(l)}</span>`;});
384
+ html += `</div></div>`;
385
+ }
386
+ html += `</div></div>`;
387
+ }
388
+ html += '</div>';
389
+ main.innerHTML = html;
390
+ }
391
+
392
+ function renderList(){
393
+ const tasks = getFiltered();
394
+ const main = document.getElementById('main');
395
+
396
+ if(!tasks.length && !data.stories.length){
397
+ main.innerHTML = `<div class="empty"><h2>No tasks found</h2><p>Create tasks with CCTD to see them here.</p><code>/cctd init</code></div>`;
398
+ return;
399
+ }
400
+
401
+ let html = '<div class="list-view">';
402
+
403
+ // Group tasks by story
404
+ const groups = new Map();
405
+ const noStory = [];
406
+ for(const t of tasks){
407
+ const sid = t.story || (t.id?.includes('-') ? t.id.split('-')[0] : '');
408
+ if(sid){
409
+ if(!groups.has(sid)) groups.set(sid, []);
410
+ groups.get(sid).push(t);
411
+ } else {
412
+ noStory.push(t);
413
+ }
414
+ }
415
+
416
+ // Render story groups
417
+ for(const [sid, stasks] of groups){
418
+ const story = data.stories.find(s => s.id === sid);
419
+ const done = stasks.filter(t => norm(t.status) === 'DONE').length;
420
+ const pct = stasks.length ? Math.round(done/stasks.length*100) : 0;
421
+
422
+ html += `<div class="story-group">`;
423
+ html += `<div class="story-header" style="cursor:pointer" onclick="openStory('${esc(sid)}')">`;
424
+ html += `<span class="status status-${norm(story?.status||'BACKLOG').toLowerCase()}">${norm(story?.status||'BACKLOG')}</span>`;
425
+ html += `<h2>${esc(story?.title || sid)}</h2>`;
426
+ html += `<span style="color:var(--text3);font-size:.8rem">${done}/${stasks.length} done</span>`;
427
+ html += `</div>`;
428
+ html += `<div class="progress-bar"><div class="progress-bar-fill" style="width:${pct}%"></div></div>`;
429
+ html += renderTaskTable(stasks);
430
+ html += `</div>`;
431
+ }
432
+
433
+ // Ungrouped tasks
434
+ if(noStory.length){
435
+ html += `<div class="story-group">`;
436
+ html += `<div class="story-header"><h2>Ungrouped Tasks</h2></div>`;
437
+ html += renderTaskTable(noStory);
438
+ html += `</div>`;
439
+ }
440
+
441
+ html += '</div>';
442
+ main.innerHTML = html;
443
+ }
444
+
445
+ function renderActivity(){
446
+ const main = document.getElementById('main');
447
+ if(!activityLog.length){
448
+ main.innerHTML = `<div class="activity-view"><div class="activity-empty"><h2>No activity yet</h2><p>Changes to .tasks/ will appear here in real-time.<br>Waiting for Claude Code to update tasks...</p></div></div>`;
449
+ return;
450
+ }
451
+ const sorted = [...activityLog].reverse();
452
+ let html = `<div class="activity-view">`;
453
+ html += `<div class="activity-header"><h2>Activity Log</h2><span class="activity-count">${sorted.length} events (in-memory)</span></div>`;
454
+ html += `<div class="timeline">`;
455
+ for(const ev of sorted){
456
+ const time = fmtTime(ev.ts);
457
+ const id = ev.taskId || ev.storyId || '';
458
+ html += `<div class="tl-item">`;
459
+ html += `<div class="tl-dot ${ev.type}"></div>`;
460
+ html += `<div class="tl-card">`;
461
+ html += `<div class="tl-time">${esc(time)}</div>`;
462
+ html += `<div class="tl-title">${esc(id)} ${esc(ev.title||'')}</div>`;
463
+ html += `<div class="tl-detail">${fmtEvent(ev)}</div>`;
464
+ html += `</div></div>`;
465
+ }
466
+ html += `</div></div>`;
467
+ main.innerHTML = html;
468
+ }
469
+
470
+ function fmtTime(iso){
471
+ if(!iso) return '';
472
+ const d = new Date(iso);
473
+ const hh = String(d.getHours()).padStart(2,'0');
474
+ const mm = String(d.getMinutes()).padStart(2,'0');
475
+ const ss = String(d.getSeconds()).padStart(2,'0');
476
+ return `${hh}:${mm}:${ss}`;
477
+ }
478
+
479
+ function fmtEvent(ev){
480
+ switch(ev.type){
481
+ case 'status_changed':
482
+ return `Status: <span class="val-from">${esc(ev.from)}</span><span class="arrow">&rarr;</span><span class="val-to">${esc(ev.to)}</span>`;
483
+ case 'worklog_added':
484
+ return (ev.lines||[]).map(l=>`<span class="wl-line">${esc(l)}</span>`).join('');
485
+ case 'task_added':
486
+ return `New task created <span class="status status-${norm(ev.status||'BACKLOG').toLowerCase()}">${norm(ev.status||'BACKLOG')}</span>`;
487
+ case 'task_removed':
488
+ return `Task removed`;
489
+ case 'spec_updated':
490
+ return `Spec was updated`;
491
+ case 'deps_changed':
492
+ return `Dependencies: <span class="val-from">${esc((ev.from||[]).join(', ')||'none')}</span><span class="arrow">&rarr;</span><span class="val-to">${esc((ev.to||[]).join(', ')||'none')}</span>`;
493
+ case 'story_added':
494
+ return `New story created <span class="status status-${norm(ev.status||'BACKLOG').toLowerCase()}">${norm(ev.status||'BACKLOG')}</span>`;
495
+ case 'story_status_changed':
496
+ return `Story status: <span class="val-from">${esc(ev.from)}</span><span class="arrow">&rarr;</span><span class="val-to">${esc(ev.to)}</span>`;
497
+ default:
498
+ return esc(ev.type);
499
+ }
500
+ }
501
+
502
+ function renderTaskTable(tasks){
503
+ let html = `<table class="task-table"><thead><tr><th>ID</th><th>Status</th><th>Title</th><th>Agent</th><th>Priority</th><th>Deps</th></tr></thead><tbody>`;
504
+ for(const t of tasks){
505
+ const status = norm(t.status);
506
+ html += `<tr onclick="openTask('${esc(t.id)}')">`;
507
+ html += `<td>${esc(t.id)}</td>`;
508
+ html += `<td><span class="status status-${status.toLowerCase()}">${status}</span></td>`;
509
+ html += `<td>${esc(t.title)}</td>`;
510
+ html += `<td>${esc(t.agent)}</td>`;
511
+ html += `<td><span class="priority-${t.priority||'medium'}">${esc(t.priority||'')}</span></td>`;
512
+ html += `<td>${(t.deps||[]).map(d=>esc(d)).join(', ')}</td>`;
513
+ html += `</tr>`;
514
+ }
515
+ html += `</tbody></table>`;
516
+ return html;
517
+ }
518
+ </script>
519
+ </body>
520
+ </html>