@1mancompany/onemancompany 0.7.25 → 0.7.30

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/frontend/app.js CHANGED
@@ -7528,6 +7528,9 @@ class AppController {
7528
7528
  const tabDefs = [
7529
7529
  { id: 'overview', label: 'Overview' },
7530
7530
  { id: 'issues', label: `Issues (${issues.length})` },
7531
+ { id: 'kanban', label: 'Kanban' },
7532
+ { id: 'roadmap', label: 'Roadmap' },
7533
+ { id: 'activity', label: 'Activity' },
7531
7534
  { id: 'projects', label: `Projects (${projects.length})` },
7532
7535
  ];
7533
7536
  const tabContent = document.createElement('div');
@@ -7559,6 +7562,12 @@ class AppController {
7559
7562
  this._renderProductOverview(product, versions, slug, container);
7560
7563
  } else if (tabId === 'issues') {
7561
7564
  this._renderProductIssues(issues, slug, container, data);
7565
+ } else if (tabId === 'kanban') {
7566
+ this._renderProductKanban(slug, container, data);
7567
+ } else if (tabId === 'roadmap') {
7568
+ this._renderProductRoadmap(slug, container);
7569
+ } else if (tabId === 'activity') {
7570
+ this._renderProductActivity(slug, container);
7562
7571
  } else if (tabId === 'projects') {
7563
7572
  this._renderProductProjects(projects, container);
7564
7573
  }
@@ -8246,6 +8255,322 @@ class AppController {
8246
8255
  }
8247
8256
  }
8248
8257
 
