bmad-viewer 0.3.4 → 0.3.6

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,11 @@
1
+ {
2
+ "version": "0.0.1",
3
+ "configurations": [
4
+ {
5
+ "name": "bmad-viewer",
6
+ "runtimeExecutable": "node",
7
+ "runtimeArgs": ["bin/cli.js", "--path", "C:/code/claude-ideas/notify", "--no-open"],
8
+ "port": 4000
9
+ }
10
+ ]
11
+ }
@@ -0,0 +1,12 @@
1
+ {
2
+ "permissions": {
3
+ "allow": [
4
+ "mcp__Claude_Preview__preview_start",
5
+ "Read(//c/code/claude-ideas/notify/**)",
6
+ "Bash(npm install:*)",
7
+ "Bash(grep -cP '^#{3,4} Story \\\\d+\\\\.\\\\d+' C:/code/claude-ideas/notify/_bmad-output/planning-artifacts/epics-and-stories.md)",
8
+ "Bash(node -e \":*)",
9
+ "Bash(timeout 5 node src/server/http-server.js --path C:/code/claude-ideas/notify)"
10
+ ]
11
+ }
12
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bmad-viewer",
3
- "version": "0.3.4",
3
+ "version": "0.3.6",
4
4
  "description": "Visual dashboard for BMAD (Boring Maintainable Agile Development) projects. Wiki browser + sprint status viewer with live reload.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -27,7 +27,7 @@
27
27
  "url": "https://github.com/CamiloValderramaGonzalez/bmad-viewer/issues"
28
28
  },
