diff --git a/openspec/changes/sort-active-changes-by-progress/proposal.md b/openspec/changes/sort-active-changes-by-progress/proposal.md new file mode 100644 index 00000000..7dbfb215 --- /dev/null +++ b/openspec/changes/sort-active-changes-by-progress/proposal.md @@ -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. diff --git a/openspec/changes/sort-active-changes-by-progress/specs/cli-view/spec.md b/openspec/changes/sort-active-changes-by-progress/specs/cli-view/spec.md new file mode 100644 index 00000000..75879879 --- /dev/null +++ b/openspec/changes/sort-active-changes-by-progress/specs/cli-view/spec.md @@ -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 diff --git a/openspec/changes/sort-active-changes-by-progress/tasks.md b/openspec/changes/sort-active-changes-by-progress/tasks.md new file mode 100644 index 00000000..1ac9c24e --- /dev/null +++ b/openspec/changes/sort-active-changes-by-progress/tasks.md @@ -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. diff --git a/openspec/specs/cli-view/spec.md b/openspec/specs/cli-view/spec.md index c79ef823..72493e3d 100644 --- a/openspec/specs/cli-view/spec.md +++ b/openspec/specs/cli-view/spec.md @@ -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 diff --git a/src/core/view.ts b/src/core/view.ts index 1f7bc991..0a80e619 100644 --- a/src/core/view.ts +++ b/src/core/view.ts @@ -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 }; diff --git a/test/core/update.test.ts b/test/core/update.test.ts index a6a4cbd1..31cbb2cc 100644 --- a/test/core/update.test.ts +++ b/test/core/update.test.ts @@ -144,22 +144,31 @@ More content after.`; const claudePath = path.join(testDir, 'CLAUDE.md'); await fs.writeFile(claudePath, '\nOld\n'); 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(); }); }); \ No newline at end of file diff --git a/test/core/view.test.ts b/test/core/view.test.ts new file mode 100644 index 00000000..7b68f2eb --- /dev/null +++ b/test/core/view.test.ts @@ -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' + ]); + }); +}); +