Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
198 changes: 198 additions & 0 deletions docs/superpowers/plans/2026-07-29-sync-action-hierarchy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
# Sync Action Hierarchy TDD Plan

> **For agentic workers:** Implement each behavior in test-first order. Steps use
> checkbox (`- [ ]`) syntax for tracking.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

**Goal:** Make routine recent sync the clear primary action while protecting
full-history sync with an accurate explanation, confirmation, and end-to-end
single-flight behavior on web and mobile.

**Behavior:** People can start a recent seven-day sync directly, understand that
full history requests all history each provider makes available, cancel the
full-history confirmation first, and follow provider progress or exact errors
without accidentally starting duplicate work.

**Scope:** Cover the bulk sync actions on the web and mobile provider screens,
shared action semantics and copy, and lifecycle-scoped deduplication for an
initial full-history provider job. Preserve individual-provider actions,
provider-specific sync implementations, checkpoint request identities, and
existing reconciliation behavior.

**Docs:** [Issue #2178](https://github.com/Asherlc/dofek/issues/2178),
[BullMQ simple-mode deduplication](https://docs.bullmq.io/guide/jobs/deduplication),
[provider metadata package](../../../packages/providers-meta/README.md),
[web package](../../../packages/web/README.md),
[mobile package](../../../packages/mobile/README.md).

---

## Current Evidence

- Web renders `Sync All` and `Full Sync All` with equal visual weight and only
disables them while the trigger mutation is pending, so they become
actionable again while returned jobs are still being polled.
- Mobile renders the same two actions side by side and disables them through
local job polling, but a bulk trigger failure is captured without displaying
its exact message.
- `sinceDays: 7` becomes a bounded recent calendar window. Omitting the range
creates an epoch-to-now full window for every connected, configured,
sync-capable provider.
- Provider activity writes update matching records. A completed authoritative
activity-list fetch reconciles its exact window, which may hide an activity
removed upstream; it does not erase or rebuild unrelated Dofek data.
- Existing request-query job IDs deduplicate identical pending requests, but an
initial full sync has moving end/cursor values and can therefore receive a
different request key on rapid repeat submission.
- BullMQ simple-mode deduplication retains a deduplication identifier only while
its job is incomplete and releases it on completion or failure. This matches
the required initial-operation lifecycle without persistent custom state.

## Test Strategy

- Unit: prove shared recent/full semantics and accessible copy; prove only
user-triggered initial full jobs receive a lifecycle deduplication key while
checkpoint continuations retain distinct request identities.
- Integration: exercise BullMQ against Redis to prove pending initial full jobs
coalesce and completion/failure releases the lifecycle key for a later
legitimate full sync.
- Web: prove recent-primary hierarchy, full-history explanation, cancel-first
initial focus, Escape cancellation, whole-job disabled/progress state, exact
trigger errors, and accessible action names.
- Mobile: prove the equivalent hierarchy and copy, cancel-first presentation,
Android/iOS modal close handling, whole-job disabled/progress state, exact
trigger errors, and accessible action names.
- Stories/runtime: add default, confirmation, syncing, and error stories for
both focused controls; build both Storybooks and inspect responsive web and
mobile rendering.

## File Structure

- Create: `packages/providers-meta/src/sync-actions.ts` and its colocated unit
test for the canonical action contract.
- Modify: `packages/providers-meta/package.json` to export the isolated
`sync-actions` entry point.
- Modify: `src/jobs/sync-request-job.ts` and its colocated test for initial
full-operation BullMQ deduplication.
- Create: `src/jobs/sync-request-job.integration.test.ts` for real Redis
lifecycle behavior.
- Create: `packages/web/src/components/SyncAllControls.tsx`, test, and stories.
- Modify: `packages/web/src/components/DataSourcesPanel.tsx` and its test/story
integration.
- Create: `packages/mobile/app/providers/sync-all-controls.tsx`, test, and
stories.
- Modify: `packages/mobile/app/providers/index.tsx`, styles, and screen tests.

## Tasks

### Task 1: Add Shared Contract and Queue Tests

**Files:**

- Create: `packages/providers-meta/src/sync-actions.test.ts`
- Modify: `src/jobs/sync-request-job.test.ts`
- Create: `src/jobs/sync-request-job.integration.test.ts`

- [ ] Write failing tests for the seven-day routine contract and layman-readable
full-history range, request-cost, update/reconciliation, and non-erasure copy.
- [ ] Write failing tests that pending initial full jobs carry the same
lifecycle deduplication ID per provider/user, ordinary recent jobs retain
request-level deduplication, and checkpoint continuations do not reuse the
initial full-operation ID.
- [ ] Run `rtk pnpm vitest run packages/providers-meta/src/sync-actions.test.ts src/jobs/sync-request-job.test.ts`.
- [ ] Confirm failures identify only the missing shared contract and
deduplication behavior.

### Task 2: Implement Shared Contract and Queue Guard

**Files:**

- Create: `packages/providers-meta/src/sync-actions.ts`
- Modify: `packages/providers-meta/package.json`
- Modify: `src/jobs/sync-request-job.ts`

- [ ] Add the smallest typed shared action contract used by both platforms.
- [ ] Add BullMQ simple-mode deduplication only to initial full-history jobs,
keyed by provider and user.
- [ ] Leave checkpoint continuations on their existing request-query IDs.
- [ ] Re-run the focused tests and confirm they pass.
- [ ] Run the Redis-backed integration test through the repository integration
harness and confirm pending jobs coalesce while completion and failure each
release the key.

### Task 3: Add Failing Web Interaction Tests

**Files:**

- Create: `packages/web/src/components/SyncAllControls.test.tsx`
- Modify: `packages/web/src/components/DataSourcesPanel.test.tsx`

- [ ] Assert one primary recent action and one lower-emphasis full-history
action with explicit accessible names.
- [ ] Assert full-history explanation and confirmation are required.
- [ ] Assert Cancel is first and initially focused, Escape closes the dialog,
and focus returns to the full-history trigger.
- [ ] Assert mutation and active/polled job states disable both actions and
expose progress guidance.
- [ ] Assert the exact bulk trigger `error.message` is visible.
- [ ] Run the focused web tests and confirm expected failures.

### Task 4: Implement Web Hierarchy and Stories

**Files:**

- Create: `packages/web/src/components/SyncAllControls.tsx`
- Create: `packages/web/src/components/SyncAllControls.stories.tsx`
- Modify: `packages/web/src/components/DataSourcesPanel.tsx`
- Modify: `packages/web/src/components/DataSourcesPanel.stories.tsx`

- [ ] Implement the focused controls with the existing modal primitive.
- [ ] Keep controls disabled from trigger start through active job completion.
- [ ] Preserve provider-card progress while adding group progress/error
visibility.
- [ ] Add default, confirmation, syncing, and error stories.
- [ ] Re-run focused web tests until green.

### Task 5: Add Failing Mobile Interaction Tests

**Files:**

- Create: `packages/mobile/app/providers/sync-all-controls.test.tsx`
- Modify: `packages/mobile/app/providers/index.test.tsx`

- [ ] Assert parity with the web hierarchy, descriptions, and accessible names.
- [ ] Assert Cancel is first and modal request-close/back cancels without
starting work.
- [ ] Assert mutation and active/polled job states disable both actions and
expose progress guidance.
- [ ] Assert the exact bulk trigger `error.message` is visible.
- [ ] Run the focused mobile tests and confirm expected failures.

### Task 6: Implement Mobile Hierarchy and Stories

**Files:**

- Create: `packages/mobile/app/providers/sync-all-controls.tsx`
- Create: `packages/mobile/app/providers/sync-all-controls.stories.tsx`
- Modify: `packages/mobile/app/providers/index.tsx`
- Modify: `packages/mobile/app/providers/styles.ts`
- Modify: `packages/mobile/app/providers/index.stories.tsx`

- [ ] Implement the focused controls with the React Native modal primitive.
- [ ] Keep controls disabled from trigger start through active job completion.
- [ ] Preserve provider-card progress while adding group progress/error
visibility.
- [ ] Add default, confirmation, syncing, and error stories.
- [ ] Re-run focused mobile tests until green.

### Task 7: Final Verification

- [ ] Run focused shared, queue, web, and mobile tests.
- [ ] Run `rtk pnpm lint`.
- [ ] Run `rtk pnpm tsc --noEmit`.
- [ ] Run package-scoped web and mobile typechecks.
- [ ] Run `rtk pnpm storybook:web:build`.
- [ ] Run `rtk pnpm storybook:mobile:build`.
- [ ] Inspect responsive web and mobile stories for focus, hierarchy,
wrapping, progress, and error presentation.
- [ ] Push the branch, open a PR with `Fixes #2178`, monitor all required checks
and review threads, address actionable feedback, and merge.
64 changes: 44 additions & 20 deletions packages/mobile/app/providers/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -126,9 +126,17 @@ vi.mock("react-native", () => ({
visible?: boolean;
} & Record<string, unknown>) => {
if (!visible) return null;
const { animationType: _at, transparent: _t, onRequestClose: _orc, ...rest } = props;
const {
animationType: _at,
transparent: _t,
onRequestClose: _orc,
onShow: _os,
...rest
} = props;
return React.createElement("div", { role: "dialog", ...rest }, children);
},
AccessibilityInfo: { setAccessibilityFocus: vi.fn() },
findNodeHandle: () => 1,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
Image: ({
source: _source,
style: _style,
Expand Down Expand Up @@ -1054,11 +1062,12 @@ describe("ProvidersScreen", () => {
expect(stravaCard.queryByText("Full sync")).toBeNull();
});

it("renders Full Sync All button alongside Sync All", async () => {
it("renders recent sync as primary with full history as the secondary action", async () => {
await renderProvidersScreen();

expect(screen.getByText("Sync All")).toBeTruthy();
expect(screen.getByText("Full Sync All")).toBeTruthy();
expect(screen.getByText("Sync recent data")).toBeTruthy();
expect(screen.getByText("Updates the last 7 days for every connected provider.")).toBeTruthy();
expect(screen.getByText("Sync full history…")).toBeTruthy();
});

it("renders dataset-level processing progress on the provider list", async () => {
Expand Down Expand Up @@ -1475,7 +1484,7 @@ describe("ProvidersScreen", () => {
);

const rendered = await renderProvidersScreen();
fireEvent.click(screen.getByText("Sync All"));
fireEvent.click(screen.getByText("Sync recent data"));
rendered.unmount();

await act(async () => {
Expand All @@ -1484,6 +1493,18 @@ describe("ProvidersScreen", () => {
});
});

it("shows the exact bulk sync startup error", async () => {
const error = new Error("Provider queues unavailable. Try again.");
mockSyncMutateAsync.mockRejectedValue(error);

await renderProvidersScreen();
fireEvent.click(screen.getByText("Sync recent data"));

await waitFor(() => {
expect(screen.getByText(error.message)).toBeTruthy();
});
});

it("shows provider cooldown outcome without polling a fake job", async () => {
mockSyncMutateAsync.mockResolvedValue({
jobId: "job-skipped",
Expand Down Expand Up @@ -1549,9 +1570,9 @@ describe("ProvidersScreen", () => {
await renderProvidersScreen();

await waitFor(() => {
expect(screen.getByText("Full Sync All").closest("button")?.hasAttribute("disabled")).toBe(
true,
);
expect(
screen.getByText("Sync full history…").closest("button")?.hasAttribute("disabled"),
).toBe(true);
});

const wahooCard = within(screen.getByTestId("provider-card-wahoo"));
Expand All @@ -1560,7 +1581,7 @@ describe("ProvidersScreen", () => {
await waitFor(() => {
expect(wahooCard.getByText("Provider sync skipped: rate-limit cooldown active")).toBeTruthy();
});
expect(screen.getByText("Full Sync All").closest("button")?.hasAttribute("disabled")).toBe(
expect(screen.getByText("Sync full history…").closest("button")?.hasAttribute("disabled")).toBe(
true,
);
});
Expand Down Expand Up @@ -1588,7 +1609,7 @@ describe("ProvidersScreen", () => {
});
});

it("passes sinceDays: 7 when Sync All is clicked", async () => {
it("passes sinceDays: 7 when recent sync is clicked", async () => {
mockSyncMutateAsync.mockResolvedValue({ jobId: "job-3", providerJobs: [] });
mockSyncStatusFetch.mockResolvedValue({
status: "done",
Expand All @@ -1597,7 +1618,7 @@ describe("ProvidersScreen", () => {

await renderProvidersScreen();

fireEvent.click(screen.getByText("Sync All"));
fireEvent.click(screen.getByText("Sync recent data"));

await waitFor(() => {
expect(mockSyncMutateAsync).toHaveBeenCalledWith({ sinceDays: 7 });
Expand All @@ -1620,7 +1641,7 @@ describe("ProvidersScreen", () => {

await renderProvidersScreen();

fireEvent.click(screen.getByText("Sync All"));
fireEvent.click(screen.getByText("Sync recent data"));

const wahooCard = within(screen.getByTestId("provider-card-wahoo"));
await waitFor(() => {
Expand All @@ -1645,7 +1666,7 @@ describe("ProvidersScreen", () => {

await renderProvidersScreen();

fireEvent.click(screen.getByText("Sync All"));
fireEvent.click(screen.getByText("Sync recent data"));

const wahooCard = within(screen.getByTestId("provider-card-wahoo"));
await waitFor(() => {
Expand Down Expand Up @@ -1700,7 +1721,7 @@ describe("ProvidersScreen", () => {

await renderProvidersScreen();

fireEvent.click(screen.getByText("Sync All"));
fireEvent.click(screen.getByText("Sync recent data"));

await waitFor(() => {
expect(mockSyncStatusFetch).toHaveBeenCalledWith(
Expand All @@ -1714,7 +1735,7 @@ describe("ProvidersScreen", () => {
});
});

it("passes sinceDays: undefined when Full Sync All is clicked", async () => {
it("confirms before passing sinceDays: undefined for full history", async () => {
mockSyncMutateAsync.mockResolvedValue({ jobId: "job-4", providerJobs: [] });
mockSyncStatusFetch.mockResolvedValue({
status: "done",
Expand All @@ -1723,7 +1744,10 @@ describe("ProvidersScreen", () => {

await renderProvidersScreen();

fireEvent.click(screen.getByText("Full Sync All"));
fireEvent.click(screen.getByText("Sync full history…"));
expect(screen.getByText(/all history each connected provider makes available/i)).toBeTruthy();
expect(mockSyncMutateAsync).not.toHaveBeenCalled();
fireEvent.click(screen.getByText("Start full sync"));

await waitFor(() => {
expect(mockSyncMutateAsync).toHaveBeenCalledWith({ sinceDays: undefined });
Expand Down Expand Up @@ -2199,8 +2223,8 @@ describe("ProvidersScreen", () => {

await renderProvidersScreen();

// Sync All button should not appear when only import-only providers exist
expect(screen.queryByText("Sync All")).toBeNull();
// Bulk sync controls should not appear when only import-only providers exist.
expect(screen.queryByText("Sync recent data")).toBeNull();
});

it("does not render Sync or Full sync for push-only providers", async () => {
Expand All @@ -2226,8 +2250,8 @@ describe("ProvidersScreen", () => {

await renderProvidersScreen();

expect(screen.queryByText("Sync All")).toBeNull();
expect(screen.queryByText("Full Sync All")).toBeNull();
expect(screen.queryByText("Sync recent data")).toBeNull();
expect(screen.queryByText("Sync full history…")).toBeNull();
});

it("passes readBlob that uses Expo file blobs without wrapping bytes", async () => {
Expand Down
Loading
Loading