29
29
  "dependencies": {
30
- "bmad-viewer": "^0.3.2",
30
+ "bmad-viewer": "^0.3.4",
31
31
  "chokidar": "^4.0.1",
32
32
  "fuse.js": "^7.0.0",
33
33
  "js-yaml": "^4.1.0",
package/public/client.js CHANGED
@@ -500,19 +500,39 @@
500
500
 
501
501
  /* ── Path Config Panel ── */
502
502
  function initPathConfig() {
503
+ var panel = document.getElementById('path-config-panel');
504
+ if (!panel) return;
505
+
506
+ // Toggle collapsed/expanded
507
+ var toggle = document.getElementById('path-config-toggle');
508
+ if (toggle) {
509
+ toggle.addEventListener('click', function () {
510
+ panel.classList.toggle('path-config-panel--collapsed');
511
+ });
512
+ }
513
+
514
+ // Load current overrides into inputs
515
+ fetch('/api/get-paths').then(function (r) { return r.json(); }).then(function (data) {
516
+ if (data.customOutputPath) { var el = document.getElementById('custom-output-path'); if (el) el.value = data.customOutputPath; }
517
+ if (data.customEpicsPath) { var el = document.getElementById('custom-epics-path'); if (el) el.value = data.customEpicsPath; }
518
+ if (data.customSprintStatusPath) { var el = document.getElementById('custom-sprint-status-path'); if (el) el.value = data.customSprintStatusPath; }
519
+ }).catch(function () {});
520
+
503
521
  var btn = document.getElementById('apply-paths-btn');
504
522
  if (!btn) return;
505
523
 
506
524
  btn.addEventListener('click', function () {
507
525
  var epicsInput = document.getElementById('custom-epics-path');
508
526
  var outputInput = document.getElementById('custom-output-path');
527
+ var sprintInput = document.getElementById('custom-sprint-status-path');
509
528
  var status = document.getElementById('path-config-status');
510
529
  var payload = {};
511
530
 
512
531
  if (outputInput && outputInput.value.trim()) payload.outputPath = outputInput.value.trim();
513
532
  if (epicsInput && epicsInput.value.trim()) payload.epicsPath = epicsInput.value.trim();
533
+ if (sprintInput && sprintInput.value.trim()) payload.sprintStatusPath = sprintInput.value.trim();
514
534
 
515
- if (!payload.outputPath && !payload.epicsPath) {
535
+ if (!payload.outputPath && !payload.epicsPath && !payload.sprintStatusPath) {
516
536
  if (status) { status.textContent = 'Enter at least one path'; status.className = 'path-config-panel__status path-config-panel__status--err'; }
517
537
  return;
518
538
  }
package/public/styles.css CHANGED
@@ -150,13 +150,17 @@ mark.search-highlight{background:rgba(245,158,11,.4);color:var(--text);border-ra
150
150
  .warning-banner{background:rgba(245,158,11,.1);border-bottom:1px solid var(--warning);padding:8px var(--gap)}
151
151
  .warning-banner__item{font-size:.85rem;color:var(--text)}
152
152
  /* Path config panel */
153
- .path-config-panel{background:var(--accent-soft);border:1px solid var(--border);border-radius:var(--radius);padding:20px 24px;margin-bottom:20px}
154
- .path-config-panel__header h3{font-size:1rem;margin-bottom:4px}
155
- .path-config-panel__header p{font-size:.85rem;color:var(--text-muted);margin-bottom:16px}
156
- .path-config-panel__fields{display:flex;flex-direction:column;gap:12px}
153
+ .path-config-panel{background:var(--accent-soft);border:1px solid var(--border);border-radius:var(--radius);padding:16px 24px;margin-bottom:20px}
154
+ .path-config-panel__toggle{display:flex;align-items:center;justify-content:space-between;cursor:pointer;user-select:none}
155
+ .path-config-panel__toggle h3{font-size:.95rem;margin:0}
156
+ .path-config-panel__arrow{font-size:.75rem;color:var(--text-muted);transition:transform .2s}
157
+ .path-config-panel--collapsed .path-config-panel__fields{display:none}
158
+ .path-config-panel--collapsed .path-config-panel__arrow{transform:rotate(0deg)}
159
+ .path-config-panel:not(.path-config-panel--collapsed) .path-config-panel__arrow{transform:rotate(90deg)}
160
+ .path-config-panel__fields{display:flex;flex-direction:column;gap:12px;margin-top:12px}
157
161
  .path-config-panel__label{display:flex;flex-direction:column;gap:4px}
158
162
  .path-config-panel__label>span:first-child{font-size:.85rem;font-weight:600}
159
- .path-config-panel__input{font-family:var(--font-mono);font-size:.85rem;padding:8px 12px;border:1px solid var(--border);border-radius:var(--radius);background:var(--bg);color:var(--text);width:100%}
163
+ .path-config-panel__input{font-family:var(--font-mono);font-size:.85rem;padding:8px 12px;border:1px solid var(--border);border-radius:var(--radius);background:var(--bg);color:var(--text);width:100%;box-sizing:border-box}
160
164
  .path-config-panel__input:focus{outline:none;border-color:var(--primary)}
161
165
  .path-config-panel__hint{font-size:.75rem;color:var(--text-muted)}
162
166
  .path-config-panel__btn{align-self:flex-start;padding:8px 20px;background:var(--primary);color:#fff;border:none;border-radius:var(--radius);cursor:pointer;font-size:.85rem;font-weight:600;transition:background .15s}
@@ -165,6 +169,10 @@ mark.search-highlight{background:rgba(245,158,11,.4);color:var(--text);border-ra
165
169
  .path-config-panel__status{font-size:.85rem;color:var(--text-muted);align-self:center}
166
170
  .path-config-panel__status--ok{color:var(--success)}
167
171
  .path-config-panel__status--err{color:var(--warning)}
172
+ /* Bug & pending cards in kanban */
173
+ .kanban-card--bug{border-left:3px solid #e74c3c}
174
+ .kanban-card--global{border-left:3px solid var(--warning)}
175
+ .kanban-card__detail{color:var(--text-muted);font-size:.8rem;margin-bottom:4px}
168
176
  /* Responsive */
169
177
  @media(max-width:768px){.app-layout{grid-template-columns:1fr}.app-layout__sidebar{max-height:none;position:static;border-right:none;border-bottom:1px solid var(--border)}.kanban{grid-template-columns:1fr}.stats-box{grid-template-columns:repeat(2,1fr)}}
170
178
  @media(max-width:440px){.header-bar__shortcut{display:none}.stats-box{grid-template-columns:1fr}.lens-tabs__tab{padding:8px 12px;font-size:.85rem}}
@@ -6,12 +6,17 @@ import { Badge } from './badge.js';
6
6
  * @param {{id: string, title: string, status: string, epic: string}} props
7
7
  * @returns {string} HTML string
8
8
  */
9
- export function KanbanCard({ id, title, status, epic }) {
10
- return `<a href="#project/story/${escapeHtml(id)}" class="kanban-card kanban-card--${escapeHtml(status)}" data-id="${escapeHtml(id)}">
9
+ export function KanbanCard({ id, title, status, epic, cardType, detail }) {
10
+ const typeClass = cardType === 'bug' ? ' kanban-card--bug' : cardType === 'global' ? ' kanban-card--global' : '';
11
+ const tag = cardType ? 'div' : 'a';
12
+ const href = cardType ? '' : ` href="#project/story/${escapeHtml(id)}"`;
13
+ const label = cardType === 'bug' ? escapeHtml(epic) : `Epic ${escapeHtml(epic)}`;
14
+ return `<${tag}${href} class="kanban-card kanban-card--${escapeHtml(status)}${typeClass}" data-id="${escapeHtml(id)}">
11
15
  <h4 class="kanban-card__title">${escapeHtml(title)}</h4>
16
+ ${detail ? `<div class="kanban-card__detail">${escapeHtml(detail)}</div>` : ''}
12
17
  <div class="kanban-card__meta">
13
- ${Badge({ status: epic, text: `Epic ${escapeHtml(epic)}` })}
18
+ ${Badge({ status: epic, text: label })}
14
19
  ${Badge({ status })}
15
20
  </div>
16
- </a>`;
21
+ </${tag}>`;
17
22
  }
@@ -8,7 +8,7 @@ import { ErrorAggregator } from '../utils/error-aggregator.js';
8
8
  * Build the complete in-memory data model from a BMAD project.
9
9
  *
10
10
  * @param {string} bmadDir - Project root containing _bmad/
11
- * @param {{customEpicsPath?: string, customOutputPath?: string}} [options] - Optional overrides
11
+ * @param {{customEpicsPath?: string, customOutputPath?: string, customSprintStatusPath?: string}} [options] - Optional overrides
12
12
  * @returns {{wiki: object, project: object, config: object, aggregator: ErrorAggregator}}
13
13
  */
14
14
  export function buildDataModel(bmadDir, options) {
@@ -17,7 +17,7 @@ export function buildDataModel(bmadDir, options) {
17
17
  const outputPath = options?.customOutputPath || join(bmadDir, '_bmad-output');
18
18
 
19
19
  const wiki = buildWikiData(bmadPath, aggregator);
20
- const project = buildProjectData(outputPath, aggregator, options?.customEpicsPath);
20
+ const project = buildProjectData(outputPath, aggregator, options);
21
21
  const config = loadConfig(bmadPath, aggregator);
22
22
 
23
23
  return { wiki, project, config, aggregator };
@@ -281,15 +281,18 @@ function readMarkdownSafe(filePath, aggregator) {
281
281
  * Build project data from _bmad-output directory.
282
282
  * @param {string} outputPath
283
283
  * @param {ErrorAggregator} aggregator
284
- * @param {string} [customEpicsPath] - Optional override path to epics file
284
+ * @param {{customEpicsPath?: string, customSprintStatusPath?: string}} [options]
285
285
  */
286
- function buildProjectData(outputPath, aggregator, customEpicsPath) {
286
+ function buildProjectData(outputPath, aggregator, options) {
287
+ const customEpicsPath = options?.customEpicsPath;
287
288
  const project = {
288
289
  sprintStatus: null,
289
290
  stories: { total: 0, pending: 0, inProgress: 0, done: 0 },
290
291
  storyList: [],
291
292
  epics: [],
292
293
  artifacts: [],
294
+ bugs: [],
295
+ pendingItems: [],
293
296
  };
294
297
 
295
298
  if (!existsSync(outputPath)) {
@@ -297,23 +300,33 @@ function buildProjectData(outputPath, aggregator, customEpicsPath) {
297
300
  return project;
298
301
  }
299
302
 
300
- // Parse sprint-status.yaml
303
+ // Parse sprint-status (yaml or md)
301
304
  const sprintStatusPaths = [
302
305
  join(outputPath, 'implementation-artifacts', 'sprint-status.yaml'),
306
+ join(outputPath, 'implementation-artifacts', 'sprint-status.md'),
303
307
  join(outputPath, 'sprint-status.yaml'),
308
+ join(outputPath, 'sprint-status.md'),
309
+ join(outputPath, 'planning-artifacts', 'sprint-status.yaml'),
310
+ join(outputPath, 'planning-artifacts', 'sprint-status.md'),
304
311
  ];
312
+ if (options?.customSprintStatusPath) {
313
+ sprintStatusPaths.unshift(options.customSprintStatusPath);
314
+ }
305
315
 
306
316
  for (const statusPath of sprintStatusPaths) {
307
317
  if (existsSync(statusPath)) {
308
- const result = parseYaml(statusPath);
309
- aggregator.addResult(statusPath, result);
318
+ let raw = '';
319
+ try { raw = readFileSync(statusPath, 'utf8'); } catch { /* ignore */ }
310
320
 
311
- // Read raw YAML to extract epic names from comments
312
- let rawYaml = '';
313
- try { rawYaml = readFileSync(statusPath, 'utf8'); } catch { /* ignore */ }
314
- const epicNames = parseEpicNamesFromComments(rawYaml);
321
+ // Try YAML format first (only report errors for .yaml files)
322
+ const result = parseYaml(statusPath);
323
+ const ext = extname(statusPath).toLowerCase();
324
+ if (ext === '.yaml' || ext === '.yml') {
325
+ aggregator.addResult(statusPath, result);
326
+ }
315
327
 
316
328
  if (result.data?.development_status) {
329
+ const epicNames = parseEpicNamesFromComments(raw);
317
330
  project.sprintStatus = result.data;
318
331
  const status = result.data.development_status;
319
332
  const epicMap = {};
@@ -352,8 +365,13 @@ function buildProjectData(outputPath, aggregator, customEpicsPath) {
352
365
  }
353
366
 
354
367
  project.epics = Object.values(epicMap).sort((a, b) => Number(a.num) - Number(b.num));
368
+ break;
369
+ }
370
+
371
+ // Fallback: try markdown table format (### Epic N: Name + | N.M | desc | status |)
372
+ if (raw && parseSprintStatusMarkdown(raw, project)) {
373
+ break;
355
374
  }
356
- break;
357
375
  }
358
376
  }
359
377
 
@@ -364,6 +382,10 @@ function buildProjectData(outputPath, aggregator, customEpicsPath) {
364
382
  for (const file of files) {
365
383
  const ext = extname(file).toLowerCase();
366
384
  const name = basename(file, ext);
385
+
386
+ // Skip sprint-status files (already parsed above)
387
+ if (name === 'sprint-status') continue;
388
+
367
389
  const content = ext === '.html'
368
390
  ? readHtmlSafe(file)
369
391
  : readMarkdownSafe(file, aggregator);
@@ -381,15 +403,49 @@ function buildProjectData(outputPath, aggregator, customEpicsPath) {
381
403
  const storyContents = parseStoriesFromEpics(content.raw, aggregator);
382
404
  project.storyContents = storyContents;
383
405
 
384
- // Build epics from markdown when no sprint-status.yaml was found
385
406
  if (project.epics.length === 0) {
407
+ // No sprint-status found — build epics entirely from markdown
386
408
  project.epics = parseEpicsFromMarkdown(content.raw, storyContents);
387
- // Build story stats from epics.md stories
388
409
  for (const epic of project.epics) {
389
410
  project.stories.total += epic.stories.length;
390
- project.stories.pending += epic.stories.length; // all backlog by default
411
+ project.stories.pending += epic.stories.length;
391
412
  project.storyList.push(...epic.stories);
392
413
  }
414
+ } else {
415
+ // Merge: add stories from epics.md that are missing in sprint-status
416
+ const mdEpics = parseEpicsFromMarkdown(content.raw, storyContents);
417
+ const existingIds = new Set(project.storyList.map(s => `${s.epic}-${s.id.split('-')[1]}`));
418
+ for (const mdEpic of mdEpics) {
419
+ let sprintEpic = project.epics.find(e => e.num === mdEpic.num);
420
+ if (!sprintEpic) {
421
+ // Entire epic missing from sprint-status — add with all stories as backlog
422
+ sprintEpic = { ...mdEpic, stories: [], status: 'backlog' };
423
+ for (const story of mdEpic.stories) {
424
+ story.status = 'backlog';
425
+ sprintEpic.stories.push(story);
426
+ project.storyList.push(story);
427
+ project.stories.total++;
428
+ project.stories.pending++;
429
+ }
430
+ project.epics.push(sprintEpic);
431
+ project.epics.sort((a, b) => Number(a.num) - Number(b.num));
432
+ } else {
433
+ if (!sprintEpic.name || sprintEpic.name === `Epic ${sprintEpic.num}`) {
434
+ sprintEpic.name = mdEpic.name;
435
+ }
436
+ // Add only stories missing from sprint-status
437
+ for (const story of mdEpic.stories) {
438
+ const storyKey = `${mdEpic.num}-${story.id.split('-')[1]}`;
439
+ if (!existingIds.has(storyKey)) {
440
+ story.status = 'backlog';
441
+ sprintEpic.stories.push(story);
442
+ project.storyList.push(story);
443
+ project.stories.total++;
444
+ project.stories.pending++;
445
+ }
446
+ }
447
+ }
448
+ }
393
449
  }
394
450
  }
395
451
  }
@@ -618,6 +674,113 @@ function parseEpicsFromMarkdown(raw, storyContents) {
618
674
  return Object.values(epicMap).sort((a, b) => Number(a.num) - Number(b.num));
619
675
  }
620
676
 
677
+ /**
678
+ * Parse sprint-status from markdown table format.
679
+ * Expects ### Epic N: Name headers followed by tables with | N.M | description | status |
680
+ * @returns {boolean} true if data was found
681
+ */
682
+ function parseSprintStatusMarkdown(raw, project) {
683
+ const epicHeaderRegex = /^#{2,3}\s*Epic\s+(\d+):\s*(.+)$/gm;
684
+ const storyRowRegex = /^\|\s*(\d+)\.(\d+)\s*\|\s*(.+?)\s*\|\s*(.+?)\s*\|$/gm;
685
+
686
+ // First pass: find all epic headers
687
+ const epicMap = {};
688
+ let match;
689
+ while ((match = epicHeaderRegex.exec(raw)) !== null) {
690
+ const epicNum = match[1];
691
+ epicMap[epicNum] = {
692
+ id: `epic-${epicNum}`,
693
+ num: epicNum,
694
+ name: match[2].trim(),
695
+ status: 'in-progress',
696
+ stories: [],
697
+ };
698
+ }
699
+
700
+ if (Object.keys(epicMap).length === 0) return false;
701
+
702
+ // Second pass: find all story rows
703
+ while ((match = storyRowRegex.exec(raw)) !== null) {
704
+ const epicNum = match[1];
705
+ const storyNum = match[2];
706
+ const title = match[3].trim();
707
+ const rawStatus = match[4].trim();
708
+
709
+ // Normalize status from emoji/text to our standard values
710
+ const status = normalizeMarkdownStatus(rawStatus);
711
+
712
+ project.stories.total++;
713
+ if (status === 'backlog' || status === 'ready-for-dev') project.stories.pending++;
714
+ else if (status === 'in-progress') project.stories.inProgress++;
715
+ else if (status === 'done' || status === 'review') project.stories.done++;
716
+
717
+ const story = {
718
+ id: `${epicNum}-${storyNum}-${title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/-+$/, '')}`,
719
+ title,
720
+ status,
721
+ epic: epicNum,
722
+ };
723
+ project.storyList.push(story);
724
+
725
+ if (!epicMap[epicNum]) {
726
+ epicMap[epicNum] = { id: `epic-${epicNum}`, num: epicNum, name: `Epic ${epicNum}`, status: 'in-progress', stories: [] };
727
+ }
728
+ epicMap[epicNum].stories.push(story);
729
+ }
730
+
731
+ // Determine epic status from stories
732
+ for (const epic of Object.values(epicMap)) {
733
+ if (epic.stories.length === 0) continue;
734
+ const allDone = epic.stories.every(s => s.status === 'done' || s.status === 'review');
735
+ const anyInProgress = epic.stories.some(s => s.status === 'in-progress');
736
+ if (allDone) epic.status = 'done';
737
+ else if (anyInProgress) epic.status = 'in-progress';
738
+ }
739
+
740
+ project.epics = Object.values(epicMap).sort((a, b) => Number(a.num) - Number(b.num));
741
+
742
+ // Parse bugs table: | BUG-XXX | desc | epic | status |
743
+ const bugRowRegex = /^\|\s*(BUG-\d+)\s*\|\s*(.+?)\s*\|\s*(.+?)\s*\|\s*(.+?)\s*\|$/gm;
744
+ while ((match = bugRowRegex.exec(raw)) !== null) {
745
+ project.bugs.push({
746
+ id: match[1],
747
+ description: match[2].trim(),
748
+ epic: match[3].trim(),
749
+ status: normalizeMarkdownStatus(match[4].trim()),
750
+ });
751
+ }
752
+
753
+ // Parse pendientes globales: - [ ] text or - [✅] text or - [x] text
754
+ const pendingRegex = /^-\s*\[([^\]]*)\]\s*\*{0,2}(.+?)(?:\*{0,2}\s*[-–—]\s*(.+))?$/gm;
755
+ while ((match = pendingRegex.exec(raw)) !== null) {
756
+ const check = match[1].trim();
757
+ const done = check === '✅' || check.toLowerCase() === 'x';
758
+ project.pendingItems.push({
759
+ title: match[2].replace(/\*{1,2}/g, '').trim(),
760
+ detail: match[3]?.trim() || '',
761
+ done,
762
+ });
763
+ }
764
+
765
+ return project.stories.total > 0;
766
+ }
767
+
768
+ /**
769
+ * Normalize markdown status text/emojis to standard status values.
770
+ */
771
+ function normalizeMarkdownStatus(raw) {
772
+ const lower = raw.toLowerCase();
773
+ // Strip parenthetical notes for primary status detection
774
+ const primary = lower.replace(/\(.*?\)/g, '').trim();
775
+ if (primary.includes('done') || primary.includes('completado') || raw.includes('✅')) return 'done';
776
+ if (primary.includes('in-progress') || primary.includes('in progress') || primary.includes('en progreso') || raw.includes('🔄')) return 'in-progress';
777
+ if (primary.includes('review') || primary.includes('revisión') || primary.includes('revision')) return 'review';
778
+ if (primary.includes('parcial')) return 'in-progress';
779
+ if (primary.includes('pendiente') || primary.includes('pending') || primary.includes('backlog')) return 'backlog';
780
+ if (raw.includes('⏳')) return 'in-progress';
781
+ return 'backlog';
782
+ }
783
+
621
784
  /**
622
785
  * Parse epic names from YAML comments like "# Epic 1: Fundación del Proyecto"
623
786
  */
@@ -22,7 +22,7 @@ export async function startServer({ port, bmadDir, open }) {
22
22
  const actualPort = await findAvailablePort(port);
23
23
 
24
24
  // Custom path overrides (can be set via API)
25
- const overrides = { customEpicsPath: null, customOutputPath: null };
25
+ const overrides = { customEpicsPath: null, customOutputPath: null, customSprintStatusPath: null };
26
26
 
27
27
  // Build initial data model
28
28
  let dataModel = buildDataModel(bmadDir, overrides);
@@ -45,6 +45,7 @@ export async function startServer({ port, bmadDir, open }) {
45
45
  const data = JSON.parse(body);
46
46
  if (data.epicsPath !== undefined) overrides.customEpicsPath = data.epicsPath || null;
47
47
  if (data.outputPath !== undefined) overrides.customOutputPath = data.outputPath || null;
48
+ if (data.sprintStatusPath !== undefined) overrides.customSprintStatusPath = data.sprintStatusPath || null;
48
49
  dataModel = buildDataModel(bmadDir, overrides);
49
50
  res.writeHead(200, { 'Content-Type': 'application/json' });
50
51
  res.end(JSON.stringify({ ok: true, epics: dataModel.project.epics.length, stories: dataModel.project.stories.total }));
@@ -44,32 +44,67 @@ export function renderDashboard(dataModel) {
44
44
  const inProgress = storyList.filter((s) => s.status === 'in-progress');
45
45
  const done = storyList.filter((s) => s.status === 'done' || s.status === 'review');
46
46
 
47
- const noEpicsNotice = project.epics.length === 0
48
- ? `<div class="path-config-panel" id="path-config-panel">
49
- <div class="path-config-panel__header">
50
- <h3>No epics found</h3>
51
- <p>Could not auto-detect epics in your project. You can specify custom paths below.</p>
47
+ const noData = project.epics.length === 0 && project.stories.total === 0;
48
+ const configPanel = `<div class="path-config-panel${noData ? '' : ' path-config-panel--collapsed'}" id="path-config-panel">
49
+ <div class="path-config-panel__toggle" id="path-config-toggle">
50
+ <h3>${noData ? 'No project data found' : 'Custom paths'}</h3>
51
+ <span class="path-config-panel__arrow" id="path-config-arrow">${noData ? '' : '&#9654;'}</span>
52
52
  </div>
53
- <div class="path-config-panel__fields">
53
+ ${noData ? '<p class="path-config-panel__hint" style="margin-bottom:12px">Could not auto-detect epics or sprint status. Specify custom paths below.</p>' : ''}
54
+ <div class="path-config-panel__fields" id="path-config-fields">
54
55
  <label class="path-config-panel__label">
55
56
  <span>Output folder</span>
56
- <input type="text" id="custom-output-path" class="path-config-panel__input" placeholder="e.g. C:\\project\\_bmad-output" />
57
+ <input type="text" id="custom-output-path" class="path-config-panel__input" placeholder="e.g. /project/_bmad-output" />
57
58
  <span class="path-config-panel__hint">Folder containing planning-artifacts, implementation-artifacts, etc.</span>
58
59
  </label>
59
60
  <label class="path-config-panel__label">
60
61
  <span>Epics file</span>
61
- <input type="text" id="custom-epics-path" class="path-config-panel__input" placeholder="e.g. C:\\project\\docs\\epics.md" />
62
+ <input type="text" id="custom-epics-path" class="path-config-panel__input" placeholder="e.g. /project/docs/epics.md" />
62
63
  <span class="path-config-panel__hint">Markdown file with epic/story definitions (## Epic N: / ### Story N.M:)</span>
63
64
  </label>
65
+ <label class="path-config-panel__label">
66
+ <span>Sprint status file</span>
67
+ <input type="text" id="custom-sprint-status-path" class="path-config-panel__input" placeholder="e.g. /project/sprint-status.yaml" />
68
+ <span class="path-config-panel__hint">YAML file (.yaml or .md) with development_status section</span>
69
+ </label>
64
70
  <button class="path-config-panel__btn" id="apply-paths-btn">Apply</button>
65
71
  <span class="path-config-panel__status" id="path-config-status"></span>
66
72
  </div>
67
- </div>`
68
- : '';
73
+ </div>`;
74
+
75
+ // Mix bugs and pendientes into kanban columns as cards
76
+ const pendingGlobal = (project.pendingItems || []).filter(i => !i.done).map(i => ({
77
+ id: `global-${i.title.toLowerCase().replace(/[^a-z0-9]+/g, '-')}`,
78
+ title: i.title,
79
+ status: 'backlog',
80
+ epic: 'Global',
81
+ detail: i.detail,
82
+ cardType: 'global',
83
+ }));
84
+ const doneGlobal = (project.pendingItems || []).filter(i => i.done).map(i => ({
85
+ id: `global-${i.title.toLowerCase().replace(/[^a-z0-9]+/g, '-')}`,
86
+ title: i.title,
87
+ status: 'done',
88
+ epic: 'Global',
89
+ cardType: 'global',
90
+ }));
91
+ const bugCards = (project.bugs || []).map(b => ({
92
+ id: b.id.toLowerCase(),
93
+ title: b.description,
94
+ status: b.status,
95
+ epic: b.id,
96
+ cardType: 'bug',
97
+ }));
98
+ const doneBugs = bugCards.filter(b => b.status === 'done');
99
+ const activeBugs = bugCards.filter(b => b.status !== 'done');
100
+
101
+ const allPending = [...pending, ...pendingGlobal, ...activeBugs.filter(b => b.status === 'backlog')];
102
+ const allInProgress = [...inProgress, ...activeBugs.filter(b => b.status === 'in-progress')];
103
+ const allDone = [...done, ...doneBugs, ...doneGlobal];
69
104
 
70
105
  const projectContent = `<div id="project-view" hidden>
71
106
  <div id="project-dashboard">
72
- ${noEpicsNotice}
107
+ ${configPanel}
73
108
  ${StatsBox({
74
109
  total: project.stories.total,
75
110
  pending: project.stories.pending,
@@ -78,9 +113,9 @@ export function renderDashboard(dataModel) {
78
113
  })}
79
114
  ${ProgressBar({ completed: project.stories.done, total: project.stories.total })}
80
115
  <div class="kanban">
81
- ${KanbanColumn({ title: 'Pending', stories: pending })}
82
- ${KanbanColumn({ title: 'In Progress', stories: inProgress })}
83
- ${KanbanColumn({ title: 'Done', stories: done })}
116
+ ${KanbanColumn({ title: 'Pending', stories: allPending })}
117
+ ${KanbanColumn({ title: 'In Progress', stories: allInProgress })}
118
+ ${KanbanColumn({ title: 'Done', stories: allDone })}
84
119
  </div>
85
120
  </div>
86
121
  <main class="content-area" id="project-content-area" hidden>