mdboard 1.2.0 → 2.0.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.
Files changed (40) hide show
  1. package/bin.js +130 -44
  2. package/index.html +3321 -1195
  3. package/package.json +10 -11
  4. package/presets/kanban/api.json +91 -0
  5. package/presets/kanban/cli.json +69 -0
  6. package/presets/kanban/docs.json +29 -0
  7. package/presets/kanban/entities.json +128 -0
  8. package/presets/kanban/structure.json +15 -0
  9. package/presets/kanban/ui.json +86 -0
  10. package/presets/scrum/api.json +98 -0
  11. package/presets/scrum/cli.json +120 -0
  12. package/presets/scrum/docs.json +43 -0
  13. package/presets/scrum/entities.json +268 -0
  14. package/presets/scrum/structure.json +32 -0
  15. package/presets/scrum/ui.json +201 -0
  16. package/presets/shape-up/api.json +40 -0
  17. package/presets/shape-up/cli.json +44 -0
  18. package/presets/shape-up/docs.json +32 -0
  19. package/presets/shape-up/entities.json +140 -0
  20. package/presets/shape-up/structure.json +28 -0
  21. package/presets/shape-up/ui.json +114 -0
  22. package/src/cli/cli.js +338 -0
  23. package/src/cli/config.js +234 -0
  24. package/src/cli/init.js +175 -0
  25. package/src/cli/preset.js +849 -0
  26. package/src/cli/skill.js +417 -0
  27. package/src/cli/status.js +180 -0
  28. package/src/core/config.js +551 -0
  29. package/src/core/history.js +146 -0
  30. package/src/core/scanner.js +521 -0
  31. package/{workspace.js → src/core/workspace.js} +0 -15
  32. package/{yaml.js → src/core/yaml.js} +5 -1
  33. package/src/server/api.js +616 -0
  34. package/{server.js → src/server/server.js} +180 -132
  35. package/{watcher.js → src/server/watcher.js} +40 -9
  36. package/api.js +0 -752
  37. package/config.js +0 -73
  38. package/defaults.json +0 -43
  39. package/init.js +0 -109
  40. package/scanner.js +0 -491
