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
1 change: 1 addition & 0 deletions .github/workflows/build-desktop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ jobs:
- run: npm run test:brand-assets
- run: npm run test:desktop-workflow
- run: npm run test:electron
- run: npm run test:mac-signing
- run: npm run test:release-artifacts
- run: npm run test:release-tag
- run: npm run test:packaged-resources
Expand Down
256 changes: 256 additions & 0 deletions docs/superpowers/plans/2026-07-27-reminder-completion-flow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,256 @@
# Reminder Completion Flow Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Make a completed pet reminder advance directly to the next queued reminder or close immediately when the queue is empty.

**Architecture:** `ActionCard` will expose an optional `onCompleted` callback that runs only after `controller.complete` succeeds. `CatReminderBubble` will use that callback to clear the action layer and inspect the controller's fresh queue snapshot, preserving the existing standalone confirmation behavior for every caller that omits the callback.

**Tech Stack:** React 19, TypeScript, Vitest, Testing Library

## Global Constraints

- Do not render a completed-status layer inside the pet reminder bubble.
- A queued successor must open at its first-level due actions.
- The final completion must close the bubble and return focus to the pet.
- A rejected completion must keep the current action available for retry.
- Completion must remain single-flight.
- Do not change standalone `ActionCard` behavior when `onCompleted` is omitted.

---

### Task 1: Successful-completion callback

**Files:**
- Modify: `src/features/reminders/components/ActionCard.tsx`
- Test: `src/features/reminders/components/ActionCard.test.tsx`

**Interfaces:**
- Consumes: `controller.complete(reminder.id): Promise<void>`
- Produces: optional prop `onCompleted?: (() => void) | undefined`

- [ ] **Step 1: Write the failing callback tests**

Add a render helper that accepts `onCompleted`, then cover success and rejection:

```tsx
test('hands successful completion to its host without rendering confirmation', async () => {
const onCompleted = vi.fn();
const controller = createAppController(createFakeDependencies({ now: NOW }));
controller.complete = vi.fn(async () => undefined);
render(actionView(controller, reminder, 'zh-CN', onCompleted));

fireEvent.click(screen.getByRole('button', { name: '完成了' }));

await waitFor(() => expect(onCompleted).toHaveBeenCalledTimes(1));
expect(screen.queryByRole('status')).not.toBeInTheDocument();
});

test('does not notify its host when completion fails', async () => {
const onCompleted = vi.fn();
const controller = createAppController(createFakeDependencies({ now: NOW }));
controller.complete = vi.fn(async () => { throw new Error('save failed'); });
render(actionView(controller, reminder, 'zh-CN', onCompleted));

fireEvent.click(screen.getByRole('button', { name: '完成了' }));

await waitFor(() => expect(screen.getByRole('button', { name: '完成了' })).toBeEnabled());
expect(onCompleted).not.toHaveBeenCalled();
});
```

- [ ] **Step 2: Run the focused tests and verify RED**

Run:

```bash
npm run test:run -- src/features/reminders/components/ActionCard.test.tsx
```

Expected: FAIL because `ActionCard` does not accept or invoke `onCompleted`.

- [ ] **Step 3: Implement the minimal callback**

Extend `ActionCardProps` and the successful branch:

```tsx
interface ActionCardProps {
reminder: Reminder;
countdownEndsAt?: number | undefined;
onCountdownStarted?: ((endsAt: number) => void) | undefined;
onCompleted?: (() => void) | undefined;
completionAction?: {
label: string;
onActivate: () => void;
};
}

await controller.complete(reminder.id);
if (onCompleted === undefined) setCompleted(true);
else onCompleted();
```

Do not invoke the callback from `catch` or `finally`.

- [ ] **Step 4: Run the focused tests and verify GREEN**

Run:

```bash
npm run test:run -- src/features/reminders/components/ActionCard.test.tsx
```

Expected: all `ActionCard` tests pass.

- [ ] **Step 5: Commit the callback unit**

```bash
git add src/features/reminders/components/ActionCard.tsx src/features/reminders/components/ActionCard.test.tsx
git commit -m "feat: expose successful reminder completion"
```

### Task 2: Direct pet reminder transition

**Files:**
- Modify: `src/features/cat/components/CatReminderBubble.tsx`
- Test: `src/features/cat/components/CatReminderBubble.test.tsx`

**Interfaces:**
- Consumes: `ActionCard` prop `onCompleted?: (() => void) | undefined`
- Consumes: `controller.getSnapshot().scheduler.dueQueue`
- Produces: direct queue advancement or `onRequestClose()`

- [ ] **Step 1: Replace the third-layer tests with failing direct-flow tests**

Keep the existing stateful queue controller and assert the desired visible behavior:

```tsx
test('advances directly to the next queued reminder after completion', async () => {
const user = userEvent.setup();
const controller = statefulQueueController();
renderBubble({ controller });
await user.click(screen.getByRole('button', { name: '现在做' }));

await user.click(screen.getByRole('button', { name: '完成了' }));

expect(screen.queryByRole('status')).not.toBeInTheDocument();
expect(screen.getByRole('dialog')).toHaveAccessibleName('喝水提醒');
expect(screen.getByRole('button', { name: '现在做' })).toHaveFocus();
});

test('closes immediately after completing the final queued reminder', async () => {
const user = userEvent.setup();
const onRequestClose = vi.fn();
const controller = statefulQueueController([reminder('lookAway', 20)]);
renderBubble({ controller, onRequestClose });
await user.click(screen.getByRole('button', { name: '现在做' }));

await user.click(screen.getByRole('button', { name: '完成了' }));

expect(screen.queryByRole('status')).not.toBeInTheDocument();
expect(onRequestClose).toHaveBeenCalledTimes(1);
expect(screen.getByRole('button', { name: '猫咪' })).toHaveFocus();
});
```

Update the single-flight test to expect the bubble transition after its controlled promise resolves instead of expecting a status element.

- [ ] **Step 2: Run the bubble tests and verify RED**

Run:

```bash
npm run test:run -- src/features/cat/components/CatReminderBubble.test.tsx
```

Expected: FAIL because the current implementation renders a status layer and explicit continuation control.

- [ ] **Step 3: Implement direct transition and close**

Remove `nextReminderIsWaiting`, `leaveCompletedAction`, and `completionAction`. Supply:

```tsx
onCompleted={() => {
setActionOpen(false);
setActionReminder(undefined);
setActionOccurrenceKey(undefined);
setCountdown(undefined);
setSnoozeOpen(false);
if (controller.getSnapshot().scheduler.dueQueue.length === 0) {
onRequestClose();
returnFocusRef.current?.focus();
}
}}
```

When a successor remains, clearing `actionOpen` makes `displayedReminder` follow the queue head and the existing focus effect selects its first action.

- [ ] **Step 4: Run both component suites and verify GREEN**

Run:

```bash
npm run test:run -- \
src/features/reminders/components/ActionCard.test.tsx \
src/features/cat/components/CatReminderBubble.test.tsx
```

Expected: both files pass with no unhandled rejection or React `act` warning.

- [ ] **Step 5: Commit the pet flow**

```bash
git add src/features/cat/components/CatReminderBubble.tsx src/features/cat/components/CatReminderBubble.test.tsx
git commit -m "fix: close completed pet reminder layers"
```

### Task 3: Release verification and PR update

**Files:**
- No production files
- Existing PR: `#3`

**Interfaces:**
- Consumes: the two committed component changes
- Produces: verified `0.2.1` branch state ready for GitHub native packaging

- [ ] **Step 1: Run the complete desktop release gate**

Run:

```bash
npm run check:desktop-release
```

Expected:

- release configuration, workflow, Electron, signing, artifact, tag, and packaged-resource tests pass
- all Vitest application tests pass
- all 40 Playwright tests pass
- web and desktop production builds pass

- [ ] **Step 2: Push the implementation commits**

```bash
git push origin feat/cross-platform-desktop-release
```

- [ ] **Step 3: Wait for PR `#3` CI**

Confirm the `Build Desktop` pull-request run completes successfully before merging.

- [ ] **Step 4: Merge and publish `v0.2.1`**

Merge PR `#3`, create annotated tag `v0.2.1` on the resulting `main` commit, and push the tag.

- [ ] **Step 5: Verify native release artifacts**

Confirm all package jobs and the release job pass, then verify the public Release contains exactly:

```text
Codex-Pet-Pause-0.2.1-mac-arm64.dmg
Codex-Pet-Pause-0.2.1-mac-x64.dmg
Codex-Pet-Pause-0.2.1-windows-x64.exe
Codex-Pet-Pause-0.2.1-linux-x64.AppImage
Codex-Pet-Pause-0.2.1-linux-x64.deb
```
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# Reminder Completion Flow Design

## Goal

Remove the extra completion-confirmation layer from the desktop pet reminder flow.
Completing a reminder should either advance directly to the next queued reminder or
close the reminder bubble when the queue is empty.

## Behavior

- The user opens a due reminder from the pet and chooses "Do it now."
- The action card keeps countdown and completion controls unchanged.
- Clicking "Complete" remains single-flight while the controller command is pending.
- After a successful completion:
- If another reminder is queued, the bubble immediately shows that reminder's
first-level due actions.
- If the queue is empty, the bubble closes and focus returns to the pet.
- The flow never renders a completed-status layer, a "Continue" button, or a
"Close" button inside the pet reminder bubble.
- If completion fails, the current action remains visible and the completion button
becomes available for retry.

## Component Boundary

`ActionCard` will accept an optional successful-completion callback. When supplied,
it invokes the callback after `controller.complete` resolves instead of entering its
local completed-status state. Existing callers that omit the callback retain the
current completion confirmation.

`CatReminderBubble` will supply that callback, clear its action state, inspect the
controller's fresh queue snapshot, and either reveal the next reminder or request
that the bubble close.

## Accessibility

- Advancing the queue focuses the next reminder's first action.
- Closing the final reminder returns focus to the pet.
- A pending completion remains disabled to prevent duplicate commands.

## Tests

