Update project files

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
ANDREW HOSFORD
2026-04-07 11:59:48 -05:00
parent dd227be9b0
commit 0e8978f48c
50 changed files with 5878 additions and 241 deletions

View File

@@ -62,7 +62,8 @@
"serve": {
"builder": "@angular-devkit/build-angular:dev-server",
"options": {
"allowedHosts": ["to-the-n8nth-degree.andrewawesomo.net"]
"allowedHosts": ["to-the-n8nth-degree.andrewawesomo.net", "local-req-planner.andrewawesomo.net"],
"proxyConfig": "proxy.conf.json"
},
"configurations": {
"production": {

View File

@@ -9,6 +9,67 @@
<a routerLink="/dashboard" routerLinkActive="active">Dashboard</a>
</div>
<div class="nav-spacer"></div>
<div class="db-switcher">
<button class="db-trigger" (click)="toggleMenu()" title="Switch database">
<span class="db-icon">DB</span>
<span class="db-name">{{ activeDbName }}</span>
</button>
@if (showDbMenu) {
<div class="db-menu">
<div class="db-menu-header">Databases</div>
@for (db of databases; track db.path) {
<button
class="db-item"
[class.active]="db.active"
(click)="switchDb(db)"
>
<span class="db-item-name">{{ db.name }}</span>
<span class="db-item-size">{{ formatSize(db.size_bytes) }}</span>
@if (db.active) {
<span class="db-item-badge">active</span>
}
</button>
}
<div class="db-menu-divider"></div>
<!-- Upload a .db file from your computer -->
<label class="db-item db-action">
<input
type="file"
accept=".db"
hidden
(change)="onFileSelected($event)"
/>
@if (uploading) {
Uploading...
} @else {
Upload .db file...
}
</label>
<!-- Download current active DB -->
<button class="db-item db-action" (click)="downloadDb()">
Download current DB
</button>
<div class="db-menu-divider"></div>
<!-- Create a new empty DB -->
@if (showNewDbInput) {
<div class="db-new-form">
<input
type="text"
[(ngModel)]="newDbName"
placeholder="new-database-name"
(keydown.enter)="createDb()"
(keydown.escape)="showNewDbInput = false"
/>
<button class="db-new-btn" (click)="createDb()">Create</button>
</div>
} @else {
<button class="db-item db-action" (click)="showNewDbInput = true">
+ New database
</button>
}
</div>
}
</div>
</nav>
<main class="main-content">
<router-outlet />

View File

@@ -74,3 +74,164 @@
overflow: hidden;
position: relative;
}
// ─── Database Switcher ─────────────────────────────────────────
.db-switcher {
position: relative;
}
.db-trigger {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg-tertiary);
color: var(--text-secondary);
font-size: 12px;
cursor: pointer;
transition: all 0.15s ease;
&:hover {
color: var(--text-primary);
border-color: var(--text-muted, var(--text-secondary));
}
}
.db-icon {
font-size: 10px;
font-weight: 700;
padding: 1px 4px;
border-radius: 3px;
background: var(--accent);
color: var(--bg-primary);
}
.db-name {
max-width: 180px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.db-menu {
position: absolute;
top: 100%;
right: 0;
margin-top: 4px;
min-width: 280px;
max-height: 400px;
overflow-y: auto;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 8px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
z-index: 1000;
}
.db-menu-header {
padding: 8px 12px 4px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-muted, var(--text-secondary));
}
.db-item {
display: flex;
align-items: center;
width: 100%;
padding: 8px 12px;
border: none;
background: none;
color: var(--text-secondary);
font-size: 13px;
cursor: pointer;
text-align: left;
gap: 8px;
transition: background 0.1s ease;
&:hover {
background: var(--bg-tertiary);
color: var(--text-primary);
}
&.active {
color: var(--accent);
font-weight: 500;
}
}
.db-item-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.db-item-size {
font-size: 11px;
color: var(--text-muted, var(--text-secondary));
flex-shrink: 0;
}
.db-item-badge {
font-size: 10px;
padding: 1px 6px;
border-radius: 10px;
background: var(--accent);
color: var(--bg-primary);
font-weight: 600;
flex-shrink: 0;
}
.db-menu-divider {
height: 1px;
margin: 4px 0;
background: var(--border);
}
.db-new,
.db-action {
color: var(--accent);
font-weight: 500;
cursor: pointer;
}
.db-new-form {
display: flex;
padding: 6px 8px;
gap: 6px;
input {
flex: 1;
padding: 4px 8px;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--bg-primary);
color: var(--text-primary);
font-size: 12px;
&:focus {
outline: none;
border-color: var(--accent);
}
}
}
.db-new-btn {
padding: 4px 10px;
border: none;
border-radius: 4px;
background: var(--accent);
color: var(--bg-primary);
font-size: 12px;
font-weight: 600;
cursor: pointer;
&:hover {
opacity: 0.9;
}
}

View File