@@ -0,0 +1,417 @@
1
+ /**
2
+ * mdboard skill — Generate and install AI skill for IDEs/agents
3
+ *
4
+ * Generates a dynamic skill document from the current project config
5
+ * and installs it to the appropriate location for the selected IDE/agent.
6
+ *
7
+ * Supported agents:
8
+ * - Claude Code (~/.claude/skills/mdboard/SKILL.md)
9
+ * - Cursor (.cursor/rules/mdboard.mdc)
10
+ * - Windsurf (.windsurf/rules/mdboard.md)
11
+ * - VS Code (.claude/skills/mdboard/SKILL.md)
12
+ *
13
+ * Usage:
14
+ * mdboard skill Interactive prompt
15
+ * mdboard skill --agent <name> Install directly
16
+ * mdboard skill --path <path> Install to custom path
17
+ */
18
+
19
+ 'use strict';
20
+
21
+ const fs = require('fs');
22
+ const path = require('path');
23
+ const os = require('os');
24
+ const readline = require('readline');
25
+ const configEngine = require('../core/config');
26
+
27
+ // --- Agent definitions ---
28
+
29
+ const AGENTS = {
30
+ 'claude-code': {
31
+ label: 'Claude Code',
32
+ path: function () { return path.join(os.homedir(), '.claude', 'skills', 'mdboard', 'SKILL.md'); },
33
+ format: 'skill-md',
34
+ },
35
+ 'cursor': {
36
+ label: 'Cursor',
37
+ path: function () { return path.join(process.cwd(), '.cursor', 'rules', 'mdboard.mdc'); },
38
+ format: 'mdc',
39
+ },
40
+ 'windsurf': {
41
+ label: 'Windsurf',
42
+ path: function () { return path.join(process.cwd(), '.windsurf', 'rules', 'mdboard.md'); },
43
+ format: 'skill-md',
44
+ },
45
+ 'vscode': {
46
+ label: 'VS Code',
47
+ path: function () { return path.join(process.cwd(), '.claude', 'skills', 'mdboard', 'SKILL.md'); },
48
+ format: 'skill-md',
49
+ },
50
+ };
51
+
52
+ // --- Skill content generator ---
53
+
54
+ /**
55
+ * Generate the skill document content from the current config.
56
+ *
57
+ * @param {object} cfg - Loaded config object
58
+ * @param {string} format - 'skill-md' or 'mdc'
59
+ * @returns {string}
60
+ */
61
+ function generateSkillContent(cfg, format) {
62
+ const types = configEngine.getEntityTypes(cfg);
63
+ const flat = configEngine.flattenHierarchy(cfg);
64
+ const hierarchyTypes = flat.filter(function (e) { return !e.standalone; });
65
+ const standaloneTypes = flat.filter(function (e) { return e.standalone; });
66
+ const methodology = cfg._preset || 'shape-up';
67
+
68
+ // Build hierarchy chain string
69
+ const hierarchyChain = hierarchyTypes.map(function (e) {
70
+ var entity = configEngine.getEntity(cfg, e.type);
71
+ return entity ? entity.singular : e.type;
72
+ }).join(' > ');
73
+
74
+ var lines = [];
75
+
76
+ // --- Frontmatter ---
77
+ if (format === 'mdc') {
78
+ lines.push('---');
79
+ lines.push('description: mdboard project management — ' + methodology + ' methodology');
80
+ lines.push('globs: "project/**/*.md"');
81
+ lines.push('alwaysApply: true');
82
+ lines.push('---');
83
+ } else {
84
+ lines.push('---');
85
+ lines.push('name: mdboard');
86
+ lines.push('description: mdboard project management — ' + methodology + ' methodology');
87
+ lines.push('---');
88
+ }
89
+
90
+ lines.push('');
91
+ lines.push('# mdboard — Project Management');
92
+ lines.push('');
93
+ lines.push('Manages a **' + methodology + '** project. Entity hierarchy: ' + hierarchyChain + '.');
94
+ if (standaloneTypes.length > 0) {
95
+ var standaloneNames = standaloneTypes.map(function (e) {
96
+ var entity = configEngine.getEntity(cfg, e.type);
97
+ return entity ? entity.plural : e.type;
98
+ }).join(', ');
99
+ lines.push('Standalone entities: ' + standaloneNames + '.');
100
+ }
101
+
102
+ // --- Entity Types ---
103
+ lines.push('');
104
+ lines.push('## Entity Types');
105
+ lines.push('');
106
+
107
+ for (var i = 0; i < types.length; i++) {
108
+ var type = types[i];
109
+ var entity = configEngine.getEntity(cfg, type);
110
+ if (!entity) continue;
111
+
112
+ var flatEntry = flat.find(function (e) { return e.type === type; });
113
+ var storage = entity.file === 'PREFIX-NNN.md' ? 'file-per-entity' : 'directory-per-entity';
114
+ var location = flatEntry ? flatEntry.dir : type;
115
+ var isStandalone = flatEntry && flatEntry.standalone;
116
+
117
+ lines.push('### ' + entity.singular + ' (`' + type + '`)');
118
+ lines.push('');
119
+ lines.push('- Prefix: `' + entity.prefix + '`');
120
+ lines.push('- Storage: ' + storage + ' in `' + location + '/`');
121
+ if (isStandalone) {
122
+ lines.push('- Standalone (not in hierarchy)');
123
+ }
124
+
125
+ // Fields
126
+ var fields = configEngine.getFields(cfg, type);
127
+ var fieldNames = Object.keys(fields);
128
+ if (fieldNames.length > 0) {
129
+ lines.push('- Fields:');
130
+ for (var f = 0; f < fieldNames.length; f++) {
131
+ var fname = fieldNames[f];
132
+ var fdef = fields[fname];
133
+ var desc = ' - `' + fname + '` (' + fdef.type + ')';
134
+ if (fdef.type === 'enum' && fdef.values) {
135
+ desc += ': ' + fdef.values.map(function (v) { return '`' + v.key + '`'; }).join(', ');
136
+ }
137
+ if (fdef.type === 'ref') {
138
+ desc += ' → ' + fdef.entity;
139
+ if (fdef.multiple) desc += ' (multiple)';
140
+ }
141
+ lines.push(desc);
142
+ }
143
+ }
144
+
145
+ // Statuses shortcut
146
+ var statuses = configEngine.getStatuses(cfg, type);
147
+ if (statuses) {
148
+ lines.push('- Statuses: ' + statuses.map(function (s) { return '`' + s.key + '`'; }).join(', '));
149
+ }
150
+
151
+ lines.push('');
152
+ }
153
+
154
+ // --- Directory Layout ---
155
+ lines.push('## Directory Layout');
156
+ lines.push('');
157
+ lines.push('```');
158
+ lines.push('project/');
159
+
160
+ for (var h = 0; h < hierarchyTypes.length; h++) {
161
+ var ht = hierarchyTypes[h];
162
+ var indent = ' '.repeat(h + 1);
163
+ lines.push(indent + ht.dir + '/');
164
+ if (h < hierarchyTypes.length - 1) {
165
+ var nextEntity = configEngine.getEntity(cfg, ht.type);
166
+ var slug = nextEntity ? nextEntity.prefix.toLowerCase() + '-nnn' : 'slug';
167
+ lines.push(indent + ' ' + slug + '/');
168
+ }
169
+ }
170
+
171
+ for (var s = 0; s < standaloneTypes.length; s++) {
172
+ lines.push(' ' + standaloneTypes[s].dir + '/');
173
+ }
174
+
175
+ lines.push('```');
176
+ lines.push('');
177
+
178
+ // --- Markdown Format ---
179
+ var leafType = hierarchyTypes.length > 0 ? hierarchyTypes[hierarchyTypes.length - 1].type : types[0];
180
+ var leafEntity = configEngine.getEntity(cfg, leafType);
181
+ var leafFields = configEngine.getFields(cfg, leafType);
182
+
183
+ lines.push('## Markdown Format');
184
+ lines.push('');
185
+ lines.push('Each entity is a markdown file with YAML frontmatter:');
186
+ lines.push('');
187
+ lines.push('```markdown');
188
+ lines.push('---');
189
+
190
+ if (leafEntity) {
191
+ lines.push('title: "Example ' + leafEntity.singular + '"');
192
+ var lfNames = Object.keys(leafFields);
193
+ for (var lf = 0; lf < lfNames.length; lf++) {
194
+ var lfName = lfNames[lf];
195
+ var lfDef = leafFields[lfName];
196
+ if (lfDef.type === 'enum' && lfDef.values && lfDef.values.length > 0) {
197
+ lines.push(lfName + ': ' + (lfDef.default || lfDef.values[0].key));
198
+ } else if (lfDef.type === 'number') {
199
+ lines.push(lfName + ': ' + (lfDef.default || 1));
200
+ } else if (lfDef.type === 'list') {
201
+ lines.push(lfName + ': []');
202
+ } else if (lfDef.type === 'date') {
203
+ lines.push(lfName + ': ' + new Date().toISOString().split('T')[0]);
204
+ } else if (lfDef.type === 'ref') {
205
+ lines.push(lfName + ': ' + (lfDef.multiple ? '[]' : '""'));
206
+ } else {
207
+ lines.push(lfName + ': ""');
208
+ }
209
+ }
210
+ }
211
+
212
+ lines.push('---');
213
+ lines.push('');
214
+ lines.push('Content goes here (markdown body).');
215
+ lines.push('```');
216
+ lines.push('');
217
+
218
+ // --- CLI Commands ---
219
+ lines.push('## CLI Commands');
220
+ lines.push('');
221
+ lines.push('### CRUD');
222
+ lines.push('');
223
+
224
+ for (var ci = 0; ci < types.length; ci++) {
225
+ var ctype = types[ci];
226
+ var centity = configEngine.getEntity(cfg, ctype);
227
+ if (!centity) continue;
228
+ var ancestors = configEngine.getAncestors(cfg, ctype);
229
+ var ancestorFlags = ancestors.map(function (a) { return '--' + a.replace(/_/g, '-') + ' <slug>'; }).join(' ');
230
+ var flagSuffix = ancestorFlags ? ' ' + ancestorFlags : '';
231
+
232
+ lines.push('- `mdboard create ' + ctype + ' "title"' + flagSuffix + '`');
233
+ lines.push('- `mdboard update ' + ctype + ' <id> --field value`');
234
+ lines.push('- `mdboard delete ' + ctype + ' <id>`');
235
+ lines.push('- `mdboard list ' + ctype + '`');
236
+ }
237
+
238
+ lines.push('');
239
+ lines.push('### System');
240
+ lines.push('');
241
+ lines.push('- `mdboard` — Start dashboard server');
242
+ lines.push('- `mdboard init [name]` — Scaffold new project');
243
+ lines.push('- `mdboard status` — Generate status report');
244
+ lines.push('- `mdboard config` — Setup preferences & AI skill');
245
+ lines.push('- `mdboard skill` — Install AI skill to IDE');
246
+ lines.push('- `mdboard cache list|clean` — Manage cache');
247
+ lines.push('- `mdboard history list|clean` — Manage history');
248
+ lines.push('- `mdboard --help` — Show all commands');
249
+
250
+ // --- API Endpoints ---
251
+ lines.push('');
252
+ lines.push('## API Endpoints');
253
+ lines.push('');
254
+ lines.push('Server runs at `http://localhost:3333` by default.');
255
+ lines.push('');
256
+ lines.push('### CRUD (per entity type)');
257
+ lines.push('');
258
+
259
+ for (var ai = 0; ai < types.length; ai++) {
260
+ var atype = types[ai];
261
+ var aentity = configEngine.getEntity(cfg, atype);
262
+ if (!aentity) continue;
263
+ var plural = aentity.plural.toLowerCase();
264
+ lines.push('- `GET /api/' + plural + '` — List ' + aentity.plural.toLowerCase());
265
+ lines.push('- `GET /api/' + plural + '/:id` — Get single');
266
+ lines.push('- `POST /api/' + plural + '` — Create');
267
+ lines.push('- `PATCH /api/' + plural + '/:id` — Update');
268
+ lines.push('- `DELETE /api/' + plural + '/:id` — Delete');
269
+ }
270
+
271
+ lines.push('');
272
+ lines.push('### System');
273
+ lines.push('');
274
+ lines.push('- `GET /api/config` — Dashboard configuration');
275
+ lines.push('- `GET /api/project` — Project metadata');
276
+ lines.push('- `GET /api/sources` — Workspace sources');
277
+ lines.push('- `GET /api/metrics` — Aggregated metrics');
278
+ lines.push('- `GET /api/health` — Project health');
279
+ lines.push('- `GET /api/status` — Status report');
280
+ lines.push('- `GET /api/events` — SSE stream');
281
+
282
+ // --- Best Practices ---
283
+ lines.push('');
284
+ lines.push('## Best Practices');
285
+ lines.push('');
286
+ lines.push('- Always use the CLI to create/update/delete entities — it handles ID generation, file placement, and frontmatter.');
287
+ lines.push('- Parent flags (`--cycle`, `--bet`, etc.) use **slugs** (directory names), not IDs.');
288
+ lines.push('- Do not manually edit entity IDs or prefixes.');
289
+ lines.push('- Status values are strict enums — only use the defined values listed above.');
290
+ lines.push('- Entity files live inside `project/` — respect the hierarchy structure.');
291
+ lines.push('- Use `mdboard status` to generate a summary report.');
292
+ lines.push('');
293
+
294
+ return lines.join('\n');
295
+ }
296
+
297
+ // --- Installer ---
298
+
299
+ /**
300
+ * Install the skill file to the given path.
301
+ *
302
+ * @param {string} projectDir - Project directory (for config loading)
303
+ * @param {string} filePath - Absolute path to write the skill file
304
+ * @param {string} format - 'skill-md' or 'mdc'
305
+ */
306
+ function installSkill(projectDir, filePath, format) {
307
+ var cfg = configEngine.loadConfig(projectDir, process.env.MDBOARD_CONFIG);
308
+ var content = generateSkillContent(cfg, format);
309
+
310
+ // Create directories
311
+ var dir = path.dirname(filePath);
312
+ if (!fs.existsSync(dir)) {
313
+ fs.mkdirSync(dir, { recursive: true });
314
+ }
315
+
316
+ fs.writeFileSync(filePath, content, 'utf-8');
317
+ }
318
+
319
+ // --- CLI runner ---
320
+
321
+ /**
322
+ * Run the skill command.
323
+ *
324
+ * @param {string[]} args - CLI arguments after 'skill'
325
+ */
326
+ function run(args) {
327
+ var agentName = null;
328
+ var customPath = null;
329
+
330
+ for (var i = 0; i < args.length; i++) {
331
+ if (args[i] === '--agent' && args[i + 1]) {
332
+ agentName = args[i + 1];
333
+ i++;
334
+ } else if (args[i] === '--path' && args[i + 1]) {
335
+ customPath = args[i + 1];
336
+ i++;
337
+ }
338
+ }
339
+
340
+ // Direct install with --agent
341
+ if (agentName) {
342
+ var agent = AGENTS[agentName];
343
+ if (!agent) {
344
+ console.error(' Error: unknown agent "' + agentName + '"');
345
+ console.error(' Available: ' + Object.keys(AGENTS).join(', '));
346
+ process.exit(1);
347
+ }
348
+ var filePath = agent.path();
349
+ installSkill(process.cwd(), filePath, agent.format);
350
+ console.log('\n Skill installed: ' + agent.label);
351
+ console.log(' Path: ' + filePath + '\n');
352
+ process.exit(0);
353
+ }
354
+
355
+ // Direct install with --path
356
+ if (customPath) {
357
+ var resolved = path.resolve(customPath);
358
+ var fmt = resolved.endsWith('.mdc') ? 'mdc' : 'skill-md';
359
+ installSkill(process.cwd(), resolved, fmt);
360
+ console.log('\n Skill installed: ' + resolved + '\n');
361
+ process.exit(0);
362
+ }
363
+
364
+ // Interactive prompt
365
+ var rl = readline.createInterface({ input: process.stdin, output: process.stdout });
366
+
367
+ console.log('\n mdboard skill — Install AI skill to your IDE\n');
368
+ console.log(' Select target:\n');
369
+ console.log(' 1) Claude Code ~/.claude/skills/mdboard/SKILL.md');
370
+ console.log(' 2) Cursor .cursor/rules/mdboard.mdc');
371
+ console.log(' 3) Windsurf .windsurf/rules/mdboard.md');
372
+ console.log(' 4) VS Code .claude/skills/mdboard/SKILL.md');
373
+ console.log(' 5) Custom path');
374
+ console.log(' 6) Skip\n');
375
+
376
+ rl.question(' Choice [1-6]: ', function (answer) {
377
+ var choice = answer.trim();
378
+
379
+ if (choice === '6' || choice === '') {
380
+ console.log(' Skipped.\n');
381
+ rl.close();
382
+ process.exit(0);
383
+ return;
384
+ }
385
+
386
+ if (choice === '5') {
387
+ rl.question(' Path: ', function (pathAnswer) {
388
+ var p = path.resolve(pathAnswer.trim());
389
+ var f = p.endsWith('.mdc') ? 'mdc' : 'skill-md';
390
+ installSkill(process.cwd(), p, f);
391
+ console.log('\n Skill installed: ' + p + '\n');
392
+ rl.close();
393
+ process.exit(0);
394
+ });
395
+ return;
396
+ }
397
+
398
+ var agentKeys = ['claude-code', 'cursor', 'windsurf', 'vscode'];
399
+ var idx = parseInt(choice, 10) - 1;
400
+ if (idx < 0 || idx >= agentKeys.length) {
401
+ console.error(' Invalid choice.');
402
+ rl.close();
403
+ process.exit(1);
404
+ return;
405
+ }
406
+
407
+ var selected = AGENTS[agentKeys[idx]];
408
+ var fp = selected.path();
409
+ installSkill(process.cwd(), fp, selected.format);
410
+ console.log('\n Skill installed: ' + selected.label);
411
+ console.log(' Path: ' + fp + '\n');
412
+ rl.close();
413
+ process.exit(0);
414
+ });
415
+ }
416
+
417
+ module.exports = { run, installSkill, generateSkillContent, AGENTS };
@@ -0,0 +1,180 @@
1
+ /**
2
+ * mdboard — Status generator (config-driven)
3
+ *
4
+ * Generates project/status.md with computed project status.
5
+ * All entity types come from config — no hardcoded names.
6
+ *
7
+ * Usage: mdboard status [--project <path>]
8
+ */
9
+
10
+ 'use strict';
11
+
12
+ const fs = require('fs');
13
+ const path = require('path');
14
+ const { buildModel } = require('./cli');
15
+ const {
16
+ getEntityTypes, getEntity, flattenHierarchy, isCompletedStatus,
17
+ } = require('../core/config');
18
+
19
+ /**
20
+ * Generate project/status.md
21
+ *
22
+ * @param {string} projectDir - Workspace root directory
23
+ */
24
+ function generateStatus(projectDir) {
25
+ const { model, config: cfg, projectPath } = buildModel(projectDir);
26
+ const status = computeStatus(model, cfg);
27
+
28
+ const projectName = (model.project && model.project.name) || path.basename(projectDir);
29
+ const lines = [];
30
+
31
+ // Frontmatter
32
+ lines.push('---');
33
+ lines.push('generated: ' + status.generated);
34
+ lines.push('---');
35
+ lines.push('');
36
+ lines.push('# Project Status: ' + projectName);
37
+ lines.push('');
38
+ lines.push('> Auto-generated by `mdboard status`. Do not edit manually.');
39
+ lines.push('');
40
+
41
+ // Summary table
42
+ lines.push('## Summary');
43
+ lines.push('');
44
+ lines.push('| Entity | Total | Completed | Progress |');
45
+ lines.push('|--------|-------|-----------|----------|');
46
+ for (var i = 0; i < status.summary.length; i++) {
47
+ var s = status.summary[i];
48
+ var extra = s.points ? ' (' + s.completedPoints + '/' + s.points + ' pts)' : '';
49
+ lines.push('| ' + s.label + ' | ' + s.total + ' | ' + s.completed + ' | ' + s.percent + '%' + extra + ' |');
50
+ }
51
+ lines.push('');
52
+
53
+ // Hierarchy sections
54
+ for (var hi = 0; hi < status.hierarchy.length; hi++) {
55
+ var h = status.hierarchy[hi];
56
+ lines.push('## ' + h.label);
57
+ lines.push('');
58
+ for (var j = 0; j < h.items.length; j++) {
59
+ var item = h.items[j];
60
+ lines.push('### ' + item.id + ': ' + (item.title || '') + ' [' + (item.status || '-') + '] (' + item.progress + '%)');
61
+ lines.push('- Children: ' + item._completedCount + '/' + item._childCount + ' done');
62
+ lines.push('');
63
+ }
64
+ }
65
+
66
+ // Breakdown
67
+ for (var type in status.breakdown) {
68
+ var entity = getEntity(cfg, type);
69
+ var label = entity ? entity.plural : type;
70
+ lines.push('## ' + label + ' by Status');
71
+ lines.push('');
72
+ var groups = status.breakdown[type];
73
+ for (var statusKey in groups) {
74
+ lines.push('- **' + capitalize(statusKey) + '**: ' + groups[statusKey]);
75
+ }
76
+ lines.push('');
77
+ }
78
+
79
+ var content = lines.join('\n') + '\n';
80
+
81
+ // Write to project/status.md
82
+ var statusPath = path.join(projectPath, 'status.md');
83
+ fs.mkdirSync(projectPath, { recursive: true });
84
+ fs.writeFileSync(statusPath, content, 'utf-8');
85
+
86
+ console.log(' mdboard status — Generated ' + path.relative(projectDir, statusPath));
87
+ var summaryLine = status.summary.map(function(s) { return s.total + ' ' + s.label.toLowerCase(); }).join(', ');
88
+ console.log(' ' + summaryLine);
89
+ }
90
+
91
+ /**
92
+ * Compute status data from model and config.
93
+ * Returns the JSON structure served by GET /api/status.
94
+ */
95
+ function computeStatus(model, cfg) {
96
+ var types = getEntityTypes(cfg);
97
+ var flat = flattenHierarchy(cfg);
98
+ var hierarchyTypes = flat.filter(function(e) { return !e.standalone; });
99
+ var leafType = hierarchyTypes.length > 0 ? hierarchyTypes[hierarchyTypes.length - 1].type : null;
100
+
101
+ // Summary: per entity type
102
+ var summary = [];
103
+ for (var i = 0; i < types.length; i++) {
104
+ var type = types[i];
105
+ var entity = getEntity(cfg, type);
106
+ var items = model.entities[type] || [];
107
+ var total = items.length;
108
+ var completed = items.filter(function(it) { return isCompletedStatus(cfg, it.status); }).length;
109
+ var percent = total > 0 ? Math.round(completed / total * 100) : 0;
110
+ var entry = {
111
+ type: type,
112
+ label: entity ? entity.plural : type,
113
+ total: total,
114
+ completed: completed,
115
+ percent: percent,
116
+ };
117
+
118
+ // Points for leaf type
119
+ if (type === leafType) {
120
+ entry.points = items.reduce(function(sum, it) { return sum + (it.points || 0); }, 0);
121
+ entry.completedPoints = items
122
+ .filter(function(it) { return isCompletedStatus(cfg, it.status); })
123
+ .reduce(function(sum, it) { return sum + (it.points || 0); }, 0);
124
+ }
125
+
126
+ summary.push(entry);
127
+ }
128
+
129
+ // Hierarchy: non-leaf types with progress
130
+ var hierarchy = [];
131
+ for (var hi = 0; hi < hierarchyTypes.length; hi++) {
132
+ var ht = hierarchyTypes[hi];
133
+ if (ht.type === leafType) continue;
134
+ var hEntity = getEntity(cfg, ht.type);
135
+ var hItems = model.entities[ht.type] || [];
136
+ var hMapped = hItems.map(function(it) {
137
+ return {
138
+ id: it.id,
139
+ title: it.title || '',
140
+ status: it.status || '',
141
+ progress: it._progress || 0,
142
+ _childCount: it._childCount || 0,
143
+ _completedCount: it._completedCount || 0,
144
+ };
145
+ });
146
+ hierarchy.push({
147
+ type: ht.type,
148
+ label: hEntity ? hEntity.plural : ht.type,
149
+ items: hMapped,
150
+ });
151
+ }
152
+
153
+ // Breakdown: leaf items grouped by status
154
+ var breakdown = {};
155
+ if (leafType) {
156
+ var leafItems = model.entities[leafType] || [];
157
+ var groups = {};
158
+ for (var li = 0; li < leafItems.length; li++) {
159
+ var st = leafItems[li].status || 'unknown';
160
+ groups[st] = (groups[st] || 0) + 1;
161
+ }
162
+ breakdown[leafType] = groups;
163
+ }
164
+
165
+ var projectName = (model.project && model.project.name) || '';
166
+
167
+ return {
168
+ generated: new Date().toISOString(),
169
+ project: { name: projectName },
170
+ summary: summary,
171
+ hierarchy: hierarchy,
172
+ breakdown: breakdown,
173
+ };
174
+ }
175
+
176
+ function capitalize(str) {
177
+ return str.replace(/(^|-)(\w)/g, function(_, sep, c) { return (sep ? ' ' : '') + c.toUpperCase(); });
178
+ }
179
+
180
+ module.exports = { generateStatus, computeStatus };