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
47 changes: 47 additions & 0 deletions openspec/changes/fix-progress-merged-metrics/proposal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# Fix Progress "Completed today" to count merges, not closures

## Why

The sidebar Progress panel's "Completed today" counter increments on **any**
task removal, because `recordTaskCompleted()` is called unconditionally inside
`removeTaskFromStore()` (`src/store/tasks.ts:504`) - the convergence point for
all UI task-removal paths. Closing an empty or unmerged task therefore inflates
a counter that users read as "work I finished today."

The panel also pairs two metrics on different time axes: "Completed today"
(daily, resets) sits beside "Merged to main/master" (lifetime cumulative line
totals). Side by side, both read as "today," which misrepresents the cumulative
number.

## What changes

- Move the daily-counter increment from `removeTaskFromStore()` onto the UI
merge path: increment only inside `mergeTask()` when `cleanup === true` and
the merge succeeded. Closing a task no longer increments it.
- Rename the counter API to match its real meaning: `recordTaskCompleted` ->
`recordTaskMerged`, `getCompletedTasksTodayCount` -> `getMergedTasksTodayCount`.
Daily-reset logic is unchanged.
- Relabel the UI: "Completed today" -> "Merged today".
- Relabel the second card "Merged to main/master" -> "Merged (total)" to
disambiguate the time axis. **No data-model change** - it stays a lifetime
cumulative line total; no persisted-state migration.

## Out of scope (known limitations, stated deliberately)

- **Coordinator subtask merges** flow through the backend coordinator merge
(`electron/mcp/coordinator.ts`) -> `MCP_TaskClosed`, which removes the subtask
via an inline path that never touches the renderer `mergeTask()`. They are
uncounted today and remain uncounted. No regression; not addressed here.
- **Arena merges** call `IPC.MergeTask` directly, bypassing renderer
`mergeTask()`. Also uncounted today and after.
- Re-axising "Merged (total)" to a daily tally (the issue's secondary proposal)
is deferred - it requires a date key on the line totals plus a migration of
existing users' cumulative values.
- The contribution-calendar enhancement is a separate future proposal.

## Impact

- New capability `progress-metrics`.
- Files: `src/store/completion.ts`, `src/store/tasks.ts`,
`src/components/SidebarFooter.tsx`. Test mocks in `src/store/tasks.test.ts`
and `src/store/notifications.test.ts` reference the renamed function.
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# Progress Metrics Specification

## ADDED Requirements

### Requirement: "Merged today" counts only tasks merged with cleanup today

The sidebar Progress panel SHALL display a "Merged today" count that increments
only when a task is successfully merged through the UI merge path with cleanup
enabled. The count SHALL reset to zero at the start of each local calendar day.

#### Scenario: Merging a task with cleanup increments the count

- **WHEN** the user merges the active task through the UI and cleanup is enabled
- **AND** the merge succeeds
- **THEN** the "Merged today" count increases by one

#### Scenario: Closing an empty or unmerged task does not increment the count

- **WHEN** the user closes a task that was not merged (including an empty task,
an unmerged task, or a current-branch-mode task)
- **THEN** the "Merged today" count is unchanged

#### Scenario: Merging without cleanup does not increment the count

- **WHEN** the user merges a task but does not enable cleanup
- **THEN** the task remains and the "Merged today" count is unchanged

#### Scenario: Count resets on a new day

- **WHEN** the local calendar day has changed since the count was last recorded
- **THEN** the displayed "Merged today" count is zero until the next merge

### Requirement: "Merged (total)" shows lifetime merged line totals

The Progress panel SHALL display a "Merged (total)" card showing the cumulative
lines added and removed across all UI merges, persisted across sessions. The
label SHALL make the lifetime (non-daily) scope distinguishable from the
"Merged today" count.

#### Scenario: Merged line totals accumulate across sessions

- **WHEN** a task is merged through the UI with a non-zero diff
- **THEN** the added and removed line totals increase by that diff
- **AND** the totals persist across app restarts
19 changes: 19 additions & 0 deletions openspec/changes/fix-progress-merged-metrics/tasks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Tasks - Fix Progress "Completed today" to count merges, not closures

- [x] Rename `recordTaskCompleted` -> `recordTaskMerged` and
`getCompletedTasksTodayCount` -> `getMergedTasksTodayCount` in
`src/store/completion.ts`; keep the daily-reset logic unchanged.
- [x] Remove the unconditional `recordTaskCompleted()` call from
`removeTaskFromStore()` in `src/store/tasks.ts`.
- [x] Call `recordTaskMerged()` inside `mergeTask()` only when `cleanup === true`
and after the merge has succeeded (i.e. in the existing `cleanup` block,
paired with the successful merge result).
- [x] Update the renamed import in `src/store/tasks.ts` and update the function
mocks in `src/store/tasks.test.ts` and `src/store/notifications.test.ts`.
- [x] Relabel the Progress panel in `src/components/SidebarFooter.tsx`:
"Completed today" -> "Merged today", "Merged to main/master" ->
"Merged (total)"; update the memo name to `getMergedTasksTodayCount`.
- [x] Add/adjust a unit test asserting that closing an empty/unmerged task does
NOT increment the counter, and merging with cleanup does.
- [x] Validate with `npm run typecheck`, `npm test`, and
`openspec validate --all --strict`.
10 changes: 5 additions & 5 deletions src/components/SidebarFooter.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { createMemo, createEffect, onCleanup, Show } from 'solid-js';
import {
store,
getCompletedTasksTodayCount,
getMergedTasksTodayCount,
getMergedLineTotals,
toggleHelpDialog,
toggleArena,
Expand All @@ -14,7 +14,7 @@ import { sf } from '../lib/fontScale';
import { alt, mod } from '../lib/platform';

export function SidebarFooter() {
const completedTasksToday = createMemo(() => getCompletedTasksTodayCount());
const mergedTasksToday = createMemo(() => getMergedTasksTodayCount());
const mergedLines = createMemo(() => getMergedLineTotals());
const hasCoordinator = createMemo(() => hasAnyCoordinatorTask());

Expand Down Expand Up @@ -97,15 +97,15 @@ export function SidebarFooter() {
color: theme.fgMuted,
}}
>
<span>Completed today</span>
<span>Merged today</span>
<span
style={{
color: theme.fg,
'font-weight': '600',
'font-variant-numeric': 'tabular-nums',
}}
>
{completedTasksToday()}
{mergedTasksToday()}
</span>
</div>
<div
Expand All @@ -121,7 +121,7 @@ export function SidebarFooter() {
color: theme.fgMuted,
}}
>
<span>Merged to main/master</span>
<span>Merged (total)</span>
<span
style={{
color: theme.fg,
Expand Down
4 changes: 2 additions & 2 deletions src/store/completion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { produce } from 'solid-js/store';
import { getLocalDateKey } from '../lib/date';
import { store, setStore } from './core';

export function recordTaskCompleted(): void {
export function recordTaskMerged(): void {
const today = getLocalDateKey();
setStore(
produce((s) => {
Expand All @@ -16,7 +16,7 @@ export function recordTaskCompleted(): void {
);
}

export function getCompletedTasksTodayCount(): number {
export function getMergedTasksTodayCount(): number {
return store.completedTaskDate === getLocalDateKey() ? store.completedTaskCount : 0;
}

Expand Down
2 changes: 1 addition & 1 deletion src/store/notifications.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ vi.mock('./taskStatus', () => ({
}));
vi.mock('./completion', () => ({
recordMergedLines: vi.fn(),
recordTaskCompleted: vi.fn(),
recordTaskMerged: vi.fn(),
}));
vi.mock('../lib/log', () => ({ warn: vi.fn() }));
vi.mock('../lib/clean-task-name', () => ({ cleanTaskName: vi.fn() }));
Expand Down
2 changes: 1 addition & 1 deletion src/store/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,7 @@ export {
export type { TaskAttentionState, TaskDotStatus } from './taskStatus';
export { showNotification, clearNotification } from './notification';
export { startPrChecksSubscription, getPrChecks, type PrChecksState } from './pr-checks';
export { getCompletedTasksTodayCount, getMergedLineTotals } from './completion';
export { getMergedTasksTodayCount, getMergedLineTotals } from './completion';
export {
createTerminal,
closeTerminal,
Expand Down
70 changes: 69 additions & 1 deletion src/store/tasks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ vi.mock('./taskStatus', () => ({
}));
vi.mock('./completion', () => ({
recordMergedLines: vi.fn(),
recordTaskCompleted: vi.fn(),
recordTaskMerged: vi.fn(),
}));
vi.mock('../lib/log', () => ({ warn: vi.fn() }));
vi.mock('../lib/clean-task-name', () => ({ cleanTaskName: vi.fn() }));
Expand Down Expand Up @@ -133,6 +133,7 @@ import {
setTaskControl,
collapseTask,
closeTask,
mergeTask,
sendPrompt,
pasteDelayMs,
markTaskUserActivity,
Expand All @@ -147,6 +148,7 @@ import {
clearTaskLandingReview,
} from './tasks';
import { getCoordinatorChildren } from './sidebar-order';
import { recordTaskMerged } from './completion';
import { markAgentSpawned, rescheduleTaskStatusPolling } from './taskStatus';
import { saveState } from './persistence';
import { getProjectBranchPrefix, getProjectPath, isProjectMissing } from './projects';
Expand Down Expand Up @@ -987,6 +989,72 @@ describe('closeTask — IPC cleanup ordering', () => {
});
});

describe('recordTaskMerged counts merges with cleanup, not closures', () => {
beforeEach(() => {
vi.clearAllMocks();
mockSetStore.mockImplementation((...args: unknown[]) => applySetStore(...args));
mockInvoke.mockResolvedValue(undefined);
vi.mocked(getProjectPath).mockReturnValue('/repo');
});

it('closing an unmerged task does NOT increment the counter', async () => {
mockTasks['task-1'] = {
agentIds: [],
shellAgentIds: [],
gitIsolation: 'direct',
projectId: 'proj-1',
};

await closeTask('task-1');

expect(recordTaskMerged).not.toHaveBeenCalled();
});

it('merging with cleanup increments the counter once', async () => {
mockTasks['task-1'] = {
agentIds: [],
shellAgentIds: [],
gitIsolation: 'worktree',
projectId: 'proj-1',
branchName: 'task/task-1',
worktreePath: '/repo/.worktrees/task-1',
baseBranch: 'main',
};
mockInvoke.mockImplementation((channel: string) => {
if (channel === IPC.MergeTask) {
return Promise.resolve({ lines_added: 10, lines_removed: 2 });
}
return Promise.resolve(undefined);
});

await mergeTask('task-1', { cleanup: true });

expect(recordTaskMerged).toHaveBeenCalledTimes(1);
});

it('merging without cleanup does NOT increment the counter', async () => {
mockTasks['task-1'] = {
agentIds: [],
shellAgentIds: [],
gitIsolation: 'worktree',
projectId: 'proj-1',
branchName: 'task/task-1',
worktreePath: '/repo/.worktrees/task-1',
baseBranch: 'main',
};
mockInvoke.mockImplementation((channel: string) => {
if (channel === IPC.MergeTask) {
return Promise.resolve({ lines_added: 10, lines_removed: 2 });
}
return Promise.resolve(undefined);
});

await mergeTask('task-1', { cleanup: false });

expect(recordTaskMerged).not.toHaveBeenCalled();
});
});

describe('MCP_TaskStateSync listener', () => {
beforeEach(() => {
mockSetStore.mockImplementation((...args: unknown[]) => applySetStore(...args));
Expand Down
5 changes: 2 additions & 3 deletions src/store/tasks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import {
isAgentIdle,
rescheduleTaskStatusPolling,
} from './taskStatus';
import { recordMergedLines, recordTaskCompleted } from './completion';
import { recordMergedLines, recordTaskMerged } from './completion';
import { warn as logWarn } from '../lib/log';
import { cleanTaskName } from '../lib/clean-task-name';
import type {
Expand Down Expand Up @@ -501,8 +501,6 @@ const REMOVE_ANIMATION_MS = 300;
const RESTORED_AGENT_SPAWN_STAGGER_MS = 1_000;

function removeTaskFromStore(taskId: string, agentIds: string[]): void {
recordTaskCompleted();

// Stop the plan file watcher (fs.FSWatcher + poll interval) on the backend.
// This is the single convergence point for all task removal paths (close,
// merge+cleanup, current-branch-mode close), so placing it here prevents leaks
Expand Down Expand Up @@ -600,6 +598,7 @@ export async function mergeTask(
recordMergedLines(mergeResult.lines_added, mergeResult.lines_removed);

if (cleanup) {
recordTaskMerged();
await Promise.allSettled(
[...agentIds, ...shellAgentIds].map((id) => invoke(IPC.KillAgent, { agentId: id })),
);
Expand Down
Loading