@1mancompany/onemancompany 0.7.30 → 0.7.32

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
@@ -7368,13 +7368,13 @@ class AppController {
7368
7368
  if (result.status === 'imported') {
7369
7369
  this.updateProjectsPanel();
7370
7370
  this._refreshProductSelector();
7371
- alert(`Imported "${bundle.product.name}" — ${result.issues_created} issues, ${result.krs_created} KRs. Auto-activated.`);
7371
+ this._showToast(`Imported "${bundle.product.name}" — ${result.issues_created} issues, ${result.krs_created} KRs`, 'success', 5000);
7372
7372
  } else {
7373
- alert('Import failed: ' + (result.detail || 'Unknown error'));
7373
+ this._showToast('Import failed: ' + (result.detail || 'Unknown error'), 'error');
7374
7374
  }
7375
7375
  } catch (err) {
7376
7376
  console.error('Import failed:', err);
7377
- alert('Import failed: ' + err.message);
7377
+ this._showToast('Import failed: ' + err.message, 'error');
7378
7378
  }
7379
7379
  });
7380
7380
  input.click();
@@ -7419,7 +7419,7 @@ class AppController {
7419
7419
  const ownerId = document.getElementById('create-product-owner')?.value || '';
7420
7420
 
7421
7421
  if (!name) {
7422
- alert('Product name is required');
7422
+ this._showToast('Product name is required', 'warning');
7423
7423
  return;
7424
7424
  }
7425
7425
 
@@ -7460,7 +7460,7 @@ class AppController {
7460
7460
  this._refreshProductSelector();
7461
7461
  } catch (e) {
7462
7462
  console.error('Failed to create product:', e);
7463
- alert('Failed to create product');
7463
+ this._showToast('Failed to create product', 'error');
7464
7464
  }
7465
7465
  }
7466
7466
 
@@ -7502,6 +7502,31 @@ class AppController {
7502
7502
  return card;
7503
7503
  }
7504
7504
 
7505
+ // ===== Toast Notifications =====
7506
+
7507
+ _showToast(message, type = 'info', duration = 3000) {
7508
+ let container = document.querySelector('.app-toast-container');
7509
+ if (!container) {
7510
+ container = document.createElement('div');
7511
+ container.className = 'app-toast-container';
7512
+ document.body.appendChild(container);
7513
+ }
7514
+ const toast = document.createElement('div');
7515
+ toast.className = `app-toast toast-${type}`;
7516
+ toast.textContent = message;
7517
+ toast.addEventListener('click', () => {
7518
+ toast.classList.add('toast-out');
7519
+ setTimeout(() => toast.remove(), 200);
7520
+ });
7521
+ container.appendChild(toast);
7522
+ setTimeout(() => {
7523
+ if (toast.parentNode) {
7524
+ toast.classList.add('toast-out');
7525
+ setTimeout(() => toast.remove(), 200);
7526
+ }
7527
+ }, duration);
7528
+ }
7529
+
7505
7530
  // ===== Product Detail Modal =====
7506
7531
 
7507
7532
  _openProductDetail(slug) {
@@ -7670,7 +7695,7 @@ class AppController {
7670
7695
  this._refreshProductSelector();
7671
7696
  } catch (err) {
7672
7697
  console.error('Delete failed:', err);
7673
- alert('Delete failed: ' + err.message);
7698
+ this._showToast('Delete failed: ' + err.message, 'error');
7674
7699
  }
7675
7700
  });
7676
7701
  header.appendChild(deleteBtn);
@@ -7994,6 +8019,14 @@ class AppController {
7994
8019
  priSel.addEventListener('change', () => renderFiltered());
7995
8020
  toolbar.appendChild(priSel);
7996
8021
 
8022
+ // Text search
8023
+ const searchInput = document.createElement('input');
8024
+ searchInput.type = 'text';
8025
+ searchInput.className = 'issue-search-input';
8026
+ searchInput.placeholder = 'Search...';
8027
+ searchInput.addEventListener('input', () => renderFiltered());
8028
+ toolbar.appendChild(searchInput);
8029
+
7997
8030
  container.appendChild(toolbar);
7998
8031
 
7999
8032
  const issueList = document.createElement('div');
@@ -8005,9 +8038,15 @@ class AppController {
8005
8038
  const renderFiltered = () => {
8006
8039
  const sf = statusSel.value;
8007
8040
  const pf = priSel.value;
8041
+ const q = (searchInput.value || '').toLowerCase().trim();
8008
8042
  let filtered = issues;
8009
8043
  if (sf) filtered = filtered.filter(i => i.status === sf);
8010
8044
  if (pf) filtered = filtered.filter(i => i.priority === pf);
8045
+ if (q) filtered = filtered.filter(i =>
8046
+ (i.title || '').toLowerCase().includes(q) ||
8047
+ (i.description || '').toLowerCase().includes(q) ||
8048
+ (i.labels || []).some(l => l.toLowerCase().includes(q))
8049
+ );
8011
8050
  filtered.sort((a, b) => {
8012
8051
  const aDone = a.status === 'done' || a.status === 'released';
8013
8052
  const bDone = b.status === 'done' || b.status === 'released';
@@ -8160,6 +8199,30 @@ class AppController {
8160
8199
  body.appendChild(histEl);
8161
8200
  }
8162
8201
 
8202
+ // Issue Links section
8203
+ this._renderIssueLinks(body, slug, issue, fullData);
8204
+
8205
+ // Delete button
8206
+ const deleteRow = document.createElement('div');
8207
+ deleteRow.style.marginTop = '8px';
8208
+ deleteRow.style.paddingTop = '6px';
8209
+ deleteRow.style.borderTop = '1px solid rgba(255,255,255,0.05)';
8210
+ const deleteBtn = document.createElement('button');
8211
+ deleteBtn.className = 'issue-delete-btn';
8212
+ deleteBtn.textContent = 'Delete Issue';
8213
+ deleteBtn.addEventListener('click', async (e) => {
8214
+ e.stopPropagation();
8215
+ if (!confirm(`Delete issue "${issue.title}"?`)) return;
8216
+ try {
8217
+ const r = await fetch(`/api/product/${encodeURIComponent(slug)}/issue/${encodeURIComponent(issue.id)}`, { method: 'DELETE' });
8218
+ if (!r.ok) { const err = await r.json(); throw new Error(err.detail || r.statusText); }
8219
+ this._showToast('Issue deleted', 'success');
8220
+ this._openProductDetail(slug);
8221
+ } catch (err) { this._showToast(`Delete failed: ${err.message}`, 'error'); }
8222
+ });
8223
+ deleteRow.appendChild(deleteBtn);
8224
+ body.appendChild(deleteRow);
8225
+
8163
8226
  card.appendChild(body);
8164
8227
 
8165
8228
  header.addEventListener('click', () => {
@@ -8169,6 +8232,129 @@ class AppController {
8169
8232
  return card;
8170
8233
  }
8171
8234
 
8235
+ _renderIssueLinks(container, slug, issue, fullData) {
8236
+ const section = document.createElement('div');
8237
+ section.className = 'issue-links-section';
8238
+
8239
+ const hdr = document.createElement('div');
8240
+ hdr.className = 'issue-links-header';
8241
+ const title = document.createElement('span');
8242
+ title.className = 'issue-links-title';
8243
+ title.textContent = 'Links';
8244
+ hdr.appendChild(title);
8245
+
8246
+ const addBtn = document.createElement('button');
8247
+ addBtn.className = 'btn-small';
8248
+ addBtn.textContent = '+';
8249
+ addBtn.style.padding = '0 4px';
8250
+ addBtn.addEventListener('click', (e) => {
8251
+ e.stopPropagation();
8252
+ this._showAddLinkForm(section, slug, issue, fullData);
8253
+ });
8254
+ hdr.appendChild(addBtn);
8255
+ section.appendChild(hdr);
8256
+
8257
+ const list = document.createElement('div');
8258
+ list.className = 'issue-links-list';
8259
+
8260
+ const links = issue.issue_links || [];
8261
+ if (links.length === 0) {
8262
+ const empty = document.createElement('div');
8263
+ empty.className = 'task-empty';
8264
+ empty.style.fontSize = 'calc(5px + var(--font-boost))';
8265
+ empty.textContent = 'No links';
8266
+ list.appendChild(empty);
8267
+ } else {
8268
+ for (const link of links) {
8269
+ const row = document.createElement('div');
8270
+ row.className = 'issue-link-row';
8271
+
8272
+ const typeEl = document.createElement('span');
8273
+ typeEl.className = 'issue-link-type';
8274
+ typeEl.textContent = link.relation;
8275
+ row.appendChild(typeEl);
8276
+
8277
+ const targetEl = document.createElement('span');
8278
+ targetEl.className = 'issue-link-target';
8279
+ const targetIssue = (fullData.issues || []).find(i => i.id === link.issue_id);
8280
+ targetEl.textContent = targetIssue ? targetIssue.title : link.issue_id;
8281
+ row.appendChild(targetEl);
8282
+
8283
+ const removeBtn = document.createElement('button');
8284
+ removeBtn.className = 'issue-link-remove';
8285
+ removeBtn.textContent = '\u00d7';
8286
+ removeBtn.title = 'Remove link';
8287
+ removeBtn.addEventListener('click', async (e) => {
8288
+ e.stopPropagation();
8289
+ try {
8290
+ const r = await fetch(`/api/product/${encodeURIComponent(slug)}/issue/${encodeURIComponent(issue.id)}/link/${encodeURIComponent(link.issue_id)}`, { method: 'DELETE' });
8291
+ if (!r.ok) { const err = await r.json(); throw new Error(err.detail || r.statusText); }
8292
+ this._showToast('Link removed', 'success');
8293
+ this._openProductDetail(slug);
8294
+ } catch (err) { this._showToast(`Remove link failed: ${err.message}`, 'error'); }
8295
+ });
8296
+ row.appendChild(removeBtn);
8297
+ list.appendChild(row);
8298
+ }
8299
+ }
8300
+
8301
+ section.appendChild(list);
8302
+ container.appendChild(section);
8303
+ }
8304
+
8305
+ _showAddLinkForm(container, slug, issue, fullData) {
8306
+ if (container.querySelector('.issue-link-add-row')) return;
8307
+ const row = document.createElement('div');
8308
+ row.className = 'issue-link-add-row';
8309
+
8310
+ const relSel = document.createElement('select');
8311
+ relSel.className = 'form-input';
8312
+ relSel.style.width = 'auto';
8313
+ relSel.innerHTML = '<option value="blocks">blocks</option><option value="blocked_by">blocked_by</option><option value="relates_to">relates_to</option>';
8314
+ row.appendChild(relSel);
8315
+
8316
+ const targetSel = document.createElement('select');
8317
+ targetSel.className = 'form-input';
8318
+ targetSel.style.width = 'auto';
8319
+ targetSel.innerHTML = '<option value="">Select issue...</option>';
8320
+ for (const i of (fullData.issues || [])) {
8321
+ if (i.id === issue.id) continue;
8322
+ const opt = document.createElement('option');
8323
+ opt.value = i.id;
8324
+ opt.textContent = `[${i.priority || 'P2'}] ${i.title}`;
8325
+ targetSel.appendChild(opt);
8326
+ }
8327
+ row.appendChild(targetSel);
8328
+
8329
+ const saveBtn = document.createElement('button');
8330
+ saveBtn.className = 'btn-small';
8331
+ saveBtn.textContent = 'Add';
8332
+ saveBtn.addEventListener('click', async (e) => {
8333
+ e.stopPropagation();
8334
+ if (!targetSel.value) return;
8335
+ try {
8336
+ const r = await fetch(`/api/product/${encodeURIComponent(slug)}/issue/${encodeURIComponent(issue.id)}/link`, {
8337
+ method: 'POST',
8338
+ headers: { 'Content-Type': 'application/json' },
8339
+ body: JSON.stringify({ target_id: targetSel.value, relation: relSel.value }),
8340
+ });
8341
+ if (!r.ok) { const err = await r.json(); throw new Error(err.detail || r.statusText); }
8342
+ this._showToast('Link added', 'success');
8343
+ this._openProductDetail(slug);
8344
+ } catch (err) { this._showToast(`Add link failed: ${err.message}`, 'error'); }
8345
+ });
8346
+ row.appendChild(saveBtn);
8347
+
8348
+ const cancelBtn = document.createElement('button');
8349
+ cancelBtn.className = 'kr-remove-btn';
8350
+ cancelBtn.textContent = '\u00d7';
8351
+ cancelBtn.addEventListener('click', (e) => { e.stopPropagation(); row.remove(); });
8352
+ row.appendChild(cancelBtn);
8353
+
8354
+ container.appendChild(row);
8355
+ targetSel.focus();
8356
+ }
8357
+
8172
8358
  _makeIssueFieldEditable(el, slug, issueId, fieldName, fullData) {
8173
8359
  el.style.cursor = 'pointer';
8174
8360
  el.title = 'Click to edit';
@@ -8374,29 +8560,94 @@ class AppController {
8374
8560
  .then(data => {
8375
8561
  container.innerHTML = '';
8376
8562
 
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) {
8563
+ // Sprint section (always visible has create button)
8564
+ {
8384
8565
  const section = document.createElement('div');
8385
8566
  section.className = 'roadmap-section';
8567
+ const hdr = document.createElement('div');
8568
+ hdr.style.display = 'flex';
8569
+ hdr.style.alignItems = 'center';
8570
+ hdr.style.justifyContent = 'space-between';
8386
8571
  const h = document.createElement('h3');
8387
8572
  h.textContent = 'Sprints';
8388
- section.appendChild(h);
8573
+ h.style.margin = '0';
8574
+ hdr.appendChild(h);
8575
+ const newBtn = document.createElement('button');
8576
+ newBtn.className = 'btn-small';
8577
+ newBtn.textContent = '+ New Sprint';
8578
+ newBtn.addEventListener('click', () => this._showNewSprintForm(section, slug));
8579
+ hdr.appendChild(newBtn);
8580
+ section.appendChild(hdr);
8389
8581
 
8390
8582
  const timeline = document.createElement('div');
8391
8583
  timeline.className = 'roadmap-timeline';
8392
8584
 
8585
+ if (data.sprints.length === 0) {
8586
+ const empty = document.createElement('div');
8587
+ empty.className = 'task-empty';
8588
+ empty.textContent = 'No sprints yet. Click "+ New Sprint" to plan your first sprint.';
8589
+ timeline.appendChild(empty);
8590
+ }
8591
+
8393
8592
  for (const s of data.sprints) {
8394
8593
  const bar = document.createElement('div');
8395
8594
  bar.className = `roadmap-sprint-bar roadmap-status-${s.status}`;
8396
8595
 
8596
+ const topRow = document.createElement('div');
8597
+ topRow.style.display = 'flex';
8598
+ topRow.style.alignItems = 'center';
8599
+ topRow.style.justifyContent = 'space-between';
8600
+
8397
8601
  const label = document.createElement('div');
8398
8602
  label.className = 'roadmap-bar-label';
8399
8603
  label.textContent = `${s.name} (${s.issue_count} issues)`;
8604
+ topRow.appendChild(label);
8605
+
8606
+ // Sprint action buttons
8607
+ const actions = document.createElement('div');
8608
+ actions.className = 'sprint-actions';
8609
+ if (s.status === 'planning') {
8610
+ const startBtn = document.createElement('button');
8611
+ startBtn.className = 'sprint-action-btn';
8612
+ startBtn.textContent = 'Start';
8613
+ startBtn.addEventListener('click', async () => {
8614
+ try {
8615
+ const r = await fetch(`/api/product/${encodeURIComponent(slug)}/sprint/${encodeURIComponent(s.id)}/start`, { method: 'POST' });
8616
+ if (!r.ok) { const err = await r.json(); throw new Error(err.detail || r.statusText); }
8617
+ this._showToast('Sprint started', 'success');
8618
+ this._renderProductRoadmap(slug, container);
8619
+ } catch (err) { this._showToast(`Start failed: ${err.message}`, 'error'); }
8620
+ });
8621
+ actions.appendChild(startBtn);
8622
+ const delBtn = document.createElement('button');
8623
+ delBtn.className = 'sprint-action-btn danger';
8624
+ delBtn.textContent = 'Delete';
8625
+ delBtn.addEventListener('click', async () => {
8626
+ if (!confirm(`Delete sprint "${s.name}"?`)) return;
8627
+ try {
8628
+ const r = await fetch(`/api/product/${encodeURIComponent(slug)}/sprint/${encodeURIComponent(s.id)}`, { method: 'DELETE' });
8629
+ if (!r.ok) { const err = await r.json(); throw new Error(err.detail || r.statusText); }
8630
+ this._showToast('Sprint deleted', 'success');
8631
+ this._renderProductRoadmap(slug, container);
8632
+ } catch (err) { this._showToast(`Delete failed: ${err.message}`, 'error'); }
8633
+ });
8634
+ actions.appendChild(delBtn);
8635
+ } else if (s.status === 'active') {
8636
+ const closeBtn = document.createElement('button');
8637
+ closeBtn.className = 'sprint-action-btn';
8638
+ closeBtn.textContent = 'Close';
8639
+ closeBtn.addEventListener('click', async () => {
8640
+ try {
8641
+ const r = await fetch(`/api/product/${encodeURIComponent(slug)}/sprint/${encodeURIComponent(s.id)}/close`, { method: 'POST' });
8642
+ if (!r.ok) { const err = await r.json(); throw new Error(err.detail || r.statusText); }
8643
+ this._showToast('Sprint closed', 'success');
8644
+ this._renderProductRoadmap(slug, container);
8645
+ } catch (err) { this._showToast(`Close failed: ${err.message}`, 'error'); }
8646
+ });
8647
+ actions.appendChild(closeBtn);
8648
+ }
8649
+ topRow.appendChild(actions);
8650
+ bar.appendChild(topRow);
8400
8651
 
8401
8652
  const dates = document.createElement('div');
8402
8653
  dates.className = 'roadmap-bar-dates';
@@ -8406,7 +8657,6 @@ class AppController {
8406
8657
  statusBadge.className = `roadmap-status-badge roadmap-status-${s.status}`;
8407
8658
  statusBadge.textContent = s.status;
8408
8659
 
8409
- bar.appendChild(label);
8410
8660
  bar.appendChild(dates);
8411
8661
  bar.appendChild(statusBadge);
8412
8662
  if (s.goal) {
@@ -8493,6 +8743,59 @@ class AppController {
8493
8743
  .catch(err => { container.innerHTML = `<div class="error-text">Failed to load roadmap: ${err.message}</div>`; });
8494
8744
  }
8495
8745
 
8746
+ _showNewSprintForm(section, slug) {
8747
+ if (section.querySelector('.sprint-inline-add')) return;
8748
+ const form = document.createElement('div');
8749
+ form.className = 'sprint-inline-add';
8750
+ form.innerHTML = `
8751
+ <input type="text" class="form-input sprint-new-name" placeholder="Sprint name" />
8752
+ <input type="text" class="form-input sprint-new-goal" placeholder="Goal (optional)" />
8753
+ <div class="sprint-form-row">
8754
+ <label style="color:var(--text-dim);font-size:calc(5px + var(--font-boost))">Start:</label>
8755
+ <input type="date" class="form-input sprint-new-start" style="width:auto" />
8756
+ <label style="color:var(--text-dim);font-size:calc(5px + var(--font-boost))">End:</label>
8757
+ <input type="date" class="form-input sprint-new-end" style="width:auto" />
8758
+ </div>
8759
+ <div class="sprint-form-row">
8760
+ <input type="number" class="form-input sprint-new-capacity" placeholder="Capacity (pts)" style="width:80px" />
8761
+ <button class="btn-small sprint-save-btn">Create</button>
8762
+ <button class="kr-remove-btn sprint-cancel-btn">&times;</button>
8763
+ </div>
8764
+ `;
8765
+ // Default dates: today → +14 days
8766
+ const today = new Date();
8767
+ const end = new Date(today);
8768
+ end.setDate(end.getDate() + 14);
8769
+ form.querySelector('.sprint-new-start').value = today.toISOString().split('T')[0];
8770
+ form.querySelector('.sprint-new-end').value = end.toISOString().split('T')[0];
8771
+
8772
+ form.querySelector('.sprint-cancel-btn').addEventListener('click', () => form.remove());
8773
+ form.querySelector('.sprint-save-btn').addEventListener('click', async () => {
8774
+ const name = form.querySelector('.sprint-new-name').value.trim();
8775
+ if (!name) { this._showToast('Sprint name is required', 'warning'); return; }
8776
+ const start_date = form.querySelector('.sprint-new-start').value;
8777
+ const end_date = form.querySelector('.sprint-new-end').value;
8778
+ if (!start_date || !end_date) { this._showToast('Dates are required', 'warning'); return; }
8779
+ const goal = form.querySelector('.sprint-new-goal').value.trim();
8780
+ const capacity = parseInt(form.querySelector('.sprint-new-capacity').value) || null;
8781
+ try {
8782
+ const r = await fetch(`/api/product/${encodeURIComponent(slug)}/sprint`, {
8783
+ method: 'POST',
8784
+ headers: { 'Content-Type': 'application/json' },
8785
+ body: JSON.stringify({ name, start_date, end_date, goal, capacity }),
8786
+ });
8787
+ if (!r.ok) { const err = await r.json(); throw new Error(err.detail || r.statusText); }
8788
+ this._showToast('Sprint created', 'success');
8789
+ this._openProductDetail(slug);
8790
+ } catch (err) { this._showToast(`Create failed: ${err.message}`, 'error'); }
8791
+ });
8792
+
8793
+ // Insert after the header
8794
+ const timeline = section.querySelector('.roadmap-timeline');
8795
+ section.insertBefore(form, timeline);
8796
+ form.querySelector('.sprint-new-name').focus();
8797
+ }
8798
+
8496
8799
  // ---------------------------------------------------------------------------
8497
8800
  // Activity Feed Tab
8498
8801
  // ---------------------------------------------------------------------------
@@ -6539,3 +6539,250 @@ body.resize-dragging {
6539
6539
  0%, 100% { opacity: 1; }
6540
6540
  50% { opacity: 0.5; }
6541
6541
  }
6542
+
6543
+ /* ================================================================
6544
+ B3: Toast Notifications
6545
+ ================================================================ */
6546
+
6547
+ .app-toast-container {
6548
+ position: fixed;
6549
+ top: 12px;
6550
+ right: 12px;
6551
+ z-index: 99999;
6552
+ display: flex;
6553
+ flex-direction: column;
6554
+ gap: 6px;
6555
+ pointer-events: none;
6556
+ }
6557
+
6558
+ .app-toast {
6559
+ background: var(--bg-panel);
6560
+ border: 1px solid var(--border);
6561
+ border-left: 3px solid var(--pixel-cyan);
6562
+ border-radius: 2px;
6563
+ padding: 8px 12px;
6564
+ font-family: var(--font-pixel);
6565
+ font-size: calc(6px + var(--font-boost));
6566
+ color: var(--pixel-white);
6567
+ max-width: 320px;
6568
+ pointer-events: auto;
6569
+ animation: toast-in 0.2s ease-out;
6570
+ cursor: pointer;
6571
+ }
6572
+
6573
+ .app-toast.toast-error {
6574
+ border-left-color: #ff4444;
6575
+ }
6576
+
6577
+ .app-toast.toast-success {
6578
+ border-left-color: var(--pixel-green);
6579
+ }
6580
+
6581
+ .app-toast.toast-warning {
6582
+ border-left-color: var(--pixel-yellow);
6583
+ }
6584
+
6585
+ .app-toast.toast-out {
6586
+ animation: toast-out 0.2s ease-in forwards;
6587
+ }
6588
+
6589
+ @keyframes toast-in {
6590
+ from { opacity: 0; transform: translateX(40px); }
6591
+ to { opacity: 1; transform: translateX(0); }
6592
+ }
6593
+
6594
+ @keyframes toast-out {
6595
+ from { opacity: 1; transform: translateX(0); }
6596
+ to { opacity: 0; transform: translateX(40px); }
6597
+ }
6598
+
6599
+ /* ================================================================
6600
+ B3: Issue Links UI
6601
+ ================================================================ */
6602
+
6603
+ .issue-links-section {
6604
+ margin-top: 8px;
6605
+ padding-top: 6px;
6606
+ border-top: 1px solid rgba(255,255,255,0.05);
6607
+ }
6608
+
6609
+ .issue-links-header {
6610
+ display: flex;
6611
+ align-items: center;
6612
+ gap: 6px;
6613
+ margin-bottom: 4px;
6614
+ }
6615
+
6616
+ .issue-links-title {
6617
+ color: var(--pixel-cyan);
6618
+ font-size: calc(5px + var(--font-boost));
6619
+ font-weight: bold;
6620
+ }
6621
+
6622
+ .issue-links-list {
6623
+ display: flex;
6624
+ flex-direction: column;
6625
+ gap: 2px;
6626
+ }
6627
+
6628
+ .issue-link-row {
6629
+ display: flex;
6630
+ align-items: center;
6631
+ gap: 6px;
6632
+ padding: 2px 4px;
6633
+ font-size: calc(5px + var(--font-boost));
6634
+ border-radius: 2px;
6635
+ }
6636
+
6637
+ .issue-link-row:hover {
6638
+ background: rgba(255,255,255,0.03);
6639
+ }
6640
+
6641
+ .issue-link-type {
6642
+ color: var(--text-dim);
6643
+ min-width: 70px;
6644
+ font-size: calc(4px + var(--font-boost));
6645
+ }
6646
+
6647
+ .issue-link-target {
6648
+ color: var(--pixel-white);
6649
+ flex: 1;
6650
+ }
6651
+
6652
+ .issue-link-remove {
6653
+ background: transparent;
6654
+ border: none;
6655
+ color: var(--text-dim);
6656
+ cursor: pointer;
6657
+ font-size: calc(6px + var(--font-boost));
6658
+ padding: 0 2px;
6659
+ font-family: var(--font-pixel);
6660
+ }
6661
+
6662
+ .issue-link-remove:hover {
6663
+ color: #ff4444;
6664
+ }
6665
+
6666
+ .issue-link-add-row {
6667
+ display: flex;
6668
+ gap: 4px;
6669
+ align-items: center;
6670
+ margin-top: 4px;
6671
+ }
6672
+
6673
+ .issue-link-add-row select,
6674
+ .issue-link-add-row input {
6675
+ font-size: calc(5px + var(--font-boost));
6676
+ }
6677
+
6678
+ /* ================================================================
6679
+ B3: Sprint Inline Form
6680
+ ================================================================ */
6681
+
6682
+ .sprint-inline-add {
6683
+ padding: 8px;
6684
+ background: rgba(0,255,255,0.03);
6685
+ border: 1px solid var(--border);
6686
+ border-radius: 2px;
6687
+ margin-bottom: 8px;
6688
+ }
6689
+
6690
+ .sprint-inline-add .form-input {
6691
+ margin-bottom: 4px;
6692
+ }
6693
+
6694
+ .sprint-form-row {
6695
+ display: flex;
6696
+ gap: 4px;
6697
+ align-items: center;
6698
+ }
6699
+
6700
+ .sprint-actions {
6701
+ display: flex;
6702
+ gap: 4px;
6703
+ margin-top: 4px;
6704
+ }
6705
+
6706
+ .sprint-action-btn {
6707
+ background: transparent;
6708
+ border: 1px solid var(--border);
6709
+ color: var(--text-dim);
6710
+ font-family: var(--font-pixel);
6711
+ font-size: calc(5px + var(--font-boost));
6712
+ padding: 1px 6px;
6713
+ cursor: pointer;
6714
+ border-radius: 2px;
6715
+ }
6716
+
6717
+ .sprint-action-btn:hover {
6718
+ border-color: var(--pixel-cyan);
6719
+ color: var(--pixel-cyan);
6720
+ }
6721
+
6722
+ .sprint-action-btn.danger:hover {
6723
+ border-color: #ff4444;
6724
+ color: #ff4444;
6725
+ }
6726
+
6727
+ /* ================================================================
6728
+ B3: Search Input
6729
+ ================================================================ */
6730
+
6731
+ .issue-search-input {
6732
+ background: var(--bg-dark);
6733
+ border: 1px solid var(--border);
6734
+ color: var(--pixel-white);
6735
+ font-family: var(--font-pixel);
6736
+ font-size: calc(5px + var(--font-boost));
6737
+ padding: 2px 6px;
6738
+ border-radius: 2px;
6739
+ width: 120px;
6740
+ }
6741
+
6742
+ .issue-search-input::placeholder {
6743
+ color: var(--text-dim);
6744
+ }
6745
+
6746
+ .issue-search-input:focus {
6747
+ border-color: var(--pixel-cyan);
6748
+ outline: none;
6749
+ }
6750
+
6751
+ /* ================================================================
6752
+ B3: Tab Overflow
6753
+ ================================================================ */
6754
+
6755
+ .project-tabs {
6756
+ overflow-x: auto;
6757
+ flex-wrap: nowrap;
6758
+ scrollbar-width: none;
6759
+ }
6760
+
6761
+ .project-tabs::-webkit-scrollbar {
6762
+ display: none;
6763
+ }
6764
+
6765
+ .project-tab {
6766
+ white-space: nowrap;
6767
+ flex-shrink: 0;
6768
+ }
6769
+
6770
+ /* ================================================================
6771
+ B3: Delete Issue Button
6772
+ ================================================================ */
6773
+
6774
+ .issue-delete-btn {
6775
+ background: transparent;
6776
+ border: 1px solid var(--border);
6777
+ color: var(--text-dim);
6778
+ font-family: var(--font-pixel);
6779
+ font-size: calc(5px + var(--font-boost));
6780
+ padding: 1px 6px;
6781
+ cursor: pointer;
6782
+ border-radius: 2px;
6783
+ }
6784
+
6785
+ .issue-delete-btn:hover {
6786
+ border-color: #ff4444;
6787
+ color: #ff4444;
6788
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@1mancompany/onemancompany",
3
- "version": "0.7.30",
3
+ "version": "0.7.32",
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.30"
3
+ version = "0.7.32"
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 = [
@@ -562,6 +562,135 @@ async def manage_review_tool(
562
562
  return f"Error: {e}"
563
563
 
564
564
 
565
+ # ---------------------------------------------------------------------------
566
+ # B2: Missing CRUD + analytics tools
567
+ # ---------------------------------------------------------------------------
568
+
569
+
570
+ @tool
571
+ def delete_issue_tool(product_slug: str, issue_id: str) -> str:
572
+ """Delete an issue and clean up all links referencing it.
573
+
574
+ Args:
575
+ product_slug: The product slug
576
+ issue_id: The issue ID to delete
577
+ """
578
+ try:
579
+ prod.delete_issue(product_slug, issue_id)
580
+ return f"Deleted issue {issue_id} from {product_slug}"
581
+ except (ValueError, FileNotFoundError) as e:
582
+ return f"Error: {e}"
583
+
584
+
585
+ @tool
586
+ def reopen_issue_tool(product_slug: str, issue_id: str) -> str:
587
+ """Reopen a closed issue (moves it back to backlog).
588
+
589
+ Args:
590
+ product_slug: The product slug
591
+ issue_id: The issue ID to reopen
592
+ """
593
+ try:
594
+ issue = prod.reopen_issue(product_slug, issue_id)
595
+ return f"Reopened issue {issue_id}: status={issue['status']}"
596
+ except (ValueError, FileNotFoundError) as e:
597
+ return f"Error: {e}"
598
+
599
+
600
+ @tool
601
+ def start_sprint_tool(product_slug: str, sprint_id: str) -> str:
602
+ """Start a sprint (set it to active). Only one sprint can be active at a time.
603
+
604
+ Args:
605
+ product_slug: The product slug
606
+ sprint_id: The sprint ID to start
607
+ """
608
+ try:
609
+ sprint = prod.start_sprint(product_slug, sprint_id)
610
+ return f"Started sprint {sprint_id}: {sprint['name']}"
611
+ except (ValueError, FileNotFoundError) as e:
612
+ return f"Error: {e}"
613
+
614
+
615
+ @tool
616
+ def delete_sprint_tool(product_slug: str, sprint_id: str) -> str:
617
+ """Delete a sprint. Cannot delete an active sprint — close it first.
618
+
619
+ Args:
620
+ product_slug: The product slug
621
+ sprint_id: The sprint ID to delete
622
+ """
623
+ try:
624
+ prod.delete_sprint(product_slug, sprint_id)
625
+ return f"Deleted sprint {sprint_id} from {product_slug}"
626
+ except (ValueError, FileNotFoundError) as e:
627
+ return f"Error: {e}"
628
+
629
+
630
+ @tool
631
+ def sprint_analytics_tool(product_slug: str, sprint_id: str) -> str:
632
+ """Get sprint analytics: velocity (story points completed).
633
+
634
+ Args:
635
+ product_slug: The product slug
636
+ sprint_id: The sprint ID
637
+ """
638
+ try:
639
+ sprint = prod.load_sprint(product_slug, sprint_id)
640
+ if not sprint:
641
+ return f"Error: Sprint '{sprint_id}' not found"
642
+ velocity = prod.get_sprint_velocity(product_slug, sprint_id)
643
+ issues = prod.list_issues(product_slug, sprint=sprint_id)
644
+ done = sum(1 for i in issues if i.get("status") in ("done", "released"))
645
+ total = len(issues)
646
+ lines = [
647
+ f"Sprint: {sprint['name']} ({sprint['status']})",
648
+ f"Velocity: {velocity} story points",
649
+ f"Issues: {done}/{total} done",
650
+ f"Dates: {sprint['start_date']} → {sprint['end_date']}",
651
+ ]
652
+ if sprint.get("goal"):
653
+ lines.append(f"Goal: {sprint['goal']}")
654
+ return "\n".join(lines)
655
+ except (ValueError, FileNotFoundError) as e:
656
+ return f"Error: {e}"
657
+
658
+
659
+ @tool
660
+ def version_management_tool(
661
+ product_slug: str,
662
+ action: str,
663
+ resolved_issue_ids: str = "",
664
+ bump: str = "patch",
665
+ ) -> str:
666
+ """Manage product versions. Actions: list, release.
667
+
668
+ Args:
669
+ product_slug: The product slug
670
+ action: 'list' to list versions, 'release' to release a new version
671
+ resolved_issue_ids: Comma-separated issue IDs resolved in this release (for 'release')
672
+ bump: Version bump type: 'patch', 'minor', or 'major' (default: 'patch')
673
+ """
674
+ try:
675
+ if action == "list":
676
+ versions = prod.list_versions(product_slug)
677
+ if not versions:
678
+ return f"No versions released for {product_slug}"
679
+ lines = [f"Versions for {product_slug}:"]
680
+ for v in versions:
681
+ lines.append(f" {v['version']} — released {v.get('released_at', '?')}")
682
+ return "\n".join(lines)
683
+
684
+ if action == "release":
685
+ ids = [i.strip() for i in resolved_issue_ids.split(",") if i.strip()]
686
+ version = prod.release_version(product_slug, ids, bump=bump)
687
+ return f"Released v{version['version']} with {len(ids)} resolved issues"
688
+
689
+ return f"Error: unknown action '{action}'. Use: list, release"
690
+ except (ValueError, FileNotFoundError) as e:
691
+ return f"Error: {e}"
692
+
693
+
565
694
  # ---------------------------------------------------------------------------
566
695
  # Export
567
696
  # ---------------------------------------------------------------------------
@@ -581,4 +710,10 @@ PRODUCT_TOOLS = [
581
710
  unlink_issues_tool,
582
711
  check_blocked_issues_tool,
583
712
  manage_review_tool,
713
+ delete_issue_tool,
714
+ reopen_issue_tool,
715
+ start_sprint_tool,
716
+ delete_sprint_tool,
717
+ sprint_analytics_tool,
718
+ version_management_tool,
584
719
  ]
@@ -7294,6 +7294,18 @@ async def api_reopen_issue(slug: str, issue_id: str) -> dict:
7294
7294
  return result
7295
7295
 
7296
7296
 
7297
+ @router.delete("/api/product/{slug}/issue/{issue_id}")
7298
+ async def api_delete_issue(slug: str, issue_id: str) -> dict:
7299
+ """Delete an issue and clean up all links."""
7300
+ from onemancompany.core import product as prod
7301
+
7302
+ try:
7303
+ prod.delete_issue(slug, issue_id)
7304
+ except ValueError as exc:
7305
+ raise HTTPException(status_code=404, detail=str(exc))
7306
+ return {"ok": True}
7307
+
7308
+
7297
7309
  # ── Versions ────────────────────────────────────────────────────────────────
7298
7310
 
7299
7311
 
@@ -7522,6 +7534,30 @@ async def api_close_sprint(slug: str, sprint_id: str) -> dict:
7522
7534
  return result
7523
7535
 
7524
7536
 
7537
+ @router.post("/api/product/{slug}/sprint/{sprint_id}/start")
7538
+ async def api_start_sprint(slug: str, sprint_id: str) -> dict:
7539
+ """Start a sprint (set to active). Only one sprint can be active at a time."""
7540
+ from onemancompany.core import product as prod
7541
+
7542
+ try:
7543
+ result = prod.start_sprint(slug, sprint_id)
7544
+ except ValueError as exc:
7545
+ raise HTTPException(status_code=400, detail=str(exc))
7546
+ return result
7547
+
7548
+
7549
+ @router.delete("/api/product/{slug}/sprint/{sprint_id}")
7550
+ async def api_delete_sprint(slug: str, sprint_id: str) -> dict:
7551
+ """Delete a sprint. Cannot delete active sprints."""
7552
+ from onemancompany.core import product as prod
7553
+
7554
+ try:
7555
+ prod.delete_sprint(slug, sprint_id)
7556
+ except ValueError as exc:
7557
+ raise HTTPException(status_code=400, detail=str(exc))
7558
+ return {"ok": True}
7559
+
7560
+
7525
7561
  @router.get("/api/product/{slug}/sprint/suggest-capacity")
7526
7562
  async def api_suggest_sprint_capacity(slug: str) -> dict:
7527
7563
  """Suggest sprint capacity based on historical velocity (sliding average of last 3)."""
@@ -389,6 +389,12 @@ def update_issue(slug: str, issue_id: str, **fields) -> dict:
389
389
  data = _read_yaml(path)
390
390
  if not data:
391
391
  raise ValueError(f"Issue '{issue_id}' not found in product '{slug}'")
392
+ # Validate status transition if status is being changed
393
+ new_status = fields.get("status")
394
+ if new_status is not None:
395
+ current_status = data.get("status", IssueStatus.BACKLOG.value)
396
+ if new_status != current_status:
397
+ _validate_status_transition(current_status, new_status)
392
398
  for key, value in fields.items():
393
399
  if value is not None:
394
400
  old_value = data.get(key)
@@ -442,6 +448,53 @@ def reopen_issue(slug: str, issue_id: str) -> dict:
442
448
  return data
443
449
 
444
450
 
451
+ def delete_issue(slug: str, issue_id: str) -> None:
452
+ """Delete an issue and clean up all links referencing it. Raises ValueError if not found."""
453
+ issue = load_issue(slug, issue_id)
454
+ if not issue:
455
+ raise ValueError(f"Issue '{issue_id}' not found in product '{slug}'")
456
+
457
+ # Clean up links from other issues pointing to this one
458
+ all_issues = list_issues(slug)
459
+ for other in all_issues:
460
+ if other["id"] == issue_id:
461
+ continue
462
+ links = other.get("issue_links", [])
463
+ if any(l["issue_id"] == issue_id for l in links):
464
+ _remove_link_entry(slug, other["id"], issue_id)
465
+
466
+ # Remove the issue file
467
+ with _get_slug_lock(slug):
468
+ path = _issues_dir(slug) / f"{issue_id}.yaml"
469
+ path.unlink(missing_ok=True)
470
+ mark_dirty(DirtyCategory.PRODUCTS)
471
+ logger.debug("Deleted issue {} from {}", issue_id, slug)
472
+
473
+
474
+ # ---------------------------------------------------------------------------
475
+ # Issue Status Transitions
476
+ # ---------------------------------------------------------------------------
477
+
478
+ _VALID_TRANSITIONS: dict[str, set[str]] = {
479
+ IssueStatus.BACKLOG.value: {IssueStatus.PLANNED.value, IssueStatus.IN_PROGRESS.value},
480
+ IssueStatus.PLANNED.value: {IssueStatus.IN_PROGRESS.value, IssueStatus.BACKLOG.value},
481
+ IssueStatus.IN_PROGRESS.value: {IssueStatus.IN_REVIEW.value, IssueStatus.DONE.value, IssueStatus.BACKLOG.value},
482
+ IssueStatus.IN_REVIEW.value: {IssueStatus.DONE.value, IssueStatus.IN_PROGRESS.value, IssueStatus.BACKLOG.value},
483
+ IssueStatus.DONE.value: {IssueStatus.RELEASED.value, IssueStatus.BACKLOG.value},
484
+ IssueStatus.RELEASED.value: {IssueStatus.BACKLOG.value},
485
+ }
486
+
487
+
488
+ def _validate_status_transition(current: str, target: str) -> None:
489
+ """Raise ValueError if the status transition is not allowed."""
490
+ allowed = _VALID_TRANSITIONS.get(current, set())
491
+ if target not in allowed:
492
+ raise ValueError(
493
+ f"Invalid transition: '{current}' → '{target}'. "
494
+ f"Allowed: {sorted(allowed)}"
495
+ )
496
+
497
+
445
498
  # ---------------------------------------------------------------------------
446
499
  # Issue Links
447
500
  # ---------------------------------------------------------------------------
@@ -1249,6 +1302,17 @@ def create_sprint(
1249
1302
  if not product:
1250
1303
  raise ValueError(f"Product '{slug}' not found")
1251
1304
 
1305
+ # Validate dates
1306
+ try:
1307
+ sd = datetime.strptime(start_date, "%Y-%m-%d")
1308
+ ed = datetime.strptime(end_date, "%Y-%m-%d")
1309
+ except ValueError as exc:
1310
+ raise ValueError(f"Invalid date format: {exc}") from exc
1311
+ if ed <= sd:
1312
+ raise ValueError(
1313
+ f"End date '{end_date}' must be after start date '{start_date}'"
1314
+ )
1315
+
1252
1316
  sprint_id = _gen_id("sprint_")
1253
1317
  now = datetime.now().isoformat()
1254
1318
 
@@ -1320,6 +1384,25 @@ def update_sprint(slug: str, sprint_id: str, **fields) -> dict:
1320
1384
  return sprint
1321
1385
 
1322
1386
 
1387
+ def start_sprint(slug: str, sprint_id: str) -> dict:
1388
+ """Start a sprint (set status to active). Raises ValueError if already active elsewhere."""
1389
+ return update_sprint(slug, sprint_id, status=SprintStatus.ACTIVE.value)
1390
+
1391
+
1392
+ def delete_sprint(slug: str, sprint_id: str) -> None:
1393
+ """Delete a sprint. Cannot delete an active sprint. Raises ValueError if not found."""
1394
+ sprint = load_sprint(slug, sprint_id)
1395
+ if not sprint:
1396
+ raise ValueError(f"Sprint '{sprint_id}' not found in '{slug}'")
1397
+ if sprint.get("status") == SprintStatus.ACTIVE.value:
1398
+ raise ValueError(f"Cannot delete active sprint '{sprint_id}'. Close it first.")
1399
+ with _get_slug_lock(slug):
1400
+ path = _sprints_dir(slug) / f"{sprint_id}.yaml"
1401
+ path.unlink(missing_ok=True)
1402
+ mark_dirty(DirtyCategory.PRODUCTS)
1403
+ logger.debug("Deleted sprint {} from {}", sprint_id, slug)
1404
+
1405
+
1323
1406
  def get_active_sprint(slug: str) -> dict | None:
1324
1407
  """Return the current active sprint for a product, or None."""
1325
1408
  active = list_sprints(slug, status=SprintStatus.ACTIVE.value)