8258
+ // ---------------------------------------------------------------------------
8259
+ // Kanban Board Tab
8260
+ // ---------------------------------------------------------------------------
8261
+
8262
+ _renderProductKanban(slug, container, fullData) {
8263
+ container.innerHTML = '<div class="loading-text">Loading kanban...</div>';
8264
+ fetch(`/api/product/${encodeURIComponent(slug)}/kanban`)
8265
+ .then(r => r.json())
8266
+ .then(data => {
8267
+ container.innerHTML = '';
8268
+ const board = document.createElement('div');
8269
+ board.className = 'kanban-board';
8270
+
8271
+ const statusLabels = {
8272
+ backlog: 'Backlog',
8273
+ planned: 'Planned',
8274
+ in_progress: 'In Progress',
8275
+ in_review: 'In Review',
8276
+ done: 'Done',
8277
+ released: 'Released',
8278
+ };
8279
+ const blockedSet = new Set(data.blocked_ids || []);
8280
+
8281
+ for (const [status, label] of Object.entries(statusLabels)) {
8282
+ const col = document.createElement('div');
8283
+ col.className = 'kanban-column';
8284
+ col.dataset.status = status;
8285
+
8286
+ const colHeader = document.createElement('div');
8287
+ colHeader.className = 'kanban-column-header';
8288
+ const items = data.columns[status] || [];
8289
+ colHeader.textContent = `${label} (${items.length})`;
8290
+ col.appendChild(colHeader);
8291
+
8292
+ const cardList = document.createElement('div');
8293
+ cardList.className = 'kanban-card-list';
8294
+
8295
+ // Drag-drop: allow dropping on column
8296
+ cardList.addEventListener('dragover', (e) => { e.preventDefault(); cardList.classList.add('kanban-drop-target'); });
8297
+ cardList.addEventListener('dragleave', () => cardList.classList.remove('kanban-drop-target'));
8298
+ cardList.addEventListener('drop', (e) => {
8299
+ e.preventDefault();
8300
+ cardList.classList.remove('kanban-drop-target');
8301
+ const issueId = e.dataTransfer.getData('text/plain');
8302
+ if (issueId) {
8303
+ fetch(`/api/product/${encodeURIComponent(slug)}/issue/${encodeURIComponent(issueId)}`, {
8304
+ method: 'PUT',
8305
+ headers: { 'Content-Type': 'application/json' },
8306
+ body: JSON.stringify({ status }),
8307
+ }).then(() => this._renderProductKanban(slug, container, fullData));
8308
+ }
8309
+ });
8310
+
8311
+ for (const issue of items) {
8312
+ const card = document.createElement('div');
8313
+ card.className = `kanban-card priority-${(issue.priority || 'p2').toLowerCase()}`;
8314
+ card.draggable = true;
8315
+ card.dataset.issueId = issue.id;
8316
+ if (blockedSet.has(issue.id)) card.classList.add('kanban-blocked');
8317
+
8318
+ card.addEventListener('dragstart', (e) => {
8319
+ e.dataTransfer.setData('text/plain', issue.id);
8320
+ card.classList.add('dragging');
8321
+ });
8322
+ card.addEventListener('dragend', () => card.classList.remove('dragging'));
8323
+
8324
+ const priTag = document.createElement('span');
8325
+ priTag.className = `kanban-priority priority-${(issue.priority || 'p2').toLowerCase()}`;
8326
+ priTag.textContent = issue.priority || 'P2';
8327
+
8328
+ const title = document.createElement('span');
8329
+ title.className = 'kanban-card-title';
8330
+ title.textContent = issue.title;
8331
+
8332
+ const meta = document.createElement('div');
8333
+ meta.className = 'kanban-card-meta';
8334
+ if (issue.assignee_id) {
8335
+ meta.textContent = issue.assignee_id;
8336
+ }
8337
+ if (issue.story_points) {
8338
+ const sp = document.createElement('span');
8339
+ sp.className = 'kanban-sp';
8340
+ sp.textContent = `${issue.story_points}sp`;
8341
+ meta.appendChild(sp);
8342
+ }
8343
+ if (blockedSet.has(issue.id)) {
8344
+ const lock = document.createElement('span');
8345
+ lock.className = 'kanban-blocked-icon';
8346
+ lock.textContent = '🔒';
8347
+ lock.title = 'Blocked by dependency';
8348
+ meta.appendChild(lock);
8349
+ }
8350
+
8351
+ card.appendChild(priTag);
8352
+ card.appendChild(title);
8353
+ card.appendChild(meta);
8354
+ cardList.appendChild(card);
8355
+ }
8356
+
8357
+ col.appendChild(cardList);
8358
+ board.appendChild(col);
8359
+ }
8360
+
8361
+ container.appendChild(board);
8362
+ })
8363
+ .catch(err => { container.innerHTML = `<div class="error-text">Failed to load kanban: ${err.message}</div>`; });
8364
+ }
8365
+
8366
+ // ---------------------------------------------------------------------------
8367
+ // Roadmap Timeline Tab
8368
+ // ---------------------------------------------------------------------------
8369
+
8370
+ _renderProductRoadmap(slug, container) {
8371
+ container.innerHTML = '<div class="loading-text">Loading roadmap...</div>';
8372
+ fetch(`/api/product/${encodeURIComponent(slug)}/roadmap`)
8373
+ .then(r => r.json())
8374
+ .then(data => {
8375
+ container.innerHTML = '';
8376
+
8377
+ if (!data.sprints.length && !data.versions.length && !data.milestoned_issues.length) {
8378
+ container.innerHTML = '<div class="task-empty">No sprints, versions, or milestoned issues yet.</div>';
8379
+ return;
8380
+ }
8381
+
8382
+ // Sprints section
8383
+ if (data.sprints.length) {
8384
+ const section = document.createElement('div');
8385
+ section.className = 'roadmap-section';
8386
+ const h = document.createElement('h3');
8387
+ h.textContent = 'Sprints';
8388
+ section.appendChild(h);
8389
+
8390
+ const timeline = document.createElement('div');
8391
+ timeline.className = 'roadmap-timeline';
8392
+
8393
+ for (const s of data.sprints) {
8394
+ const bar = document.createElement('div');
8395
+ bar.className = `roadmap-sprint-bar roadmap-status-${s.status}`;
8396
+
8397
+ const label = document.createElement('div');
8398
+ label.className = 'roadmap-bar-label';
8399
+ label.textContent = `${s.name} (${s.issue_count} issues)`;
8400
+
8401
+ const dates = document.createElement('div');
8402
+ dates.className = 'roadmap-bar-dates';
8403
+ dates.textContent = `${s.start_date} → ${s.end_date}`;
8404
+
8405
+ const statusBadge = document.createElement('span');
8406
+ statusBadge.className = `roadmap-status-badge roadmap-status-${s.status}`;
8407
+ statusBadge.textContent = s.status;
8408
+
8409
+ bar.appendChild(label);
8410
+ bar.appendChild(dates);
8411
+ bar.appendChild(statusBadge);
8412
+ if (s.goal) {
8413
+ const goal = document.createElement('div');
8414
+ goal.className = 'roadmap-goal';
8415
+ goal.textContent = s.goal;
8416
+ bar.appendChild(goal);
8417
+ }
8418
+ timeline.appendChild(bar);
8419
+ }
8420
+ section.appendChild(timeline);
8421
+ container.appendChild(section);
8422
+ }
8423
+
8424
+ // Versions section
8425
+ if (data.versions.length) {
8426
+ const section = document.createElement('div');
8427
+ section.className = 'roadmap-section';
8428
+ const h = document.createElement('h3');
8429
+ h.textContent = 'Releases';
8430
+ section.appendChild(h);
8431
+
8432
+ for (const v of data.versions) {
8433
+ const row = document.createElement('div');
8434
+ row.className = 'roadmap-version-row';
8435
+
8436
+ const ver = document.createElement('span');
8437
+ ver.className = 'roadmap-version-tag';
8438
+ ver.textContent = `v${v.version}`;
8439
+
8440
+ const date = document.createElement('span');
8441
+ date.className = 'roadmap-version-date';
8442
+ date.textContent = v.released_at ? v.released_at.split('T')[0] : '';
8443
+
8444
+ const count = document.createElement('span');
8445
+ count.className = 'roadmap-version-count';
8446
+ count.textContent = `${v.resolved_count} issues resolved`;
8447
+
8448
+ row.appendChild(ver);
8449
+ row.appendChild(date);
8450
+ row.appendChild(count);
8451
+ section.appendChild(row);
8452
+ }
8453
+ container.appendChild(section);
8454
+ }
8455
+
8456
+ // Milestoned issues
8457
+ if (data.milestoned_issues.length) {
8458
+ const section = document.createElement('div');
8459
+ section.className = 'roadmap-section';
8460
+ const h = document.createElement('h3');
8461
+ h.textContent = 'Milestoned Issues';
8462
+ section.appendChild(h);
8463
+
8464
+ // Group by milestone_version
8465
+ const groups = {};
8466
+ for (const i of data.milestoned_issues) {
8467
+ const mv = i.milestone_version;
8468
+ if (!groups[mv]) groups[mv] = [];
8469
+ groups[mv].push(i);
8470
+ }
8471
+
8472
+ for (const [ver, items] of Object.entries(groups).sort()) {
8473
+ const group = document.createElement('div');
8474
+ group.className = 'roadmap-milestone-group';
8475
+
8476
+ const gh = document.createElement('div');
8477
+ gh.className = 'roadmap-milestone-header';
8478
+ gh.textContent = `v${ver} (${items.length} issues)`;
8479
+ group.appendChild(gh);
8480
+
8481
+ for (const item of items) {
8482
+ const row = document.createElement('div');
8483
+ row.className = `roadmap-issue-row priority-${(item.priority || 'p2').toLowerCase()}`;
8484
+ row.innerHTML = `<span class="roadmap-issue-pri">[${item.priority}]</span> ${item.title} <span class="roadmap-issue-status">${item.status}</span>`;
8485
+ group.appendChild(row);
8486
+ }
8487
+
8488
+ section.appendChild(group);
8489
+ }
8490
+ container.appendChild(section);
8491
+ }
8492
+ })
8493
+ .catch(err => { container.innerHTML = `<div class="error-text">Failed to load roadmap: ${err.message}</div>`; });
8494
+ }
8495
+
8496
+ // ---------------------------------------------------------------------------
8497
+ // Activity Feed Tab
8498
+ // ---------------------------------------------------------------------------
8499
+
8500
+ _renderProductActivity(slug, container) {
8501
+ container.innerHTML = '<div class="loading-text">Loading activity...</div>';
8502
+ fetch(`/api/product/${encodeURIComponent(slug)}/activity?limit=100`)
8503
+ .then(r => r.json())
8504
+ .then(entries => {
8505
+ container.innerHTML = '';
8506
+
8507
+ if (!entries.length) {
8508
+ container.innerHTML = '<div class="task-empty">No activity recorded yet.</div>';
8509
+ return;
8510
+ }
8511
+
8512
+ const feed = document.createElement('div');
8513
+ feed.className = 'activity-feed';
8514
+
8515
+ const eventIcons = {
8516
+ issue_created: '📋',
8517
+ issue_closed: '✅',
8518
+ issue_assigned: '👤',
8519
+ sprint_created: '🏃',
8520
+ sprint_closed: '🏁',
8521
+ version_released: '🚀',
8522
+ review_created: '📝',
8523
+ review_completed: '☑️',
8524
+ kr_updated: '📊',
8525
+ };
8526
+
8527
+ for (const entry of entries) {
8528
+ const item = document.createElement('div');
8529
+ item.className = 'activity-item';
8530
+
8531
+ const icon = document.createElement('span');
8532
+ icon.className = 'activity-icon';
8533
+ icon.textContent = eventIcons[entry.event_type] || '•';
8534
+
8535
+ const content = document.createElement('div');
8536
+ content.className = 'activity-content';
8537
+
8538
+ const headerLine = document.createElement('div');
8539
+ headerLine.className = 'activity-header';
8540
+ const typeLabel = document.createElement('span');
8541
+ typeLabel.className = 'activity-type';
8542
+ typeLabel.textContent = (entry.event_type || '').replace(/_/g, ' ');
8543
+ const actor = document.createElement('span');
8544
+ actor.className = 'activity-actor';
8545
+ actor.textContent = entry.actor || '';
8546
+ headerLine.appendChild(typeLabel);
8547
+ headerLine.appendChild(actor);
8548
+
8549
+ const detail = document.createElement('div');
8550
+ detail.className = 'activity-detail';
8551
+ detail.textContent = entry.detail || '';
8552
+
8553
+ const ts = document.createElement('div');
8554
+ ts.className = 'activity-ts';
8555
+ if (entry.ts) {
8556
+ const d = new Date(entry.ts);
8557
+ ts.textContent = d.toLocaleString();
8558
+ }
8559
+
8560
+ content.appendChild(headerLine);
8561
+ content.appendChild(detail);
8562
+ content.appendChild(ts);
8563
+
8564
+ item.appendChild(icon);
8565
+ item.appendChild(content);
8566
+ feed.appendChild(item);
8567
+ }
8568
+
8569
+ container.appendChild(feed);
8570
+ })
8571
+ .catch(err => { container.innerHTML = `<div class="error-text">Failed to load activity: ${err.message}</div>`; });
8572
+ }
8573
+
8249
8574
  _doUpdateProjectsPanel() {
8250
8575
  const panel = document.getElementById('projects-panel-list');
8251
8576
  if (!panel) return;
@@ -6173,6 +6173,291 @@ body.resize-dragging {
6173
6173
  align-items: center;
6174
6174
  }
6175
6175
 
6176
+ /* ================================================================
6177
+ Kanban Board
6178
+ ================================================================ */
6179
+
6180
+ .kanban-board {
6181
+ display: flex;
6182
+ gap: 8px;
6183
+ overflow-x: auto;
6184
+ padding: 4px 0;
6185
+ min-height: 200px;
6186
+ }
6187
+
6188
+ .kanban-column {
6189
+ flex: 1;
6190
+ min-width: 130px;
6191
+ max-width: 200px;
6192
+ background: rgba(255,255,255,0.02);
6193
+ border: 1px solid var(--border);
6194
+ border-radius: 2px;
6195
+ }
6196
+
6197
+ .kanban-column-header {
6198
+ padding: 6px 8px;
6199
+ font-size: calc(6px + var(--font-boost));
6200
+ color: var(--pixel-cyan);
6201
+ border-bottom: 1px solid var(--border);
6202
+ text-align: center;
6203
+ font-weight: bold;
6204
+ }
6205
+
6206
+ .kanban-card-list {
6207
+ padding: 4px;
6208
+ min-height: 40px;
6209
+ }
6210
+
6211
+ .kanban-card-list.kanban-drop-target {
6212
+ background: rgba(0,255,255,0.05);
6213
+ outline: 1px dashed var(--pixel-cyan);
6214
+ }
6215
+
6216
+ .kanban-card {
6217
+ background: rgba(0,0,0,0.3);
6218
+ border: 1px solid var(--border);
6219
+ border-left: 3px solid var(--text-dim);
6220
+ border-radius: 2px;
6221
+ padding: 4px 6px;
6222
+ margin-bottom: 4px;
6223
+ cursor: grab;
6224
+ font-size: calc(5px + var(--font-boost));
6225
+ transition: opacity 0.15s;
6226
+ }
6227
+
6228
+ .kanban-card.dragging {
6229
+ opacity: 0.4;
6230
+ }
6231
+
6232
+ .kanban-card.priority-p0 { border-left-color: #ff4444; }
6233
+ .kanban-card.priority-p1 { border-left-color: #ff8800; }
6234
+ .kanban-card.priority-p2 { border-left-color: var(--pixel-yellow); }
6235
+ .kanban-card.priority-p3 { border-left-color: var(--text-dim); }
6236
+
6237
+ .kanban-card.kanban-blocked {
6238
+ background: rgba(255,68,68,0.08);
6239
+ }
6240
+
6241
+ .kanban-priority {
6242
+ font-size: calc(4px + var(--font-boost));
6243
+ padding: 0 3px;
6244
+ border-radius: 1px;
6245
+ margin-right: 4px;
6246
+ }
6247
+
6248
+ .kanban-card-title {
6249
+ color: var(--pixel-white);
6250
+ word-break: break-word;
6251
+ }
6252
+
6253
+ .kanban-card-meta {
6254
+ color: var(--text-dim);
6255
+ font-size: calc(4px + var(--font-boost));
6256
+ margin-top: 3px;
6257
+ display: flex;
6258
+ gap: 4px;
6259
+ align-items: center;
6260
+ }
6261
+
6262
+ .kanban-sp {
6263
+ background: rgba(255,255,255,0.08);
6264
+ padding: 0 3px;
6265
+ border-radius: 1px;
6266
+ }
6267
+
6268
+ .kanban-blocked-icon {
6269
+ font-size: calc(5px + var(--font-boost));
6270
+ }
6271
+
6272
+ /* ================================================================
6273
+ Roadmap Timeline
6274
+ ================================================================ */
6275
+
6276
+ .roadmap-section {
6277
+ margin-bottom: 16px;
6278
+ }
6279
+
6280
+ .roadmap-section h3 {
6281
+ color: var(--pixel-cyan);
6282
+ font-size: calc(7px + var(--font-boost));
6283
+ margin: 0 0 8px 0;
6284
+ border-bottom: 1px solid var(--border);
6285
+ padding-bottom: 4px;
6286
+ }
6287
+
6288
+ .roadmap-timeline {
6289
+ display: flex;
6290
+ flex-direction: column;
6291
+ gap: 6px;
6292
+ }
6293
+
6294
+ .roadmap-sprint-bar {
6295
+ background: rgba(0,255,255,0.05);
6296
+ border: 1px solid var(--border);
6297
+ border-left: 3px solid var(--pixel-cyan);
6298
+ border-radius: 2px;
6299
+ padding: 6px 8px;
6300
+ }
6301
+
6302
+ .roadmap-sprint-bar.roadmap-status-active {
6303
+ border-left-color: var(--pixel-green);
6304
+ background: rgba(0,255,0,0.05);
6305
+ }
6306
+
6307
+ .roadmap-sprint-bar.roadmap-status-closed {
6308
+ border-left-color: var(--text-dim);
6309
+ opacity: 0.7;
6310
+ }
6311
+
6312
+ .roadmap-bar-label {
6313
+ color: var(--pixel-white);
6314
+ font-size: calc(6px + var(--font-boost));
6315
+ font-weight: bold;
6316
+ }
6317
+
6318
+ .roadmap-bar-dates {
6319
+ color: var(--text-dim);
6320
+ font-size: calc(5px + var(--font-boost));
6321
+ margin-top: 2px;
6322
+ }
6323
+
6324
+ .roadmap-status-badge {
6325
+ display: inline-block;
6326
+ font-size: calc(4px + var(--font-boost));
6327
+ padding: 0 4px;
6328
+ border-radius: 2px;
6329
+ margin-top: 3px;
6330
+ color: var(--pixel-cyan);
6331
+ border: 1px solid var(--border);
6332
+ }
6333
+
6334
+ .roadmap-status-badge.roadmap-status-active { color: var(--pixel-green); border-color: var(--pixel-green); }
6335
+ .roadmap-status-badge.roadmap-status-closed { color: var(--text-dim); border-color: var(--text-dim); }
6336
+
6337
+ .roadmap-goal {
6338
+ color: var(--text-dim);
6339
+ font-size: calc(5px + var(--font-boost));
6340
+ font-style: italic;
6341
+ margin-top: 2px;
6342
+ }
6343
+
6344
+ .roadmap-version-row {
6345
+ display: flex;
6346
+ align-items: center;
6347
+ gap: 10px;
6348
+ padding: 4px 0;
6349
+ border-bottom: 1px solid rgba(255,255,255,0.03);
6350
+ font-size: calc(6px + var(--font-boost));
6351
+ }
6352
+
6353
+ .roadmap-version-tag {
6354
+ color: var(--pixel-green);
6355
+ font-weight: bold;
6356
+ min-width: 60px;
6357
+ }
6358
+
6359
+ .roadmap-version-date {
6360
+ color: var(--text-dim);
6361
+ min-width: 80px;
6362
+ }
6363
+
6364
+ .roadmap-version-count {
6365
+ color: var(--pixel-white);
6366
+ }
6367
+
6368
+ .roadmap-milestone-group {
6369
+ margin-bottom: 10px;
6370
+ }
6371
+
6372
+ .roadmap-milestone-header {
6373
+ color: var(--pixel-yellow);
6374
+ font-size: calc(6px + var(--font-boost));
6375
+ font-weight: bold;
6376
+ margin-bottom: 4px;
6377
+ }
6378
+
6379
+ .roadmap-issue-row {
6380
+ padding: 2px 8px;
6381
+ font-size: calc(5px + var(--font-boost));
6382
+ color: var(--pixel-white);
6383
+ border-left: 2px solid var(--text-dim);
6384
+ margin-left: 8px;
6385
+ }
6386
+
6387
+ .roadmap-issue-row.priority-p0 { border-left-color: #ff4444; }
6388
+ .roadmap-issue-row.priority-p1 { border-left-color: #ff8800; }
6389
+
6390
+ .roadmap-issue-pri {
6391
+ color: var(--text-dim);
6392
+ margin-right: 4px;
6393
+ }
6394
+
6395
+ .roadmap-issue-status {
6396
+ color: var(--text-dim);
6397
+ font-size: calc(4px + var(--font-boost));
6398
+ margin-left: 6px;
6399
+ }
6400
+
6401
+ /* ================================================================
6402
+ Activity Feed
6403
+ ================================================================ */
6404
+
6405
+ .activity-feed {
6406
+ display: flex;
6407
+ flex-direction: column;
6408
+ gap: 2px;
6409
+ }
6410
+
6411
+ .activity-item {
6412
+ display: flex;
6413
+ gap: 8px;
6414
+ padding: 6px 8px;
6415
+ border-bottom: 1px solid rgba(255,255,255,0.03);
6416
+ }
6417
+
6418
+ .activity-icon {
6419
+ font-size: calc(7px + var(--font-boost));
6420
+ flex-shrink: 0;
6421
+ width: 20px;
6422
+ text-align: center;
6423
+ }
6424
+
6425
+ .activity-content {
6426
+ flex: 1;
6427
+ min-width: 0;
6428
+ }
6429
+
6430
+ .activity-header {
6431
+ display: flex;
6432
+ gap: 8px;
6433
+ align-items: center;
6434
+ }
6435
+
6436
+ .activity-type {
6437
+ color: var(--pixel-cyan);
6438
+ font-size: calc(5px + var(--font-boost));
6439
+ font-weight: bold;
6440
+ text-transform: capitalize;
6441
+ }
6442
+
6443
+ .activity-actor {
6444
+ color: var(--text-dim);
6445
+ font-size: calc(4px + var(--font-boost));
6446
+ }
6447
+
6448
+ .activity-detail {
6449
+ color: var(--pixel-white);
6450
+ font-size: calc(5px + var(--font-boost));
6451
+ margin-top: 1px;
6452
+ word-break: break-word;
6453
+ }
6454
+
6455
+ .activity-ts {
6456
+ color: var(--text-dim);
6457
+ font-size: calc(4px + var(--font-boost));
6458
+ margin-top: 1px;
6459
+ }
6460
+
6176
6461
  /* Product Detail Button in panel */
6177
6462
  .product-detail-btn {
6178
6463
  margin-left: auto;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@1mancompany/onemancompany",
3
- "version": "0.7.25",
3
+ "version": "0.7.30",
4
4
  "description": "The AI Operating System for One-Person Companies",
5
5
  "bin": {
6
6
  "onemancompany": "bin/cli.js"
package/pyproject.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "onemancompany"
3
- version = "0.7.25"
3
+ version = "0.7.30"
4
4
  description = "A one-man company simulation with pixel art visualization and LangChain AI agents"
5
5
  requires-python = ">=3.12"
6
6
  dependencies = [
@@ -7197,8 +7197,11 @@ async def api_list_issues(slug: str, status: str = "", priority: str = "") -> li
7197
7197
  from onemancompany.core import product as prod
7198
7198
  from onemancompany.core.models import IssuePriority, IssueStatus
7199
7199
 
7200
- status_filter = IssueStatus(status) if status else None
7201
- priority_filter = IssuePriority(priority) if priority else None
7200
+ try:
7201
+ status_filter = IssueStatus(status) if status else None
7202
+ priority_filter = IssuePriority(priority) if priority else None
7203
+ except ValueError as exc:
7204
+ raise HTTPException(status_code=400, detail=str(exc))
7202
7205
  return prod.list_issues(slug, status=status_filter, priority=priority_filter)
7203
7206
 
7204
7207
 
@@ -7223,13 +7226,17 @@ async def api_update_issue(slug: str, issue_id: str, request: Request) -> dict:
7223
7226
  ISSUE_MUTABLE_FIELDS = {"title", "status", "priority", "assignee_id", "labels", "milestone_version", "description", "story_points", "sprint"}
7224
7227
  body = await request.json()
7225
7228
  filtered = {k: v for k, v in body.items() if k in ISSUE_MUTABLE_FIELDS}
7226
- if "status" in filtered:
7227
- filtered["status"] = _IS(filtered["status"]).value
7228
- if "priority" in filtered:
7229
- filtered["priority"] = _IP2(filtered["priority"]).value
7230
- result = prod.update_issue(slug, issue_id, **filtered)
7231
- if not result:
7232
- raise HTTPException(status_code=404, detail=f"Issue '{issue_id}' not found")
7229
+ try:
7230
+ if "status" in filtered:
7231
+ filtered["status"] = _IS(filtered["status"]).value
7232
+ if "priority" in filtered:
7233
+ filtered["priority"] = _IP2(filtered["priority"]).value
7234
+ except ValueError as exc:
7235
+ raise HTTPException(status_code=400, detail=str(exc))
7236
+ try:
7237
+ result = prod.update_issue(slug, issue_id, **filtered)
7238
+ except ValueError as exc:
7239
+ raise HTTPException(status_code=404, detail=str(exc))
7233
7240
 
7234
7241
  # Publish ISSUE_ASSIGNED event when assignee changes
7235
7242
  if "assignee_id" in filtered and filtered["assignee_id"]:
@@ -7257,9 +7264,14 @@ async def api_close_issue(slug: str, issue_id: str, request: Request) -> dict:
7257
7264
 
7258
7265
  body = await request.json() if request.headers.get("content-length", "0") != "0" else {}
7259
7266
  resolution_str = body.get("resolution", "fixed")
7260
- result = prod.close_issue(slug, issue_id, resolution=IssueResolution(resolution_str))
7261
- if not result:
7262
- raise HTTPException(status_code=404, detail=f"Issue '{issue_id}' not found")
7267
+ try:
7268
+ resolution = IssueResolution(resolution_str)
7269
+ except ValueError as exc:
7270
+ raise HTTPException(status_code=400, detail=str(exc))
7271
+ try:
7272
+ result = prod.close_issue(slug, issue_id, resolution=resolution)
7273
+ except ValueError as exc:
7274
+ raise HTTPException(status_code=404, detail=str(exc))
7263
7275
  await event_bus.publish(
7264
7276
  CompanyEvent(
7265
7277
  type=EventType.ISSUE_CLOSED,
@@ -7275,9 +7287,10 @@ async def api_reopen_issue(slug: str, issue_id: str) -> dict:
7275
7287
  """Reopen a closed issue."""
7276
7288
  from onemancompany.core import product as prod
7277
7289
 
7278
- result = prod.reopen_issue(slug, issue_id)
7279
- if not result:
7280
- raise HTTPException(status_code=404, detail=f"Issue '{issue_id}' not found")
7290
+ try:
7291
+ result = prod.reopen_issue(slug, issue_id)
7292
+ except ValueError as exc:
7293
+ raise HTTPException(status_code=404, detail=str(exc))
7281
7294
  return result
7282
7295
 
7283
7296
 
@@ -7645,3 +7658,48 @@ async def api_complete_review(slug: str, review_id: str) -> dict:
7645
7658
  return prod.complete_review(slug, review_id)
7646
7659
  except ValueError as exc:
7647
7660
  raise HTTPException(status_code=400, detail=str(exc))
7661
+
7662
+
7663
+ # ---------------------------------------------------------------------------
7664
+ # Kanban Board
7665
+ # ---------------------------------------------------------------------------
7666
+
7667
+
7668
+ @router.get("/api/product/{slug}/kanban")
7669
+ async def api_kanban_board(slug: str) -> dict:
7670
+ """Return issues grouped by status columns for kanban view."""
7671
+ from onemancompany.core import product as prod
7672
+
7673
+ try:
7674
+ return prod.kanban_board(slug)
7675
+ except ValueError as exc:
7676
+ raise HTTPException(status_code=404, detail=str(exc))
7677
+
7678
+
7679
+ # ---------------------------------------------------------------------------
7680
+ # Roadmap Timeline
7681
+ # ---------------------------------------------------------------------------
7682
+
7683
+
7684
+ @router.get("/api/product/{slug}/roadmap")
7685
+ async def api_roadmap_timeline(slug: str) -> dict:
7686
+ """Return sprints, versions, and milestoned issues for timeline view."""
7687
+ from onemancompany.core import product as prod
7688
+
7689
+ try:
7690
+ return prod.roadmap_timeline(slug)
7691
+ except ValueError as exc:
7692
+ raise HTTPException(status_code=404, detail=str(exc))
7693
+
7694
+
7695
+ # ---------------------------------------------------------------------------
7696
+ # Product Activity Feed
7697
+ # ---------------------------------------------------------------------------
7698
+
7699
+
7700
+ @router.get("/api/product/{slug}/activity")
7701
+ async def api_product_activity(slug: str, limit: int = 50) -> list[dict]:
7702
+ """Return product-scoped activity feed, newest first."""
7703
+ from onemancompany.core import product as prod
7704
+
7705
+ return prod.list_product_activity(slug, limit=limit)
@@ -63,6 +63,7 @@ ISSUES_DIR_NAME = "issues"
63
63
  VERSIONS_DIR_NAME = "versions"
64
64
  SPRINTS_DIR_NAME = "sprints"
65
65
  REVIEWS_DIR_NAME = "reviews"
66
+ ACTIVITY_LOG_DIR_NAME = "activity"
66
67
  TALENT_PERSONA_FILENAME = "talent_persona.md"
67
68
  MCP_CONFIG_FILENAME = "mcp_config.json"
68
69
  CONVERSATIONS_DIR_NAME = "conversations"
@@ -18,6 +18,7 @@ from pathlib import Path
18
18
  from loguru import logger
19
19
 
20
20
  from onemancompany.core.config import (
21
+ ACTIVITY_LOG_DIR_NAME,
21
22
  ISSUES_DIR_NAME,
22
23
  PRODUCT_YAML_FILENAME,
23
24
  PRODUCTS_DIR,
@@ -286,9 +287,11 @@ def create_issue(
286
287
  sprint: str | None = None,
287
288
  ) -> dict:
288
289
  """Create an issue for a product. Returns the issue dict."""
289
- issue_id = _gen_id("issue_")
290
290
  product = load_product(slug)
291
- product_id = product["id"] if product else ""
291
+ if not product:
292
+ raise ValueError(f"Product '{slug}' not found")
293
+ issue_id = _gen_id("issue_")
294
+ product_id = product["id"]
292
295
  now = datetime.now().isoformat()
293
296
 
294
297
  data = {
@@ -379,14 +382,13 @@ def list_issues(
379
382
  return results
380
383
 
381
384
 
382
- def update_issue(slug: str, issue_id: str, **fields) -> dict | None:
383
- """Update issue fields. Returns updated dict or None if not found."""
385
+ def update_issue(slug: str, issue_id: str, **fields) -> dict:
386
+ """Update issue fields. Returns updated dict. Raises ValueError if not found."""
384
387
  with _get_slug_lock(slug):
385
388
  path = _issues_dir(slug) / f"{issue_id}.yaml"
386
389
  data = _read_yaml(path)
387
390
  if not data:
388
- logger.warning("update_issue: issue {} not found in {}", issue_id, slug)
389
- return None
391
+ raise ValueError(f"Issue '{issue_id}' not found in product '{slug}'")
390
392
  for key, value in fields.items():
391
393
  if value is not None:
392
394
  old_value = data.get(key)
@@ -403,14 +405,13 @@ def close_issue(
403
405
  issue_id: str,
404
406
  *,
405
407
  resolution: IssueResolution = IssueResolution.FIXED,
406
- ) -> dict | None:
407
- """Close an issue with a resolution. Returns updated dict or None."""
408
+ ) -> dict:
409
+ """Close an issue with a resolution. Returns updated dict. Raises ValueError if not found."""
408
410
  with _get_slug_lock(slug):
409
411
  path = _issues_dir(slug) / f"{issue_id}.yaml"
410
412
  data = _read_yaml(path)
411
413
  if not data:
412
- logger.warning("close_issue: issue {} not found in {}", issue_id, slug)
413
- return None
414
+ raise ValueError(f"Issue '{issue_id}' not found in product '{slug}'")
414
415
  old_status = data.get("status")
415
416
  _append_history(data, "status", old_status, IssueStatus.DONE.value, changed_by="system")
416
417
  data["status"] = IssueStatus.DONE.value
@@ -422,14 +423,13 @@ def close_issue(
422
423
  return data
423
424
 
424
425
 
425
- def reopen_issue(slug: str, issue_id: str) -> dict | None:
426
- """Reopen a closed issue. Increments reopened_count. Returns updated dict or None."""
426
+ def reopen_issue(slug: str, issue_id: str) -> dict:
427
+ """Reopen a closed issue. Increments reopened_count. Returns updated dict. Raises ValueError if not found."""
427
428
  with _get_slug_lock(slug):
428
429
  path = _issues_dir(slug) / f"{issue_id}.yaml"
429
430
  data = _read_yaml(path)
430
431
  if not data:
431
- logger.warning("reopen_issue: issue {} not found in {}", issue_id, slug)
432
- return None
432
+ raise ValueError(f"Issue '{issue_id}' not found in product '{slug}'")
433
433
  old_status = data.get("status")
434
434
  _append_history(data, "status", old_status, IssueStatus.BACKLOG.value, changed_by="system")
435
435
  data["status"] = IssueStatus.BACKLOG.value
@@ -477,6 +477,13 @@ def add_issue_link(
477
477
  rel_value = relation.value if hasattr(relation, "value") else relation
478
478
  reverse_rel = _REVERSE_RELATION[rel_value]
479
479
 
480
+ # Circular dependency check for blocking relations
481
+ if rel_value == IssueRelation.BLOCKS.value:
482
+ _check_block_cycle(slug, issue_id, target_id)
483
+ elif rel_value == IssueRelation.BLOCKED_BY.value:
484
+ # blocked_by is the reverse: target blocks issue
485
+ _check_block_cycle(slug, target_id, issue_id)
486
+
480
487
  # Add forward link (idempotent)
481
488
  _add_link_entry(slug, issue_id, target_id, rel_value)
482
489
  # Add reverse link
@@ -486,6 +493,34 @@ def add_issue_link(
486
493
  logger.debug("Linked {} —{}→ {}", issue_id, rel_value, target_id)
487
494
 
488
495
 
496
+ def _check_block_cycle(slug: str, blocker_id: str, blocked_id: str) -> None:
497
+ """Raise ValueError if adding 'blocker_id blocks blocked_id' would create a cycle.
498
+
499
+ Walks the existing 'blocks' graph starting from blocked_id to see if
500
+ blocker_id is reachable (meaning blocked_id already transitively blocks blocker_id).
501
+ """
502
+ visited: set[str] = set()
503
+ queue = [blocked_id]
504
+ while queue:
505
+ current = queue.pop()
506
+ if current in visited:
507
+ continue
508
+ visited.add(current)
509
+ issue = load_issue(slug, current)
510
+ if not issue:
511
+ continue
512
+ for link in issue.get("issue_links", []):
513
+ if link["relation"] != IssueRelation.BLOCKS.value:
514
+ continue
515
+ downstream = link["issue_id"]
516
+ if downstream == blocker_id:
517
+ raise ValueError(
518
+ f"Circular dependency: {blocked_id} already transitively "
519
+ f"blocks {blocker_id}"
520
+ )
521
+ queue.append(downstream)
522
+
523
+
489
524
  def _add_link_entry(slug: str, issue_id: str, target_id: str, relation: str) -> None:
490
525
  """Add a single link entry to an issue (idempotent)."""
491
526
  with _get_slug_lock(slug):
@@ -662,6 +697,143 @@ def complete_review(slug: str, review_id: str) -> dict:
662
697
  return data
663
698
 
664
699
 
700
+ # ---------------------------------------------------------------------------
701
+ # Kanban Board
702
+ # ---------------------------------------------------------------------------
703
+
704
+
705
+ def kanban_board(slug: str) -> dict:
706
+ """Return issues grouped by IssueStatus columns + blocked IDs.
707
+
708
+ Raises ValueError if product not found.
709
+ """
710
+ product = load_product(slug)
711
+ if not product:
712
+ raise ValueError(f"Product '{slug}' not found")
713
+
714
+ all_issues = list_issues(slug)
715
+ columns: dict[str, list[dict]] = {s.value: [] for s in IssueStatus}
716
+ blocked_ids: list[str] = []
717
+
718
+ for issue in all_issues:
719
+ status = issue.get("status", IssueStatus.BACKLOG.value)
720
+ if status in columns:
721
+ columns[status].append(issue)
722
+ if is_blocked(slug, issue["id"]):
723
+ blocked_ids.append(issue["id"])
724
+
725
+ return {"columns": columns, "blocked_ids": blocked_ids}
726
+
727
+
728
+ # ---------------------------------------------------------------------------
729
+ # Roadmap Timeline
730
+ # ---------------------------------------------------------------------------
731
+
732
+
733
+ def roadmap_timeline(slug: str) -> dict:
734
+ """Return sprints, versions, and milestoned issues for timeline display.
735
+
736
+ Raises ValueError if product not found.
737
+ """
738
+ product = load_product(slug)
739
+ if not product:
740
+ raise ValueError(f"Product '{slug}' not found")
741
+
742
+ sprints = list_sprints(slug)
743
+ sprint_summaries = []
744
+ for s in sprints:
745
+ issue_count = len(list_issues(slug, sprint=s["id"]))
746
+ sprint_summaries.append({
747
+ "id": s["id"],
748
+ "name": s["name"],
749
+ "start_date": s["start_date"],
750
+ "end_date": s["end_date"],
751
+ "status": s["status"],
752
+ "goal": s.get("goal", ""),
753
+ "issue_count": issue_count,
754
+ })
755
+
756
+ versions = list_versions(slug)
757
+ version_summaries = []
758
+ for v in versions:
759
+ version_summaries.append({
760
+ "version": v["version"],
761
+ "released_at": v["released_at"],
762
+ "resolved_count": len(v.get("resolved_issue_ids", [])),
763
+ "changelog": v.get("changelog", ""),
764
+ })
765
+
766
+ all_issues = list_issues(slug)
767
+ milestoned = [
768
+ {
769
+ "issue_id": i["id"],
770
+ "title": i["title"],
771
+ "priority": i.get("priority", "P2"),
772
+ "milestone_version": i["milestone_version"],
773
+ "status": i.get("status", "backlog"),
774
+ }
775
+ for i in all_issues
776
+ if i.get("milestone_version")
777
+ ]
778
+
779
+ return {
780
+ "sprints": sprint_summaries,
781
+ "versions": version_summaries,
782
+ "milestoned_issues": milestoned,
783
+ }
784
+
785
+
786
+ # ---------------------------------------------------------------------------
787
+ # Product Activity Log
788
+ # ---------------------------------------------------------------------------
789
+
790
+ _MAX_ACTIVITY_ENTRIES = 500
791
+
792
+
793
+ def _activity_dir(slug: str) -> Path:
794
+ return _product_dir(slug) / ACTIVITY_LOG_DIR_NAME
795
+
796
+
797
+ def append_product_activity(
798
+ slug: str,
799
+ *,
800
+ event_type: str,
801
+ actor: str,
802
+ detail: str,
803
+ ) -> None:
804
+ """Append an activity entry to the product's activity log."""
805
+ adir = _activity_dir(slug)
806
+ adir.mkdir(parents=True, exist_ok=True)
807
+ log_path = adir / "log.yaml"
808
+
809
+ entry = {
810
+ "ts": datetime.now().isoformat(),
811
+ "event_type": event_type,
812
+ "actor": actor,
813
+ "detail": detail,
814
+ }
815
+
816
+ with _get_slug_lock(slug):
817
+ log = _read_yaml(log_path) or []
818
+ if not isinstance(log, list):
819
+ log = []
820
+ log.append(entry)
821
+ if len(log) > _MAX_ACTIVITY_ENTRIES:
822
+ log = log[-_MAX_ACTIVITY_ENTRIES:]
823
+ _write_yaml(log_path, log)
824
+
825
+
826
+ def list_product_activity(slug: str, *, limit: int = 50) -> list[dict]:
827
+ """Return product activity entries, newest first."""
828
+ log_path = _activity_dir(slug) / "log.yaml"
829
+ log = _read_yaml(log_path)
830
+ if not log or not isinstance(log, list):
831
+ return []
832
+ # Newest first
833
+ log.reverse()
834
+ return log[:limit]
835
+
836
+
665
837
  # ---------------------------------------------------------------------------
666
838
  # Product Versioning
667
839
  # ---------------------------------------------------------------------------
@@ -701,6 +701,37 @@ async def handle_sprint_closed(event: CompanyEvent) -> None:
701
701
  logger.info("[PRODUCT_TRIGGER] Auto-created review for sprint {} in {}", sprint_id, slug)
702
702
 
703
703
 
704
+ def _log_product_activity(event: CompanyEvent) -> None:
705
+ """Log a product event to the product-scoped activity feed."""
706
+ slug = event.payload.get("product_slug", "")
707
+ if not slug:
708
+ return
709
+ detail = event.payload.get("detail", "")
710
+ if not detail:
711
+ # Build a default detail string from event type + payload
712
+ etype = event.type.value
713
+ title = event.payload.get("title", "")
714
+ issue_id = event.payload.get("issue_id", "")
715
+ sprint_id = event.payload.get("sprint_id", "")
716
+ if title:
717
+ detail = f"{etype}: {title}"
718
+ elif issue_id:
719
+ detail = f"{etype}: {issue_id}"
720
+ elif sprint_id:
721
+ detail = f"{etype}: {sprint_id}"
722
+ else:
723
+ detail = etype
724
+ try:
725
+ prod.append_product_activity(
726
+ slug,
727
+ event_type=event.type.value,
728
+ actor=event.agent,
729
+ detail=detail,
730
+ )
731
+ except Exception:
732
+ logger.debug("[PRODUCT_TRIGGER] Failed to log activity for {}", slug)
733
+
734
+
704
735
  # ---------------------------------------------------------------------------
705
736
  # Registration
706
737
  # ---------------------------------------------------------------------------
@@ -718,10 +749,27 @@ def register_product_triggers() -> "asyncio.Task":
718
749
 
719
750
  queue = event_bus.subscribe()
720
751
 
752
+ # Event types that should be auto-logged to product activity feed
753
+ _ACTIVITY_EVENT_TYPES = {
754
+ EventType.ISSUE_CREATED,
755
+ EventType.ISSUE_CLOSED,
756
+ EventType.ISSUE_ASSIGNED,
757
+ EventType.SPRINT_CREATED,
758
+ EventType.SPRINT_CLOSED,
759
+ EventType.VERSION_RELEASED,
760
+ EventType.REVIEW_CREATED,
761
+ EventType.REVIEW_COMPLETED,
762
+ EventType.KR_UPDATED,
763
+ }
764
+
721
765
  async def _dispatch_loop() -> None:
722
766
  while True:
723
767
  event = await queue.get()
724
768
  try:
769
+ # Auto-log product events to activity feed
770
+ if event.type in _ACTIVITY_EVENT_TYPES:
771
+ _log_product_activity(event)
772
+
725
773
  if event.type == EventType.ISSUE_CREATED:
726
774
  await handle_issue_created(event)
727
775
  elif event.type == EventType.ISSUE_ASSIGNED: