Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions openspec/changes/sort-active-changes-by-progress/proposal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# Change: Sort Active Changes by Progress

## Problem
- The dashboard currently lists active changes in filesystem discovery order.
- Users cannot quickly spot proposals that have not started or are nearly complete.
- Inconsistent ordering between runs makes it harder to track progress when many changes exist.

## Proposal
1. Update the Active Changes list in the dashboard to sort by percentage of completion in ascending order so 0% items show first.
2. When two changes share the same completion percentage, break ties deterministically by change identifier (alphabetical).

## Benefits
- Highlights work that has not started yet, enabling quicker prioritization.
- Provides consistent ordering across machines and repeated runs.
- Keeps the dashboard compact while communicating the most important status signal.

## Risks & Mitigations
- **Risk:** Sorting logic could regress rendering when progress data is missing.
- **Mitigation:** Treat missing progress as 0% so items still surface and document behavior in tests.
- **Risk:** Additional sorting could impact performance for large change sets.
- **Mitigation:** The number of active changes is typically small; sorting a few entries is negligible.

## Success Criteria
- Dashboard output shows active changes ordered by ascending completion percentage with deterministic tie-breaking.
- Unit coverage verifying the sort when percentages vary and when ties occur.
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
## MODIFIED Requirements
### Requirement: Active Changes Display
The dashboard SHALL show active changes with visual progress indicators.

#### Scenario: Active changes ordered by completion percentage
- **WHEN** multiple active changes are displayed with progress information
- **THEN** list them sorted by completion percentage ascending so 0% items appear first
- **AND** treat missing progress values as 0% for ordering
- **AND** break ties by change identifier in ascending alphabetical order to keep output deterministic
8 changes: 8 additions & 0 deletions openspec/changes/sort-active-changes-by-progress/tasks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# Implementation Tasks

## 1. Dashboard Sorting Logic
- [x] 1.1 Update the Active Changes rendering to sort by completion percentage ascending.
- [x] 1.2 Treat missing progress as 0% and break ties alphabetically by change identifier.

## 2. Verification
- [x] 2.1 Add tests that cover different completion percentages and tie cases to confirm deterministic ordering.
7 changes: 7 additions & 0 deletions openspec/specs/cli-view/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,13 @@ The dashboard SHALL show active changes with visual progress indicators.
- **AND** visual progress bar using Unicode characters
- **AND** percentage completion on the right

#### Scenario: Active changes ordered by completion percentage

- **WHEN** multiple active changes are displayed with progress information
- **THEN** list them sorted by completion percentage ascending so 0% items appear first
- **AND** treat missing progress values as 0% for ordering
- **AND** break ties by change identifier in ascending alphabetical order to keep output deterministic

#### Scenario: No active changes

- **WHEN** all changes are completed or no changes exist
Expand Down
11 changes: 9 additions & 2 deletions src/core/view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,8 +95,15 @@ export class ViewCommand {
}
}

// Sort alphabetically
active.sort((a, b) => a.name.localeCompare(b.name));
// Sort active changes by completion percentage (ascending) and then by name for deterministic ordering
active.sort((a, b) => {
const percentageA = a.progress.total > 0 ? a.progress.completed / a.progress.total : 0;
const percentageB = b.progress.total > 0 ? b.progress.completed / b.progress.total : 0;

if (percentageA < percentageB) return -1;
if (percentageA > percentageB) return 1;
return a.name.localeCompare(b.name);
});
completed.sort((a, b) => a.name.localeCompare(b.name));

return { active, completed };
Expand Down
17 changes: 13 additions & 4 deletions test/core/update.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,22 +144,31 @@ More content after.`;
const claudePath = path.join(testDir, 'CLAUDE.md');
await fs.writeFile(claudePath, '<!-- OPENSPEC:START -->\nOld\n<!-- OPENSPEC:END -->');
await fs.chmod(claudePath, 0o444); // Read-only

const consoleSpy = vi.spyOn(console, 'log');
const errorSpy = vi.spyOn(console, 'error');

const originalWriteFile = FileSystemUtils.writeFile.bind(FileSystemUtils);
const writeSpy = vi.spyOn(FileSystemUtils, 'writeFile').mockImplementation(async (filePath, content) => {
if (filePath.endsWith('CLAUDE.md')) {
throw new Error('EACCES: permission denied, open');
}

return originalWriteFile(filePath, content);
});

// Execute update command - should not throw
await updateCommand.execute(testDir);

// Should report the failure
expect(errorSpy).toHaveBeenCalled();
expect(consoleSpy).toHaveBeenCalledWith(
'Updated OpenSpec instructions (README.md)\nFailed to update: CLAUDE.md'
);

// Restore permissions for cleanup
await fs.chmod(claudePath, 0o644);
consoleSpy.mockRestore();
errorSpy.mockRestore();
writeSpy.mockRestore();
});
});
79 changes: 79 additions & 0 deletions test/core/view.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { promises as fs } from 'fs';
import path from 'path';
import os from 'os';
import { ViewCommand } from '../../src/core/view.js';

const stripAnsi = (input: string): string => input.replace(/\u001b\[[0-9;]*m/g, '');

describe('ViewCommand', () => {
let tempDir: string;
let originalLog: typeof console.log;
let logOutput: string[] = [];

beforeEach(async () => {
tempDir = path.join(os.tmpdir(), `openspec-view-test-${Date.now()}`);
await fs.mkdir(tempDir, { recursive: true });

originalLog = console.log;
console.log = (...args: any[]) => {
logOutput.push(args.join(' '));
};

logOutput = [];
});

afterEach(async () => {
console.log = originalLog;
await fs.rm(tempDir, { recursive: true, force: true });
});

it('sorts active changes by completion percentage ascending with deterministic tie-breakers', async () => {
const changesDir = path.join(tempDir, 'openspec', 'changes');
await fs.mkdir(changesDir, { recursive: true });

await fs.mkdir(path.join(changesDir, 'gamma-change'), { recursive: true });
await fs.writeFile(
path.join(changesDir, 'gamma-change', 'tasks.md'),
'- [x] Done\n- [x] Also done\n- [ ] Not done\n'
);

await fs.mkdir(path.join(changesDir, 'beta-change'), { recursive: true });
await fs.writeFile(
path.join(changesDir, 'beta-change', 'tasks.md'),
'- [x] Task 1\n- [ ] Task 2\n'
);

await fs.mkdir(path.join(changesDir, 'delta-change'), { recursive: true });
await fs.writeFile(
path.join(changesDir, 'delta-change', 'tasks.md'),
'- [x] Task 1\n- [ ] Task 2\n'
);

await fs.mkdir(path.join(changesDir, 'alpha-change'), { recursive: true });
await fs.writeFile(
path.join(changesDir, 'alpha-change', 'tasks.md'),
'- [ ] Task 1\n- [ ] Task 2\n'
);

const viewCommand = new ViewCommand();
await viewCommand.execute(tempDir);

const activeLines = logOutput
.map(stripAnsi)
.filter(line => line.includes('◉'));

const activeOrder = activeLines.map(line => {
const afterBullet = line.split('◉')[1] ?? '';
return afterBullet.split('[')[0]?.trim();
});

expect(activeOrder).toEqual([
'alpha-change',
'beta-change',
'delta-change',
'gamma-change'
]);
});
});

Loading