- A single completed reminder closes immediately without rendering a status layer.
- A completed reminder with a queued successor advances directly to the successor.
- A rejected completion keeps the current action available for retry.
- Existing single-flight completion behavior remains covered.
5 changes: 2 additions & 3 deletions e2e/accessibility.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,9 +100,8 @@ test('completes a reminder and reaches all four views with keyboard only', async
await page.keyboard.press('Enter');
await expect(page.getByRole('button', { name: '完成了' })).toBeFocused();
await page.keyboard.press('Enter');
await expect(page.getByText('喝水已完成')).toBeFocused();
await page.keyboard.press('Tab');
await page.keyboard.press('Enter');
await expect(page.getByRole('dialog')).toHaveCount(0);
await expect(page.getByTestId('cat-stage')).toBeFocused();

for (const [buttonName, headingName] of [
['提醒', '提醒'], ['宠物', '宠物库'], ['设置', '设置'], ['陪伴', '团子 在陪你'],
Expand Down
3 changes: 2 additions & 1 deletion e2e/localization.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -253,7 +253,8 @@ test('supports English settings navigation and a reminder action with the keyboa
await page.keyboard.press('Enter');
await expect(page.getByRole('button', { name: 'Done' })).toBeFocused();
await page.keyboard.press('Enter');
await expect(page.getByText('Drink water completed')).toBeFocused();
await expect(page.getByRole('dialog')).toHaveCount(0);
await expect(page.getByTestId('cat-stage')).toBeFocused();
});

for (const viewport of [
Expand Down
2 changes: 1 addition & 1 deletion e2e/persistence.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -361,7 +361,7 @@ test('clears local settings and IndexedDB history after confirmation', async ({
await openWaitingCat(page);
await page.getByRole('button', { name: '现在做' }).click();
await page.getByRole('button', { name: '完成了' }).click();
await page.getByRole('button', { name: '关闭' }).click();
await expect(page.getByRole('dialog')).toHaveCount(0);
await page.getByRole('button', { name: '设置', exact: true }).click();
await page.getByRole('button', { name: '清除全部本地数据' }).click();
await page.getByLabel('我了解本机数据将被删除').check();
Expand Down
2 changes: 0 additions & 2 deletions e2e/reminders.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,8 +95,6 @@ test('supports complete, snooze with 10-minute focus, and skip', async ({ page }
await expect(page.getByRole('dialog', { name: '目视远方提醒' })).toBeVisible();
await page.getByRole('button', { name: '现在做' }).click();
await page.getByRole('button', { name: '完成了' }).click();
await expect(page.getByRole('status', { name: '' }).filter({ hasText: '目视远方已完成' })).toBeVisible();
await page.getByRole('button', { name: '继续下一项' }).click();

await expect(page.getByRole('dialog', { name: '喝水提醒' })).toBeVisible();
await page.getByRole('button', { name: '稍后提醒' }).click();
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 4 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "codex-pet-pause",
"private": true,
"version": "0.2.0",
"version": "0.2.1",
"description": "A playful, local-first break reminder PWA with interactive and Codex-compatible pets.",
"license": "MIT",
"repository": {
Expand All @@ -28,6 +28,7 @@
"test": "vitest",
"test:run": "vitest run",
"test:electron": "vitest run --config vitest.electron.config.js",
"test:mac-signing": "node --test scripts/after-pack.test.mjs",
"test:e2e": "playwright test",
"check:release-dist": "node scripts/verify-release-dist.mjs",
"test:verify-release-dist": "node scripts/test-verify-release-dist.mjs",
Expand Down Expand Up @@ -62,7 +63,7 @@
"test:release-tag": "node --test scripts/verify-release-tag.test.mjs",
"test:packaged-resources": "node --test scripts/verify-packaged-resources.test.mjs",
"test:packaged-app": "node --test scripts/verify-packaged-app.test.mjs",
"check:desktop-release": "npm run test:desktop-release-config && npm run test:brand-assets && npm run test:desktop-workflow && npm run test:electron && npm run test:release-artifacts && npm run test:release-tag && npm run test:packaged-resources && npm run test:packaged-app && npm run check:desktop-release-config && npm run check:desktop-workflow && npm run typecheck && npm run test:run && npm run build && npm run test:e2e && npm run desktop:build && npm run desktop:smoke:packaged-resources"
"check:desktop-release": "npm run test:desktop-release-config && npm run test:brand-assets && npm run test:desktop-workflow && npm run test:electron && npm run test:mac-signing && npm run test:release-artifacts && npm run test:release-tag && npm run test:packaged-resources && npm run test:packaged-app && npm run check:desktop-release-config && npm run check:desktop-workflow && npm run typecheck && npm run test:run && npm run build && npm run test:e2e && npm run desktop:build && npm run desktop:smoke:packaged-resources"
},
"dependencies": {
"@zip.js/zip.js": "^2.8.34",
Expand Down Expand Up @@ -100,6 +101,7 @@
"appId": "io.elevenlabs.codexpetpause",
"productName": "Codex Pet Pause",
"asar": true,
"afterPack": "scripts/after-pack.mjs",
"files": [
"dist/**/*",
"electron/**/*",
Expand Down
Loading
Loading