@@ -1,11 +1,104 @@
import { Component } from '@angular/core';
import { Component, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { RouterOutlet, RouterLink, RouterLinkActive } from '@angular/router';
import { RequirementService } from './core/services/requirement.service';
import { DatabaseInfo } from './core/models/requirement.model';
@Component({
selector: 'app-root',
standalone: true,
imports: [RouterOutlet, RouterLink, RouterLinkActive],
imports: [CommonModule, FormsModule, RouterOutlet, RouterLink, RouterLinkActive],
templateUrl: './app.component.html',
styleUrl: './app.component.scss'
})
export class AppComponent {}
export class AppComponent implements OnInit {
private svc = inject(RequirementService);
databases: DatabaseInfo[] = [];
activeDbPath = '';
showDbMenu = false;
newDbName = '';
showNewDbInput = false;
uploading = false;
ngOnInit() {
this.loadDatabases();
}
loadDatabases() {
this.svc.getDatabases().subscribe(dbs => {
this.databases = dbs;
const active = dbs.find(d => d.active);
if (active) this.activeDbPath = active.path;
});
}
toggleMenu() {
this.showDbMenu = !this.showDbMenu;
if (this.showDbMenu) {
this.loadDatabases();
}
}
switchDb(db: DatabaseInfo) {
if (db.active) {
this.showDbMenu = false;
return;
}
this.svc.switchDatabase(db.path).subscribe(() => {
window.location.reload();
});
}
onFileSelected(event: Event) {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
if (!file) return;
if (!file.name.endsWith('.db')) {
alert('Please select a .db file');
input.value = '';
return;
}
this.uploading = true;
this.svc.uploadDatabase(file).subscribe({
next: () => window.location.reload(),
error: (err) => {
this.uploading = false;
alert(err.error?.detail ?? 'Failed to upload database');
input.value = '';
},
});
}
downloadDb() {
this.svc.downloadDatabase().subscribe(blob => {
const active = this.databases.find(d => d.active);
const name = active?.name ?? 'planner';
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${name}.db`;
a.click();
URL.revokeObjectURL(url);
});
}
createDb() {
const name = this.newDbName.trim();
if (!name) return;
this.svc.createDatabase(name).subscribe({
next: () => window.location.reload(),
error: (err) => alert(err.error?.detail ?? 'Failed to create database'),
});
}
formatSize(bytes: number): string {
return (bytes / 1024).toFixed(0) + ' KB';
}
get activeDbName(): string {
const active = this.databases.find(d => d.active);
return active?.name ?? 'planner';
}
}

View File

@@ -84,6 +84,7 @@ export interface Stats {
requirements: number;
sub_requirements: number;
leaves: number;
orphaned: number;
by_status: Record<string, number>;
by_priority: Record<string, number>;
quality_passing: number;
@@ -146,6 +147,14 @@ export interface GraphLayoutCreate {
is_default?: boolean;
}
// ─── Database Switching ──────────────────────────────────────────────
export interface DatabaseInfo {
name: string;
path: string;
size_bytes: number;
active: boolean;
}
export const PRIORITY_COLORS: Record<Priority, string> = {
critical: '#da3633',
high: '#d29922',

View File

@@ -12,6 +12,7 @@ import {
GraphLayoutResponse,
GraphLayoutDetailResponse,
GraphLayoutCreate,
DatabaseInfo,
} from '../models/requirement.model';
@Injectable({ providedIn: 'root' })
@@ -49,8 +50,10 @@ export class RequirementService {
return this.http.put<RequirementNode>(`${this.baseUrl}/requirements/${id}`, data);
}
deleteRequirement(id: string): Observable<void> {
return this.http.delete<void>(`${this.baseUrl}/requirements/${id}`);
deleteRequirement(id: string, deleteIds: string[] = []): Observable<void> {
return this.http.request<void>('DELETE', `${this.baseUrl}/requirements/${id}`, {
body: deleteIds,
});
}
@@ -116,10 +119,49 @@ export class RequirementService {
return this.http.delete<void>(`${this.baseUrl}/layouts/${id}`);
}
getAutosaveLayout(): Observable<GraphLayoutDetailResponse> {
return this.http.get<GraphLayoutDetailResponse>(`${this.baseUrl}/layouts/autosave`);
}
saveAutosaveLayout(data: { node_positions?: Record<string, { x: number; y: number }>; viewport?: Record<string, any> }): Observable<GraphLayoutDetailResponse> {
return this.http.put<GraphLayoutDetailResponse>(`${this.baseUrl}/layouts/autosave`, data);
}
// ─── Stats ────────────────────────────────────────────────────
getStats(projectId?: number): Observable<Stats> {
const params: Record<string, string> = {};
if (projectId != null) params['project_id'] = String(projectId);
return this.http.get<Stats>(`${this.baseUrl}/stats`, { params });
}
// ─── Databases ──────────────────────────────────────────────
getDatabases(): Observable<DatabaseInfo[]> {
return this.http.get<DatabaseInfo[]>(`${this.baseUrl}/databases`);
}
getActiveDatabase(): Observable<DatabaseInfo> {
return this.http.get<DatabaseInfo>(`${this.baseUrl}/databases/active`);
}
switchDatabase(path: string): Observable<DatabaseInfo> {
return this.http.post<DatabaseInfo>(`${this.baseUrl}/databases/switch`, { path });
}
createDatabase(name: string): Observable<DatabaseInfo> {
return this.http.post<DatabaseInfo>(`${this.baseUrl}/databases/create`, { name });
}
copyDatabase(name: string): Observable<DatabaseInfo> {
return this.http.post<DatabaseInfo>(`${this.baseUrl}/databases/copy`, { name });
}
uploadDatabase(file: File): Observable<DatabaseInfo> {
const formData = new FormData();
formData.append('file', file);
return this.http.post<DatabaseInfo>(`${this.baseUrl}/databases/upload`, formData);
}
downloadDatabase(): Observable<Blob> {
return this.http.get(`${this.baseUrl}/databases/download`, { responseType: 'blob' });
}
}

View File

@@ -27,6 +27,12 @@
<div class="stat-value">{{ stats.coverage_percent }}%</div>
<div class="stat-label">Test Coverage</div>
</div>
@if (stats.orphaned > 0) {
<div class="stat-card warn">
<div class="stat-value">{{ stats.orphaned }}</div>
<div class="stat-label">Orphaned</div>
</div>
}
</div>
<div class="charts-row">

View File

@@ -31,6 +31,11 @@
border-color: var(--accent);
background: rgba(88, 166, 255, 0.05);
}
&.warn {
border-color: var(--yellow, #d29922);
background: rgba(210, 153, 34, 0.08);
}
}
.stat-value {

View File

@@ -151,10 +151,43 @@
}
</div>
<!-- Orphan Warning + Re-parent -->
@if (!node.parent_id && node.node_type !== 'pillar') {
<div class="section orphan-banner">
<div class="orphan-label">Orphaned — assign a parent to restore traceability</div>
<app-node-picker
placeholder="Parent node ID..."
submitLabel="Assign"
[excludeIds]="[node.id]"
(picked)="onReparentPicked($event)"
/>
@if (reparentError) {
<div class="reparent-error">{{ reparentError }}</div>
}
</div>
}
<!-- Parent Requirement -->
@if (parentNode) {
<div class="section">
<h3 class="section-title">Parent</h3>
<h3 class="section-title">
Parent
@if (editing) {
<button class="section-action" (click)="showReparent = !showReparent">change</button>
<button class="section-action danger" (click)="unlinkParent()">unlink</button>
}
</h3>
@if (editing && showReparent) {
<app-node-picker
placeholder="New parent node ID..."
submitLabel="Move"
[excludeIds]="[node.id]"
(picked)="onReparentPicked($event)"
/>
@if (reparentError) {
<div class="reparent-error">{{ reparentError }}</div>
}
}
<div class="child-card clickable" (click)="goToNode(parentNode.id)">
<div class="child-header">
<span class="child-id">{{ parentNode.id }}</span>
@@ -253,38 +286,16 @@
</div>
@if (showAddLink) {
<div class="add-link-form">
<div class="typeahead-container">
<input
class="edit-input"
[(ngModel)]="newLinkTargetId"
placeholder="Search by ID or title..."
(input)="onLinkSearchInput()"
(focus)="onLinkSearchFocus()"
(keydown)="onLinkSearchKeydown($event)"
(keydown.enter)="highlightedIndex < 0 && createLink()"
(blur)="showLinkDropdown = false"
autocomplete="off"
/>
@if (showLinkDropdown) {
<div class="typeahead-dropdown">
@for (req of filteredRequirements; track req.id; let i = $index) {
<div
class="typeahead-option"
[class.highlighted]="i === highlightedIndex"
(mousedown)="selectLinkTarget(req)"
>
<span class="typeahead-id">{{ req.id }}</span>
<span class="typeahead-title">{{ req.title }}</span>
</div>
}
</div>
}
</div>
<app-node-picker
placeholder="Search by ID or title..."
submitLabel="Link"
[excludeIds]="linkExcludeIds"
(picked)="onLinkPicked($event)"
/>
@if (linkError) {
<div class="create-error">{{ linkError }}</div>
}
<div class="form-actions">
<button class="action-btn primary small" (click)="createLink()">Link</button>
<button class="action-btn small" (click)="toggleAddLink()">Cancel</button>
</div>
</div>
@@ -350,45 +361,27 @@
<div class="history-detail">
<h4 class="history-detail-title">Version {{ selectedVersion.version }} Details</h4>
<div class="history-fields">
<div class="history-field" [class.changed]="isFieldChanged('title')">
<span class="history-field-label">Title</span>
<span class="history-field-value">{{ selectedVersion.title }}</span>
</div>
<div class="history-field" [class.changed]="isFieldChanged('description')">
<span class="history-field-label">Description</span>
<span class="history-field-value">{{ selectedVersion.description || '(empty)' }}</span>
</div>
<div class="history-field" [class.changed]="isFieldChanged('priority')">
<span class="history-field-label">Priority</span>
<span class="history-field-value">{{ selectedVersion.priority }}</span>
</div>
<div class="history-field" [class.changed]="isFieldChanged('status')">
<span class="history-field-label">Status</span>
<span class="history-field-value">{{ selectedVersion.status }}</span>
</div>
@if (isFieldChanged('acceptance_criteria')) {
<div class="history-field changed">
<span class="history-field-label">Acceptance Criteria</span>
<pre class="history-field-pre">{{ selectedVersion.acceptance_criteria || '(empty)' }}</pre>
</div>
}
@if (isFieldChanged('test_spec')) {
<div class="history-field changed">
<span class="history-field-label">Test Spec</span>
<pre class="history-field-pre">{{ selectedVersion.test_spec || '(empty)' }}</pre>
</div>
}
@if (isFieldChanged('test_code')) {
<div class="history-field changed">
<span class="history-field-label">Test Code</span>
<pre class="history-field-pre">{{ selectedVersion.test_code || '(empty)' }}</pre>
</div>
}
@if (isFieldChanged('impl_code')) {
<div class="history-field changed">
<span class="history-field-label">Impl Code</span>
<pre class="history-field-pre">{{ selectedVersion.impl_code || '(empty)' }}</pre>
</div>
@for (field of [
{key: 'title', label: 'Title'},
{key: 'description', label: 'Description'},
{key: 'priority', label: 'Priority'},
{key: 'status', label: 'Status'},
{key: 'node_type', label: 'Node Type'},
{key: 'acceptance_criteria', label: 'Acceptance Criteria'},
{key: 'test_spec', label: 'Test Spec'},
{key: 'test_code', label: 'Test Code'},
{key: 'impl_code', label: 'Impl Code'}
]; track field.key) {
@if (isFieldChanged(field.key)) {
<div class="history-field changed">
<span class="history-field-label">{{ field.label }}</span>
<div class="history-delta">
<div class="delta-old">{{ getFieldOldValue(field.key) }}</div>
<span class="delta-arrow">&rarr;</span>
<div class="delta-new">{{ getFieldNewValue(field.key) }}</div>
</div>
</div>
}
}
</div>
<button class="action-btn primary small" (click)="restoreVersion(selectedVersion.version)">Restore this version</button>
@@ -409,3 +402,50 @@
}
</div>
</div>
@if (showDeleteDialog) {
<div class="delete-overlay" (click)="showDeleteDialog = false">
<div class="delete-dialog" (click)="$event.stopPropagation()">
<div class="delete-header">
<span>Delete "{{ node.title }}"</span>
<button class="delete-close" (click)="showDeleteDialog = false">x</button>
</div>
<div class="delete-body">
<p class="delete-desc">This node has {{ deleteChildren.length }} direct {{ deleteChildren.length === 1 ? 'child' : 'children' }}. Choose what happens to each:</p>
<div class="delete-bulk-actions">
<button class="bulk-btn" (click)="setAllDeleteActions('orphan')">Orphan All</button>
<button class="bulk-btn danger" (click)="setAllDeleteActions('delete')">Delete All</button>
</div>
<div class="delete-children-list">
@for (entry of deleteChildren; track entry.node.id) {
<div class="delete-child-row">
<div class="delete-child-info">
<span class="delete-child-id">{{ entry.node.id }}</span>
<span class="delete-child-title">{{ entry.node.title }}</span>
@if (entry.descendantCount > 0) {
<span class="delete-child-desc">+{{ entry.descendantCount }} nested</span>
}
</div>
<div class="delete-child-toggle">
<button
class="toggle-btn"
[class.active]="entry.action === 'orphan'"
(click)="entry.action = 'orphan'"
>Orphan</button>
<button
class="toggle-btn danger"
[class.active]="entry.action === 'delete'"
(click)="entry.action = 'delete'"
>Delete</button>
</div>
</div>
}
</div>
</div>
<div class="delete-footer">
<button class="action-btn danger" (click)="confirmDelete()">Confirm Delete</button>
<button class="action-btn" (click)="showDeleteDialog = false">Cancel</button>
</div>
</div>
</div>
}

View File

@@ -496,57 +496,6 @@
margin-top: 8px;
}
// Typeahead dropdown
.typeahead-container {
position: relative;
}
.typeahead-dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-top: none;
border-radius: 0 0 6px 6px;
max-height: 200px;
overflow-y: auto;
z-index: 20;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
}
.typeahead-option {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
cursor: pointer;
border-bottom: 1px solid var(--border);
&:last-child {
border-bottom: none;
}
&:hover, &.highlighted {
background: rgba(188, 140, 255, 0.1);
}
}
.typeahead-id {
color: #bc8cff;
font-weight: 600;
font-size: 12px;
white-space: nowrap;
}
.typeahead-title {
color: var(--text-secondary);
font-size: 12px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
// History styles
.clickable-title {
@@ -666,3 +615,221 @@
max-height: 150px;
overflow-y: auto;
}
.history-delta {
display: flex;
align-items: flex-start;
gap: 6px;
font-size: 12px;
margin-top: 2px;
}
.delta-old {
color: var(--text-muted);
text-decoration: line-through;
flex: 1;
white-space: pre-wrap;
word-break: break-word;
}
.delta-arrow {
color: var(--yellow);
flex-shrink: 0;
line-height: 1.4;
}
.delta-new {
color: var(--text-primary);
flex: 1;
white-space: pre-wrap;
word-break: break-word;
}
// ─── Orphan / Re-parent ──────────────────────────────────────────
.orphan-banner {
background: rgba(210, 153, 34, 0.08);
border: 1px dashed #d29922;
border-radius: 8px;
padding: 12px;
}
.orphan-label {
font-size: 12px;
font-weight: 600;
color: #d29922;
margin-bottom: 8px;
}
.reparent-error {
font-size: 12px;
color: var(--red);
margin-top: 4px;
}
.section-action {
background: none;
border: none;
color: var(--accent);
font-size: 11px;
cursor: pointer;
padding: 0 4px;
&:hover { text-decoration: underline; }
&.danger { color: var(--red, #f85149); }
}
// ─── Delete Dialog ────────────────────────────────────────────────
.delete-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
}
.delete-dialog {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 10px;
width: 480px;
max-height: 70vh;
display: flex;
flex-direction: column;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6);
}
.delete-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px;
border-bottom: 1px solid var(--border);
font-weight: 600;
font-size: 15px;
color: var(--text-primary);
}
.delete-close {
background: none;
border: none;
color: var(--text-secondary);
font-size: 16px;
cursor: pointer;
padding: 2px 6px;
&:hover { color: var(--text-primary); }
}
.delete-body {
padding: 12px 16px;
overflow-y: auto;
flex: 1;
}
.delete-desc {
font-size: 13px;
color: var(--text-secondary);
margin: 0 0 12px;
}
.delete-bulk-actions {
display: flex;
gap: 8px;
margin-bottom: 12px;
}
.bulk-btn {
background: var(--bg-tertiary);
border: 1px solid var(--border);
color: var(--text-secondary);
padding: 4px 12px;
border-radius: 4px;
font-size: 12px;
cursor: pointer;
&:hover { color: var(--text-primary); border-color: var(--text-muted); }
&.danger { color: var(--red); &:hover { border-color: var(--red); } }
}
.delete-children-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.delete-child-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 8px 10px;
background: var(--bg-tertiary);
border: 1px solid var(--border);
border-radius: 6px;
}
.delete-child-info {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
flex: 1;
}
.delete-child-id {
font-size: 11px;
font-weight: 600;
color: var(--accent);
font-family: monospace;
}
.delete-child-title {
font-size: 13px;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.delete-child-desc {
font-size: 11px;
color: var(--text-muted);
}
.delete-child-toggle {
display: flex;
gap: 2px;
flex-shrink: 0;
}
.toggle-btn {
background: var(--bg-secondary);
border: 1px solid var(--border);
color: var(--text-muted);
padding: 3px 10px;
font-size: 11px;
cursor: pointer;
border-radius: 4px;
transition: all 0.15s;
&.active {
color: var(--text-primary);
border-color: var(--accent);
background: rgba(88, 166, 255, 0.1);
}
&.danger.active {
color: var(--red);
border-color: var(--red);
background: rgba(218, 54, 51, 0.1);
}
}
.delete-footer {
padding: 12px 16px;
border-top: 1px solid var(--border);
display: flex;
gap: 8px;
justify-content: flex-end;
}

View File

@@ -12,6 +12,7 @@ import {
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { RequirementService } from '../../core/services/requirement.service';
import { NodePickerComponent } from '../../shared/node-picker.component';
import {
RequirementNode,
CrossPillarLink,
@@ -27,7 +28,7 @@ import {
@Component({
selector: 'app-detail-panel',
standalone: true,
imports: [CommonModule, FormsModule],
imports: [CommonModule, FormsModule, NodePickerComponent],
templateUrl: './detail-panel.component.html',
styleUrl: './detail-panel.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
@@ -57,17 +58,21 @@ export class DetailPanelComponent implements OnChanges {
historyList: RequirementHistorySummary[] = [];
showHistory = false;
selectedVersion: RequirementHistoryDetail | null = null;
previousVersion: RequirementHistoryDetail | null = null;
// Delete dialog
showDeleteDialog = false;
deleteChildren: { node: RequirementNode; action: 'orphan' | 'delete'; descendantCount: number }[] = [];
// Re-parent
showReparent = false;
reparentError: string | null = null;
// Link management
outgoingLinks: CrossPillarLink[] = []; // this node "also satisfies" these
incomingLinks: CrossPillarLink[] = []; // these nodes satisfy this one
outgoingLinks: CrossPillarLink[] = [];
incomingLinks: CrossPillarLink[] = [];
showAddLink = false;
newLinkTargetId = '';
linkError: string | null = null;
allRequirements: { id: string; title: string }[] = [];
filteredRequirements: { id: string; title: string }[] = [];
showLinkDropdown = false;
highlightedIndex = -1;
get effectiveMeasurable(): boolean {
return this.validationReport ? this.validationReport.measurable : this.node.measurable;
@@ -86,9 +91,14 @@ export class DetailPanelComponent implements OnChanges {
this.editing = false;
this.showAddChild = false;
this.showAddLink = false;
this.showReparent = false;
this.showReparent = false;
this.reparentError = null;
this.showDeleteDialog = false;
this.showHistory = false;
this.historyList = [];
this.selectedVersion = null;
this.previousVersion = null;
this.validationReport = this.node.quality || null;
this.loadParent();
this.loadChildren();
@@ -205,9 +215,81 @@ export class DetailPanelComponent implements OnChanges {
}
deleteNode(): void {
if (!confirm(`Delete "${this.node.title}" and all its children?`)) return;
this.reqService.deleteRequirement(this.node.id).subscribe({
next: () => this.nodeDeleted.emit(this.node.id),
if (this.children.length === 0) {
// No children — delete immediately with simple confirm
if (!confirm(`Delete "${this.node.title}"?`)) return;
this.reqService.deleteRequirement(this.node.id).subscribe({
next: () => this.nodeDeleted.emit(this.node.id),
});
return;
}
// Has children — show the delete dialog
this.deleteChildren = this.children.map(c => ({
node: c,
action: 'orphan' as const,
descendantCount: this.countDescendants(c),
}));
this.showDeleteDialog = true;
this.cdr.markForCheck();
}
private countDescendants(node: RequirementNode): number {
let count = 0;
if (node.children) {
count = node.children.length;
for (const child of node.children) {
count += this.countDescendants(child);
}
}
return count;
}
setAllDeleteActions(action: 'orphan' | 'delete'): void {
this.deleteChildren.forEach(c => c.action = action);
}
confirmDelete(): void {
const deleteIds = this.deleteChildren
.filter(c => c.action === 'delete')
.map(c => c.node.id);
this.reqService.deleteRequirement(this.node.id, deleteIds).subscribe({
next: () => {
this.showDeleteDialog = false;
this.nodeDeleted.emit(this.node.id);
},
});
}
// ─── Re-parent ──────────────────────────────────────────────
unlinkParent(): void {
this.reqService.updateRequirement(this.node.id, { parent_id: null } as any).subscribe({
next: (updated) => {
Object.assign(this.node, updated);
this.validationReport = updated.quality || null;
this.parentNode = null;
this.nodeUpdated.emit(updated);
this.cdr.markForCheck();
},
});
}
onReparentPicked(newParentId: string): void {
this.reparentError = null;
this.reqService.updateRequirement(this.node.id, { parent_id: newParentId } as any).subscribe({
next: (updated) => {
this.showReparent = false;
Object.assign(this.node, updated);
this.validationReport = updated.quality || null;
this.loadParent();
this.loadChildren();
this.nodeUpdated.emit(updated);
this.cdr.markForCheck();
},
error: (err) => {
this.reparentError = err?.error?.detail || 'Failed to re-parent';
this.cdr.markForCheck();
},
});
}
@@ -225,72 +307,14 @@ export class DetailPanelComponent implements OnChanges {
toggleAddLink(): void {
this.showAddLink = !this.showAddLink;
this.newLinkTargetId = '';
this.linkError = null;
this.showLinkDropdown = false;
this.highlightedIndex = -1;
if (this.showAddLink) {
this.reqService.getRequirements().subscribe({
next: (reqs) => {
// Exclude self and already-linked targets
const linkedIds = new Set(this.outgoingLinks.map(l => l.target_id));
linkedIds.add(this.node.id);
this.allRequirements = reqs
.filter(r => !linkedIds.has(r.id))
.map(r => ({ id: r.id, title: r.title }));
this.filteredRequirements = [];
this.cdr.markForCheck();
},
});
}
}
onLinkSearchInput(): void {
this.filterLinkOptions();
get linkExcludeIds(): string[] {
return [this.node.id, ...this.outgoingLinks.map(l => l.target_id)];
}
onLinkSearchFocus(): void {
this.filterLinkOptions();
}
private filterLinkOptions(): void {
const q = this.newLinkTargetId.toLowerCase().trim();
if (!q) {
this.filteredRequirements = this.allRequirements.slice(0, 10);
} else {
this.filteredRequirements = this.allRequirements
.filter(r => r.id.toLowerCase().includes(q) || r.title.toLowerCase().includes(q))
.slice(0, 10);
}
this.showLinkDropdown = this.filteredRequirements.length > 0;
this.highlightedIndex = -1;
this.cdr.markForCheck();
}
onLinkSearchKeydown(event: KeyboardEvent): void {
if (!this.showLinkDropdown) return;
if (event.key === 'ArrowDown') {
event.preventDefault();
this.highlightedIndex = Math.min(this.highlightedIndex + 1, this.filteredRequirements.length - 1);
} else if (event.key === 'ArrowUp') {
event.preventDefault();
this.highlightedIndex = Math.max(this.highlightedIndex - 1, 0);
} else if (event.key === 'Enter' && this.highlightedIndex >= 0) {
event.preventDefault();
this.selectLinkTarget(this.filteredRequirements[this.highlightedIndex]);
}
}
selectLinkTarget(req: { id: string; title: string }): void {
this.newLinkTargetId = req.id;
this.showLinkDropdown = false;
this.highlightedIndex = -1;
this.cdr.markForCheck();
}
createLink(): void {
const targetId = this.newLinkTargetId.trim();
if (!targetId) return;
onLinkPicked(targetId: string): void {
this.reqService.createLink({
source_id: this.node.id,
target_id: targetId,
@@ -298,13 +322,13 @@ export class DetailPanelComponent implements OnChanges {
}).subscribe({
next: () => {
this.showAddLink = false;
this.newLinkTargetId = '';
this.linkError = null;
this.loadLinks();
this.linksChanged.emit();
},
error: (err) => {
this.linkError = err?.error?.detail || 'Failed to create link';
this.cdr.markForCheck();
},
});
}
@@ -335,6 +359,7 @@ export class DetailPanelComponent implements OnChanges {
toggleHistory(): void {
this.showHistory = !this.showHistory;
this.selectedVersion = null;
this.previousVersion = null;
if (this.showHistory) {
this.loadHistory();
}
@@ -352,12 +377,23 @@ export class DetailPanelComponent implements OnChanges {
viewVersion(version: number): void {
if (this.selectedVersion?.version === version) {
this.selectedVersion = null;
this.previousVersion = null;
return;
}
this.reqService.getHistoryVersion(this.node.id, version).subscribe({
next: (detail) => {
this.selectedVersion = detail;
this.cdr.markForCheck();
if (version > 1) {
this.reqService.getHistoryVersion(this.node.id, version - 1).subscribe({
next: (prev) => {
this.previousVersion = prev;
this.cdr.markForCheck();
},
});
} else {
this.previousVersion = null;
this.cdr.markForCheck();
}
},
});
}
@@ -370,6 +406,7 @@ export class DetailPanelComponent implements OnChanges {
this.validationReport = restored.quality || null;
this.nodeUpdated.emit(restored);
this.selectedVersion = null;
this.previousVersion = null;
this.loadHistory();
this.cdr.markForCheck();
},
@@ -378,7 +415,21 @@ export class DetailPanelComponent implements OnChanges {
isFieldChanged(field: string): boolean {
if (!this.selectedVersion) return false;
return (this.selectedVersion as any)[field] !== (this.node as any)[field];
if (this.previousVersion) {
return (this.selectedVersion as any)[field] !== (this.previousVersion as any)[field];
}
// Version 1 — everything is "new"
return true;
}
getFieldOldValue(field: string): string {
if (!this.previousVersion) return '(none)';
return (this.previousVersion as any)[field] || '(empty)';
}
getFieldNewValue(field: string): string {
if (!this.selectedVersion) return '';
return (this.selectedVersion as any)[field] || '(empty)';
}
goToNode(nodeId: string): void {

View File

@@ -41,6 +41,7 @@
<button class="toolbar-btn" (click)="zoomOut()" title="Zoom Out">-</button>
<button class="toolbar-btn" (click)="fitGraph()" title="Fit to Screen">Fit</button>
<button class="toolbar-btn" (click)="relayout()" title="Re-layout">Layout</button>
<button class="toolbar-btn warn" (click)="groupOrphans()" title="Group orphaned nodes together">Orphans</button>
<div class="layout-controls">
<button class="toolbar-btn" (click)="showSaveLayout = true" title="Save Layout">Save</button>
@if (savedLayouts.length > 0) {
@@ -55,16 +56,17 @@
<button class="toolbar-btn" (click)="showManageLayouts = true" title="Manage Layouts">Layouts</button>
}
</div>
<button class="toolbar-btn help-btn" (click)="showShortcuts = !showShortcuts" title="Keyboard & Mouse Shortcuts">&#9000;</button>
<button class="toolbar-btn help-btn" (click)="showHelp = !showHelp" title="Help">?</button>
</div>
</div>
@if (showHelp) {
<div class="help-overlay" (click)="showHelp = false">
@if (showShortcuts) {
<div class="help-overlay" (click)="showShortcuts = false">
<div class="help-dialog" (click)="$event.stopPropagation()">
<div class="help-header">
<span>Keyboard & Mouse Shortcuts</span>
<button class="help-close" (click)="showHelp = false">x</button>
<button class="help-close" (click)="showShortcuts = false">x</button>
</div>
<div class="help-body">
<div class="help-section">Mouse</div>
@@ -87,6 +89,7 @@
<div class="help-section">Graph</div>
<div class="help-row"><span class="help-key">Solid lines</span><span>Parent-child relationships</span></div>
<div class="help-row"><span class="help-key">Dashed purple</span><span>"Also satisfies" links</span></div>
<div class="help-row"><span class="help-key">Dashed yellow</span><span>Orphaned node (parent was deleted)</span></div>
<div class="help-row"><span class="help-key">Red dot</span><span>Fewer than 2 quality gates pass</span></div>
<div class="help-row"><span class="help-key">Orange dot</span><span>2 of 3 quality gates pass</span></div>
<div class="help-row"><span class="help-key">Green dot</span><span>All quality gates pass</span></div>
@@ -95,6 +98,54 @@
</div>
}
@if (showHelp) {
<div class="help-overlay" (click)="showHelp = false">
<div class="help-dialog wide" (click)="$event.stopPropagation()">
<div class="help-header">
<span>How It Works</span>
<button class="help-close" (click)="showHelp = false">x</button>
</div>
<div class="help-body">
<div class="help-section">Databases</div>
<div class="help-text">
Each project lives in its own SQLite <code>.db</code> file. Use the database selector
in the navbar to switch between them.
</div>
<div class="help-row"><span class="help-key">New</span><span>Creates an empty database on the server</span></div>
<div class="help-row"><span class="help-key">Copy</span><span>Clones the active database under a new name</span></div>
<div class="help-row"><span class="help-key">Upload</span><span>Uploads a <code>.db</code> file from your computer to the server and switches to it</span></div>
<div class="help-row"><span class="help-key">Download</span><span>Downloads the active database as a local backup file</span></div>
<div class="help-section">Layout Persistence</div>
<div class="help-text">
Node positions, viewport (zoom/pan), selected node, and panel state are automatically
saved to the active database every time you make a change. This means your layout
travels with the <code>.db</code> file — download it, upload to another machine or
browser, and everything is exactly where you left it.
</div>
<div class="help-row"><span class="help-key">Auto-save</span><span>Working state writes to the DB after 1 second of inactivity</span></div>
<div class="help-row"><span class="help-key">Named layouts</span><span>Save/Load snapshots of node positions via the toolbar</span></div>
<div class="help-row"><span class="help-key">Layout button</span><span>Resets the tree to an automatic dagre layout; orphans are grouped below</span></div>
<div class="help-section">Deleting Nodes</div>
<div class="help-text">
Deleting a requirement does <strong>not</strong> delete its children. Children become
<strong>orphans</strong> — they lose their parent link and show a dashed yellow border.
Orphaned nodes fail the Traceable (T) quality gate until re-parented.
</div>
<div class="help-row"><span class="help-key">Orphans button</span><span>Groups orphaned nodes together without moving the rest of the layout</span></div>
<div class="help-row"><span class="help-key">Re-parent</span><span>Edit an orphan's parent ID in the detail panel to restore traceability</span></div>
<div class="help-section">Quality Gates (TMCS)</div>
<div class="help-row"><span class="help-key">T — Traceable</span><span>Auto-calculated: non-pillar nodes must have a parent</span></div>
<div class="help-row"><span class="help-key">M — Measurable</span><span>Requires a test specification (GIVEN/WHEN/THEN)</span></div>
<div class="help-row"><span class="help-key">C — Consistent</span><span>Manual toggle — you verify it doesn't contradict other requirements</span></div>
<div class="help-row"><span class="help-key">S — Single-purpose</span><span>Manual toggle — you verify it serves exactly one purpose</span></div>
</div>
</div>
</div>
}
@if (showCreatePillar) {
<div class="create-pillar-bar">
<input

View File

@@ -260,6 +260,10 @@
max-height: 80vh;
overflow-y: auto;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6);
&.wide {
width: 560px;
}
}
.help-header {
@@ -304,6 +308,22 @@
}
}
.help-text {
font-size: 13px;
color: var(--text-secondary);
line-height: 1.5;
margin-bottom: 8px;
code {
background: var(--bg-tertiary);
border: 1px solid var(--border);
border-radius: 3px;
padding: 1px 5px;
font-size: 12px;
color: var(--text-primary);
}
}
.help-row {
display: flex;
align-items: baseline;
@@ -440,3 +460,12 @@
border-color: var(--red);
}
}
.toolbar-btn.warn {
color: #d29922;
border-color: rgba(210, 153, 34, 0.3);
&:hover {
background: rgba(210, 153, 34, 0.15);
border-color: #d29922;
}
}

View File

@@ -15,8 +15,8 @@ import cytoscape, { Core, NodeSingular, Collection } from 'cytoscape';
import dagre from 'cytoscape-dagre';
import { RequirementService } from '../../core/services/requirement.service';
import { DetailPanelComponent } from '../detail/detail-panel.component';
import { RequirementNode, RequirementTreeNode, CrossPillarLink, PRIORITY_COLORS, Priority, GraphLayoutResponse } from '../../core/models/requirement.model';
import { Subject, takeUntil, forkJoin } from 'rxjs';
import { RequirementNode, RequirementTreeNode, CrossPillarLink, PRIORITY_COLORS, Priority, GraphLayoutResponse, DatabaseInfo } from '../../core/models/requirement.model';
import { Subject, takeUntil, forkJoin, debounceTime } from 'rxjs';
cytoscape.use(dagre);
@@ -67,6 +67,7 @@ export class GraphViewComponent implements OnInit, OnDestroy {
treeData: RequirementTreeNode[] = [];
loading = true;
showCreatePillar = false;
showShortcuts = false;
showHelp = false;
isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0;
newPillar = { id: '', title: '', description: '' };
@@ -85,6 +86,12 @@ export class GraphViewComponent implements OnInit, OnDestroy {
newLayoutDescription = '';
newLayoutDefault = false;
// Database-scoped localStorage prefix
private dbName = 'planner';
// Debounced autosave to DB
private autosave$ = new Subject<void>();
// Canvas state history (Ctrl+Z to undo)
private canvasHistory: CanvasSnapshot[] = [];
private readonly MAX_HISTORY = 20;
@@ -99,8 +106,42 @@ export class GraphViewComponent implements OnInit, OnDestroy {
ngOnInit(): void {
this.initCytoscape();
this.loadTree();
this.loadSavedLayouts();
this.initAutosavePipeline();
this.reqService.getActiveDatabase().subscribe({
next: (db) => { this.dbName = db.name; },
complete: () => {
this.loadTree();
this.loadSavedLayouts();
},
});
}
private initAutosavePipeline(): void {
this.autosave$.pipe(
debounceTime(1000),
takeUntil(this.destroy$),
).subscribe(() => this.flushAutosaveToDb());
}
private flushAutosaveToDb(): void {
const positions: Record<string, { x: number; y: number }> = {};
this.cy.nodes().forEach((node: NodeSingular) => {
const pos = node.position();
positions[node.id()] = { x: pos.x, y: pos.y };
});
const viewport: Record<string, any> = {
zoom: this.cy.zoom(),
pan: this.cy.pan(),
selected_node_id: this.selectedNode?.id ?? null,
panel_open: this.detailOpen,
};
this.reqService.saveAutosaveLayout({ node_positions: positions, viewport }).pipe(
takeUntil(this.destroy$),
).subscribe();
}
private dbKey(key: string): string {
return `req-planner-${this.dbName}-${key}`;
}
@HostListener('document:keydown', ['$event'])
@@ -223,11 +264,12 @@ export class GraphViewComponent implements OnInit, OnDestroy {
zoom: this.cy.zoom(),
pan: this.cy.pan(),
};
localStorage.setItem('req-planner-viewport', JSON.stringify(viewport));
localStorage.setItem(this.dbKey('viewport'), JSON.stringify(viewport));
this.autosave$.next();
}
private restoreViewport(): boolean {
const saved = localStorage.getItem('req-planner-viewport');
const saved = localStorage.getItem(this.dbKey('viewport'));
if (!saved) return false;
try {
const { zoom, pan } = JSON.parse(saved);
@@ -247,11 +289,12 @@ export class GraphViewComponent implements OnInit, OnDestroy {
const pos = node.position();
positions[node.id()] = { x: pos.x, y: pos.y };
});
localStorage.setItem('req-planner-node-positions', JSON.stringify(positions));
localStorage.setItem(this.dbKey('node-positions'), JSON.stringify(positions));
this.autosave$.next();
}
private restoreNodePositions(): boolean {
const saved = localStorage.getItem('req-planner-node-positions');
const saved = localStorage.getItem(this.dbKey('node-positions'));
if (!saved) return false;
try {
const positions: Record<string, { x: number; y: number }> = JSON.parse(saved);
@@ -403,6 +446,14 @@ export class GraphViewComponent implements OnInit, OnDestroy {
'border-color': '#d29922',
},
},
{
selector: 'node.orphan',
style: {
'border-style': 'dashed',
'border-color': '#d29922',
'border-width': 2,
},
},
];
}
@@ -436,6 +487,7 @@ export class GraphViewComponent implements OnInit, OnDestroy {
const borderColor = this.getPriorityColor(node.priority);
const dims = this.getNodeDimensions(node.node_type);
const qualityLabel = this.getQualityLabel(node);
const isOrphan = !node.parent_id && node.node_type !== 'pillar';
elements.push({
data: {
id: node.id,
@@ -453,9 +505,12 @@ export class GraphViewComponent implements OnInit, OnDestroy {
measurable: node.measurable,
consistent: node.consistent,
singlePurpose: node.single_purpose,
parent_id: node.parent_id || null,
hasGiteaLink: !!node.gitea_issue_number,
giteaBadge: this.getGiteaBadgeDataUri(!!node.gitea_issue_number),
orphan: isOrphan,
},
classes: isOrphan ? 'orphan' : undefined,
});
if (node.parent_id) {
@@ -490,7 +545,42 @@ export class GraphViewComponent implements OnInit, OnDestroy {
this.cy.elements().remove();
this.cy.add(elements);
// Try restoring saved positions; fall back to dagre layout
// Try restoring from DB autosave first, then localStorage, then dagre
this.reqService.getAutosaveLayout().pipe(takeUntil(this.destroy$)).subscribe({
next: (autosave) => {
const vp = autosave.viewport as Record<string, any>;
if (this.restoreFromLayout(autosave.node_positions, vp)) {
this.updateNodeAppearance();
this.restoreSelectedNode(vp);
} else {
this.restoreWithLocalStorageOrDagre();
}
},
error: () => {
// No autosave in DB — fall back to localStorage / dagre
this.restoreWithLocalStorageOrDagre();
},
});
}
private restoreFromLayout(
positions: Record<string, { x: number; y: number }>,
viewport: Record<string, any>,
): boolean {
let restored = 0;
const total = this.cy.nodes().length;
this.cy.nodes().forEach((node: NodeSingular) => {
const pos = positions[node.id()];
if (pos) { node.position(pos); restored++; }
});
if (restored !== total) return false;
this.restoringViewport = true;
this.cy.viewport({ zoom: viewport['zoom'], pan: viewport['pan'] });
this.restoringViewport = false;
return true;
}
private restoreWithLocalStorageOrDagre(): void {
if (this.restoreNodePositions()) {
if (!this.restoreViewport()) {
this.cy.fit(undefined, 40);
@@ -499,7 +589,6 @@ export class GraphViewComponent implements OnInit, OnDestroy {
this.restoreSelectedNode();
} else {
this.restoringViewport = true;
// Exclude satisfaction links from layout so they don't pull nodes out of tree order
const layoutEles = this.cy.elements().filter((ele: any) => {
if (ele.isEdge() && ele.data('edgeType') === 'also-satisfies') return false;
return true;
@@ -524,12 +613,13 @@ export class GraphViewComponent implements OnInit, OnDestroy {
}
}
private restoreSelectedNode(): void {
const savedId = localStorage.getItem('req-planner-selected-node');
private restoreSelectedNode(viewport?: Record<string, any>): void {
// Try DB autosave state first, then localStorage
const savedId = viewport?.['selected_node_id'] ?? localStorage.getItem(this.dbKey('selected-node'));
if (!savedId) return;
const cyNode = this.cy.getElementById(savedId);
if (cyNode.length) {
const panelWasOpen = localStorage.getItem('req-planner-panel-open') === 'true';
const panelWasOpen = viewport?.['panel_open'] ?? localStorage.getItem(this.dbKey('panel-open')) === 'true';
this.loadNodeDetail(savedId, panelWasOpen);
}
}
@@ -691,8 +781,9 @@ export class GraphViewComponent implements OnInit, OnDestroy {
}, { duration: 300 });
}
}
localStorage.setItem('req-planner-selected-node', nodeId);
localStorage.setItem('req-planner-panel-open', String(this.detailOpen));
localStorage.setItem(this.dbKey('selected-node'), nodeId);
localStorage.setItem(this.dbKey('panel-open'), String(this.detailOpen));
this.autosave$.next();
this.cdr.markForCheck();
},
});
@@ -1095,7 +1186,8 @@ export class GraphViewComponent implements OnInit, OnDestroy {
closeDetail(): void {
this.detailOpen = false;
localStorage.setItem('req-planner-panel-open', 'false');
localStorage.setItem(this.dbKey('panel-open'), 'false');
this.autosave$.next();
const zoom = this.cy.zoom();
const pan = { ...this.cy.pan() };
requestAnimationFrame(() => {
@@ -1107,8 +1199,9 @@ export class GraphViewComponent implements OnInit, OnDestroy {
}
onNodeUpdated(updated: RequirementNode): void {
// Refresh the node in the graph
const cyNode = this.cy.getElementById(updated.id);
const oldParentId = cyNode.length ? cyNode.data('parent_id') : null;
if (cyNode.length) {
cyNode.data('label', this.getNodeLabel(updated as any));
cyNode.data('priority', updated.priority);
@@ -1119,7 +1212,13 @@ export class GraphViewComponent implements OnInit, OnDestroy {
cyNode.data('hasGiteaLink', !!updated.gitea_issue_number);
cyNode.data('giteaBadge', this.getGiteaBadgeDataUri(!!updated.gitea_issue_number));
}
this.updateNodeAppearance();
// If parent changed, reload tree to rebuild edges
if (updated.parent_id !== oldParentId) {
this.loadTree();
} else {
this.updateNodeAppearance();
}
}
onNodeCreated(): void {
@@ -1149,13 +1248,15 @@ export class GraphViewComponent implements OnInit, OnDestroy {
}
relayout(): void {
localStorage.removeItem('req-planner-node-positions');
// Exclude satisfaction links from layout so they don't pull nodes out of tree order
const layoutEles = this.cy.elements().filter((ele: any) => {
localStorage.removeItem(this.dbKey('node-positions'));
const orphanNodes = this.cy.nodes('[?orphan]');
// Layout the tree (non-orphan) elements
const treeEles = this.cy.elements().filter((ele: any) => {
if (ele.isNode() && ele.data('orphan')) return false;
if (ele.isEdge() && ele.data('edgeType') === 'also-satisfies') return false;
return true;
});
layoutEles.layout({
treeEles.layout({
name: 'dagre',
rankDir: 'TB',
nodeSep: 60,
@@ -1164,11 +1265,44 @@ export class GraphViewComponent implements OnInit, OnDestroy {
animate: true,
animationDuration: 300,
stop: () => {
this.positionOrphansBelow(orphanNodes);
this.saveNodePositions();
},
} as any).run();
}
groupOrphans(): void {
const orphanNodes = this.cy.nodes('[?orphan]');
if (orphanNodes.length === 0) return;
this.positionOrphansBelow(orphanNodes);
this.saveNodePositions();
}
private positionOrphansBelow(orphanNodes: any): void {
if (orphanNodes.length === 0) return;
// Find the bounding box of all non-orphan nodes
const nonOrphans = this.cy.nodes().filter((n: NodeSingular) => !n.data('orphan'));
let bottomY = 0;
let centerX = 0;
if (nonOrphans.length > 0) {
const bb = nonOrphans.boundingBox();
bottomY = bb.y2 + 120;
centerX = (bb.x1 + bb.x2) / 2;
}
// Grid layout for orphans
const cols = Math.ceil(Math.sqrt(orphanNodes.length));
const spacingX = 200;
const spacingY = 100;
const startX = centerX - ((cols - 1) * spacingX) / 2;
orphanNodes.forEach((node: NodeSingular, i: number) => {
const col = i % cols;
const row = Math.floor(i / cols);
node.animate({
position: { x: startX + col * spacingX, y: bottomY + row * spacingY },
}, { duration: 300 });
});
}
// ─── Search Typeahead ──────────────────────────────────────────
private buildSearchIndex(tree: RequirementTreeNode[]): void {

View File

@@ -0,0 +1,235 @@
import {
Component,
Input,
Output,
EventEmitter,
ChangeDetectionStrategy,
ChangeDetectorRef,
inject,
OnInit,
OnDestroy,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { RequirementService } from '../core/services/requirement.service';
import { Subject, takeUntil } from 'rxjs';
@Component({
selector: 'app-node-picker',
standalone: true,
imports: [CommonModule, FormsModule],
template: `
<div class="typeahead-container">
<div class="typeahead-input-row">
<input
class="typeahead-input"
[(ngModel)]="query"
[placeholder]="placeholder"
(input)="onInput()"
(focus)="onFocus()"
(keydown)="onKeydown($event)"
(blur)="onBlur()"
autocomplete="off"
/>
<button
class="typeahead-submit"
[class.primary]="submitStyle === 'primary'"
[disabled]="!query.trim()"
(click)="submit()"
>{{ submitLabel }}</button>
</div>
@if (showDropdown && filtered.length > 0) {
<div class="typeahead-dropdown">
@for (item of filtered; track item.id; let i = $index) {
<div
class="typeahead-option"
[class.highlighted]="i === highlightedIndex"
(mousedown)="selectItem(item)"
>
<span class="typeahead-id">{{ item.id }}</span>
<span class="typeahead-title">{{ item.title }}</span>
</div>
}
</div>
}
</div>
`,
styles: [`
.typeahead-container {
position: relative;
}
.typeahead-input-row {
display: flex;
gap: 6px;
align-items: center;
}
.typeahead-input {
flex: 1;
background: var(--bg-tertiary);
border: 1px solid var(--border);
border-radius: 4px;
padding: 5px 8px;
font-size: 13px;
color: var(--text-primary);
outline: none;
&:focus { border-color: var(--accent); }
}
.typeahead-submit {
background: var(--bg-tertiary);
border: 1px solid var(--border);
border-radius: 4px;
padding: 5px 12px;
font-size: 12px;
color: var(--text-secondary);
cursor: pointer;
white-space: nowrap;
&:hover { color: var(--text-primary); border-color: var(--text-muted); }
&:disabled { opacity: 0.4; cursor: default; }
&.primary {
color: var(--accent);
border-color: var(--accent);
&:hover { background: rgba(88, 166, 255, 0.1); }
}
}
.typeahead-dropdown {
position: absolute;
left: 0;
right: 0;
top: 100%;
margin-top: 2px;
background: var(--bg-tertiary);
border: 1px solid var(--border);
border-radius: 6px;
max-height: 180px;
overflow-y: auto;
z-index: 30;
}
.typeahead-option {
display: flex;
gap: 8px;
padding: 6px 8px;
cursor: pointer;
font-size: 12px;
&:hover, &.highlighted { background: rgba(88, 166, 255, 0.1); }
}
.typeahead-id {
font-weight: 600;
color: var(--accent);
font-family: monospace;
flex-shrink: 0;
}
.typeahead-title {
color: var(--text-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
`],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class NodePickerComponent implements OnInit, OnDestroy {
@Input() placeholder = 'Search by ID or title...';
@Input() submitLabel = 'Select';
@Input() submitStyle: 'primary' | 'default' = 'primary';
@Input() excludeIds: string[] = [];
@Output() picked = new EventEmitter<string>();
private reqService = inject(RequirementService);
private cdr = inject(ChangeDetectorRef);
private destroy$ = new Subject<void>();
query = '';
allItems: { id: string; title: string }[] = [];
filtered: { id: string; title: string }[] = [];
showDropdown = false;
highlightedIndex = -1;
private loaded = false;
ngOnInit(): void {
this.loadItems();
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
private loadItems(): void {
this.reqService.getRequirements().pipe(takeUntil(this.destroy$)).subscribe({
next: (reqs) => {
const excluded = new Set(this.excludeIds);
this.allItems = reqs
.filter(r => !excluded.has(r.id))
.map(r => ({ id: r.id, title: r.title }));
this.loaded = true;
this.cdr.markForCheck();
},
});
}
onInput(): void {
this.filter();
}
onFocus(): void {
if (!this.loaded) this.loadItems();
this.filter();
}
onBlur(): void {
// Delay to allow mousedown on dropdown items
setTimeout(() => {
this.showDropdown = false;
this.cdr.markForCheck();
}, 150);
}
onKeydown(event: KeyboardEvent): void {
if (!this.showDropdown) return;
if (event.key === 'ArrowDown') {
event.preventDefault();
this.highlightedIndex = Math.min(this.highlightedIndex + 1, this.filtered.length - 1);
} else if (event.key === 'ArrowUp') {
event.preventDefault();
this.highlightedIndex = Math.max(this.highlightedIndex - 1, 0);
} else if (event.key === 'Enter') {
if (this.highlightedIndex >= 0) {
event.preventDefault();
this.selectItem(this.filtered[this.highlightedIndex]);
} else {
this.submit();
}
}
}
selectItem(item: { id: string; title: string }): void {
this.query = item.id;
this.showDropdown = false;
this.highlightedIndex = -1;
this.cdr.markForCheck();
}
submit(): void {
const id = this.query.trim();
if (!id) return;
this.picked.emit(id);
this.query = '';
this.showDropdown = false;
this.highlightedIndex = -1;
this.cdr.markForCheck();
}
private filter(): void {
const q = this.query.toLowerCase().trim();
if (!q) {
this.filtered = this.allItems.slice(0, 10);
} else {
this.filtered = this.allItems
.filter(r => r.id.toLowerCase().includes(q) || r.title.toLowerCase().includes(q))
.slice(0, 10);
}
this.showDropdown = this.filtered.length > 0;
this.highlightedIndex = -1;
this.cdr.markForCheck();
}
}