diff --git a/docs/superpowers/plans/2026-07-29-sync-action-hierarchy.md b/docs/superpowers/plans/2026-07-29-sync-action-hierarchy.md new file mode 100644 index 0000000000..e6886acf02 --- /dev/null +++ b/docs/superpowers/plans/2026-07-29-sync-action-hierarchy.md @@ -0,0 +1,195 @@ +# Sync Action Hierarchy TDD Plan + +**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. diff --git a/packages/mobile/app/providers/index.test.tsx b/packages/mobile/app/providers/index.test.tsx index 259d859f51..bb2597536c 100644 --- a/packages/mobile/app/providers/index.test.tsx +++ b/packages/mobile/app/providers/index.test.tsx @@ -12,6 +12,7 @@ const mockSyncMutateAsync = vi.fn(); const mockImportSharedFile = vi.fn(); const mockGetDocumentAsync = vi.fn(); const mockAlert = vi.hoisted(() => vi.fn()); +const mockSendAccessibilityEvent = vi.hoisted(() => vi.fn()); vi.mock("react-native", () => ({ AppState: { @@ -50,45 +51,53 @@ vi.mock("react-native", () => ({ }, RefreshControl: () => null, Alert: { alert: mockAlert }, - TouchableOpacity: ({ - children, - onPress, - disabled, - ...props - }: { - children?: React.ReactNode; - onPress?: () => void; - disabled?: boolean; - } & Record) => { - const { - accessibilityLabel, - accessibilityRole, - accessibilityState, - style: _s, - activeOpacity: _ao, - testID, - ...rest - } = props; - const state = - typeof accessibilityState === "object" && accessibilityState !== null - ? accessibilityState - : {}; - return React.createElement( - "button", + TouchableOpacity: React.forwardRef( + ( { - type: "button", - onClick: onPress, + children, + onPress, disabled, - "aria-busy": "busy" in state ? state.busy : undefined, - "aria-disabled": "disabled" in state ? state.disabled : undefined, - "aria-label": accessibilityLabel, - role: accessibilityRole ?? "presentation", - ...(testID ? { "data-testid": testID } : {}), - ...rest, - }, - children, - ); - }, + ...props + }: { + children?: React.ReactNode; + onPress?: () => void; + disabled?: boolean; + } & Record, + ref: React.ForwardedRef, + ) => { + const { + accessibilityLabel, + accessibilityRole, + accessibilityState, + accessible, + style: _s, + activeOpacity: _ao, + testID, + ...rest + } = props; + const state = + typeof accessibilityState === "object" && accessibilityState !== null + ? accessibilityState + : {}; + return React.createElement( + "button", + { + type: "button", + ref, + onClick: onPress, + disabled, + "aria-busy": "busy" in state ? state.busy : undefined, + "aria-disabled": "disabled" in state ? state.disabled : undefined, + "aria-label": accessibilityLabel, + "data-accessible": accessible, + role: accessibilityRole ?? "presentation", + ...(testID ? { "data-testid": testID } : {}), + ...rest, + }, + children, + ); + }, + ), TextInput: ({ placeholder, value, @@ -125,10 +134,13 @@ vi.mock("react-native", () => ({ children?: React.ReactNode; visible?: boolean; } & Record) => { - if (!visible) return null; - const { animationType: _at, transparent: _t, onRequestClose: _orc, ...rest } = props; - return React.createElement("div", { role: "dialog", ...rest }, children); + const { animationType: _at, transparent: _t, onRequestClose: _orc, onShow, ...rest } = props; + React.useEffect(() => { + if (visible) onShow?.(); + }, [onShow, visible]); + return visible ? React.createElement("div", { role: "dialog", ...rest }, children) : null; }, + AccessibilityInfo: { sendAccessibilityEvent: mockSendAccessibilityEvent }, Image: ({ source: _source, style: _style, @@ -911,6 +923,7 @@ describe("ProvidersScreen", () => { mockImportSharedFile.mockReset(); mockGetDocumentAsync.mockReset(); mockAlert.mockReset(); + mockSendAccessibilityEvent.mockReset(); mockProvidersQuery.mockReset(); mockStatsQuery.mockReset(); mockLogsQuery.mockReset(); @@ -992,11 +1005,25 @@ describe("ProvidersScreen", () => { }); }); + it("does not render Full sync link for disconnected providers", async () => { + mockProvidersQuery.mockReturnValue({ + data: [disconnectedProvider], + isLoading: false, + error: null, + }); + + await renderProvidersScreen(); + + const stravaCard = within(screen.getByTestId("provider-card-strava")); + expect(stravaCard.queryByText("Full sync")).toBeNull(); + }); + it("renders Full Sync All button alongside Sync All", 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 () => { @@ -1413,7 +1440,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 () => { @@ -1422,6 +1449,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", @@ -1487,9 +1526,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")); @@ -1498,11 +1537,34 @@ 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, ); }); + it("passes sinceDays: undefined when Full sync link is clicked", async () => { + mockSyncMutateAsync.mockResolvedValue({ jobId: "job-2" }); + mockSyncStatusFetch.mockResolvedValue({ + status: "done", + providers: { wahoo: { status: "done" } }, + }); + + await renderProvidersScreen(); + + const wahooCard = within(screen.getByTestId("provider-card-wahoo")); + fireEvent.click(wahooCard.getByText("Full sync")); + + await waitFor(() => { + expect(mockSyncMutateAsync).toHaveBeenCalledWith({ + providerId: "wahoo", + sinceDays: undefined, + }); + }); + await waitFor(() => { + expect(mockSyncStatusFetch).toHaveBeenCalledWith({ jobId: "job-2" }, { staleTime: 0 }); + }); + }); + it("passes sinceDays: 7 when Sync All is clicked", async () => { mockSyncMutateAsync.mockResolvedValue({ jobId: "job-3", providerJobs: [] }); mockSyncStatusFetch.mockResolvedValue({ @@ -1512,7 +1574,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 }); @@ -1535,7 +1597,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(() => { @@ -1560,7 +1622,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(() => { @@ -1615,7 +1677,7 @@ describe("ProvidersScreen", () => { await renderProvidersScreen(); - fireEvent.click(screen.getByText("Sync All")); + fireEvent.click(screen.getByText("Sync recent data")); await waitFor(() => { expect(mockSyncStatusFetch).toHaveBeenCalledWith( @@ -1629,7 +1691,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", @@ -1638,7 +1700,14 @@ 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(); + const cancelButton = screen.getByText("Cancel").closest("button"); + await waitFor(() => + expect(mockSendAccessibilityEvent).toHaveBeenCalledWith(cancelButton, "focus"), + ); + fireEvent.click(screen.getByText("Start full sync")); await waitFor(() => { expect(mockSyncMutateAsync).toHaveBeenCalledWith({ sinceDays: undefined }); @@ -2113,8 +2182,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 a sync action for push-only providers", async () => { @@ -2139,8 +2208,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 () => { diff --git a/packages/mobile/app/providers/index.tsx b/packages/mobile/app/providers/index.tsx index e86d9d8826..d0cc6527bf 100644 --- a/packages/mobile/app/providers/index.tsx +++ b/packages/mobile/app/providers/index.tsx @@ -1,5 +1,6 @@ import { formatDateYmd } from "@dofek/format/format"; import type { ProviderStats } from "@dofek/providers/provider-stats"; +import { ROUTINE_SYNC_DAYS } from "@dofek/providers/sync-actions"; import * as DocumentPicker from "expo-document-picker"; import { File as ExpoFile } from "expo-file-system"; import { useLocalSearchParams, useRouter } from "expo-router"; @@ -52,6 +53,7 @@ import { SyncLogRow, } from "./provider-card.tsx"; import { styles } from "./styles.ts"; +import { SyncAllControls } from "./sync-all-controls.tsx"; async function readBlobFromFileUri(fileUri: string): Promise { const file = new ExpoFile(fileUri); @@ -115,6 +117,7 @@ export default function ProvidersScreen() { Record >({}); const [anySyncing, setAnySyncing] = useState(false); + const [syncAllError, setSyncAllError] = useState(); const [sharedImportState, setSharedImportState] = useState(null); const resumedJobIds = useRef(new Set()); const pollingJobIds = useRef(new Set()); @@ -436,7 +439,7 @@ export default function ProvidersScreen() { try { const result = await syncMutation.mutateAsync({ providerId, - sinceDays: 7, + sinceDays: fullSync ? undefined : ROUTINE_SYNC_DAYS, }); const providerResult = result.providerResults?.find( (entry) => entry.providerId === providerId, @@ -477,6 +480,7 @@ export default function ProvidersScreen() { const handleSyncAll = useCallback( async (fullSync = false) => { + setSyncAllError(undefined); const enabled = (providers.data ?? []).filter( (provider) => provider.authorized && !provider.importOnly && !provider.pushOnly, ); @@ -486,7 +490,7 @@ export default function ProvidersScreen() { setAnySyncing(true); try { const result = await syncMutation.mutateAsync({ - sinceDays: fullSync ? undefined : 7, + sinceDays: fullSync ? undefined : ROUTINE_SYNC_DAYS, }); const providerResults = result.providerResults ?? []; const providerJobMap = new Map( @@ -555,6 +559,7 @@ export default function ProvidersScreen() { } catch (error: unknown) { captureException(error, { context: "sync-all" }); if (!isMounted.current) return; + setSyncAllError(error instanceof Error ? error.message : "Sync failed"); setSyncingProviders(new Set()); setAnySyncing(false); } @@ -699,40 +704,13 @@ export default function ProvidersScreen() { /> } > - {/* Sync All */} {enabledProviders.length > 0 && ( - - handleSyncAll(false)} - activeOpacity={0.7} - disabled={anySyncing} - accessibilityRole="button" - accessibilityLabel="Sync All" - accessibilityState={{ busy: anySyncing, disabled: anySyncing }} - > - {anySyncing ? ( - - ) : ( - Sync All - )} - - handleSyncAll(true)} - activeOpacity={0.7} - disabled={anySyncing} - accessibilityRole="button" - accessibilityLabel="Full Sync All" - accessibilityState={{ busy: anySyncing, disabled: anySyncing }} - > - Full Sync All - - + void handleSyncAll(false)} + onFullSync={() => void handleSyncAll(true)} + /> )} diff --git a/packages/mobile/app/providers/styles.ts b/packages/mobile/app/providers/styles.ts index 0f86536531..faf6688703 100644 --- a/packages/mobile/app/providers/styles.ts +++ b/packages/mobile/app/providers/styles.ts @@ -18,6 +18,11 @@ export const styles = StyleSheet.create({ alignItems: "center", }, + fullSyncLink: { + fontSize: 13, + color: colors.accent, + marginTop: 4, + }, // Sync All buttons syncAllRow: { flexDirection: "row", diff --git a/packages/mobile/app/providers/sync-all-controls.stories.tsx b/packages/mobile/app/providers/sync-all-controls.stories.tsx new file mode 100644 index 0000000000..2f564f5815 --- /dev/null +++ b/packages/mobile/app/providers/sync-all-controls.stories.tsx @@ -0,0 +1,45 @@ +import type { Meta, StoryObj } from "@storybook/react-native"; +import { View } from "react-native"; +import { within } from "storybook/test"; +import { colors } from "../../theme"; +import { SyncAllControls } from "./sync-all-controls"; + +const meta = { + title: "Providers/SyncAllControls", + component: SyncAllControls, + decorators: [ + (Story) => ( + + + + ), + ], + args: { + busy: false, + onFullSync: () => {}, + onRecentSync: () => {}, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const FullHistoryConfirmation: Story = { + play: async ({ canvasElement, userEvent }) => { + await userEvent.click( + within(canvasElement).getByRole("button", { + name: "Sync full history for all providers", + }), + ); + }, +}; + +export const Syncing: Story = { + args: { busy: true }, +}; + +export const ErrorState: Story = { + args: { errorMessage: "Garmin credentials expired. Reconnect Garmin." }, +}; diff --git a/packages/mobile/app/providers/sync-all-controls.test.tsx b/packages/mobile/app/providers/sync-all-controls.test.tsx new file mode 100644 index 0000000000..70cc1b7287 --- /dev/null +++ b/packages/mobile/app/providers/sync-all-controls.test.tsx @@ -0,0 +1,207 @@ +// @vitest-environment jsdom +import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import type React from "react"; +import { describe, expect, it, vi } from "vitest"; + +const sendAccessibilityEvent = vi.hoisted(() => vi.fn()); + +vi.mock("react-native", () => { + const ReactModule = require("react"); + const View = ({ children, style: _style, ...props }: Record) => + ReactModule.createElement("div", props, children); + const Text = ({ children, accessibilityRole, style, ...props }: Record) => + ReactModule.createElement("span", { role: accessibilityRole, style, ...props }, children); + const TouchableOpacity = ReactModule.forwardRef( + ( + { + accessibilityLabel, + accessibilityRole, + accessibilityState, + accessible, + activeOpacity: _activeOpacity, + children, + disabled, + onPress, + style, + ...props + }: Record, + ref: React.Ref, + ) => { + const resolvedStyle = Array.isArray(style) + ? Object.assign({}, ...style.filter(Boolean)) + : style; + return ReactModule.createElement( + "button", + { + ...props, + ref, + "aria-busy": + typeof accessibilityState === "object" && + accessibilityState !== null && + "busy" in accessibilityState + ? accessibilityState.busy + : undefined, + "aria-disabled": + typeof accessibilityState === "object" && + accessibilityState !== null && + "disabled" in accessibilityState + ? accessibilityState.disabled + : undefined, + "aria-label": accessibilityLabel, + "data-accessible": accessible, + disabled, + onClick: onPress, + role: accessibilityRole, + style: resolvedStyle, + type: "button", + }, + children, + ); + }, + ); + const Modal = ({ + children, + onRequestClose, + onShow, + visible, + }: { + children?: React.ReactNode; + onRequestClose?: () => void; + onShow?: () => void; + visible?: boolean; + }) => { + ReactModule.useEffect(() => { + if (visible) onShow?.(); + }, [onShow, visible]); + return visible + ? ReactModule.createElement( + "div", + { role: "dialog", onClick: onRequestClose, "aria-label": "Full sync confirmation" }, + children, + ) + : null; + }; + return { + AccessibilityInfo: { sendAccessibilityEvent }, + ActivityIndicator: () => ReactModule.createElement("span", { role: "progressbar" }), + Modal, + StyleSheet: { create: (styles: Record) => styles }, + Text, + TouchableOpacity, + View, + }; +}); + +vi.mock("../../theme", () => ({ + colors: { + accent: "#00aaff", + background: "#000000", + danger: "#ff0000", + surface: "#111111", + surfaceSecondary: "#222222", + text: "#111111", + textInverse: "#ffffff", + textSecondary: "#aaaaaa", + }, +})); + +import { SyncAllControls } from "./sync-all-controls.tsx"; + +describe("SyncAllControls", () => { + it("makes recent sync the directly available primary action", () => { + const onRecentSync = vi.fn(); + render(); + + const recent = screen.getByRole("button", { + name: "Sync all providers for the last 7 days", + }); + const full = screen.getByRole("button", { + name: "Sync full history for all providers", + }); + + expect(recent.compareDocumentPosition(full) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + expect(screen.getByText("Updates the last 7 days for every connected provider.")).toBeTruthy(); + fireEvent.click(recent); + expect(onRecentSync).toHaveBeenCalledOnce(); + }); + + it("uses inverse text on accent-filled actions", () => { + render(); + + expect(screen.getByText("Sync recent data").style.color).toBe("rgb(255, 255, 255)"); + fireEvent.click(screen.getByRole("button", { name: "Sync full history for all providers" })); + expect(screen.getByText("Start full sync").style.color).toBe("rgb(255, 255, 255)"); + }); + + it("requires confirmation after explaining full-history impact", () => { + const onFullSync = vi.fn(); + render(); + + fireEvent.click(screen.getByRole("button", { name: "Sync full history for all providers" })); + + const dialog = screen.getByRole("dialog", { name: "Full sync confirmation" }); + expect(within(dialog).getByText("Sync full provider history?")).toBeTruthy(); + expect( + within(dialog).getByText(/all history each connected provider makes available/i), + ).toBeTruthy(); + expect(within(dialog).getByText(/more provider requests/i)).toBeTruthy(); + expect(within(dialog).getByText(/matching records are updated/i)).toBeTruthy(); + expect(within(dialog).getByText(/activities removed upstream may be hidden/i)).toBeTruthy(); + expect(onFullSync).not.toHaveBeenCalled(); + + fireEvent.click(within(dialog).getByRole("button", { name: "Start full sync" })); + expect(onFullSync).toHaveBeenCalledOnce(); + }); + + it("puts Cancel first, moves accessibility focus to it, and treats request-close as cancel", async () => { + const onFullSync = vi.fn(); + render(); + fireEvent.click(screen.getByRole("button", { name: "Sync full history for all providers" })); + + const dialog = screen.getByRole("dialog", { name: "Full sync confirmation" }); + const actions = within(dialog).getAllByRole("button"); + expect(actions.map((action) => action.textContent)).toEqual(["Cancel", "Start full sync"]); + expect(actions[0]?.getAttribute("data-accessible")).toBe("true"); + await waitFor(() => expect(sendAccessibilityEvent).toHaveBeenCalledWith(actions[0], "focus")); + + fireEvent.click(dialog); + expect(screen.queryByRole("dialog")).toBeNull(); + expect(onFullSync).not.toHaveBeenCalled(); + }); + + it("disables both actions and exposes progress while work is active", () => { + render(); + + expect( + screen + .getByRole("button", { name: "Sync all providers for the last 7 days" }) + .hasAttribute("disabled"), + ).toBe(true); + expect( + screen.getByRole("button", { name: "Sync full history for all providers" }).style.opacity, + ).toBe("0.5"); + expect( + screen + .getByRole("button", { name: "Sync full history for all providers" }) + .hasAttribute("disabled"), + ).toBe(true); + expect( + screen.getByText("Sync in progress. Follow each provider below for progress.").textContent, + ).toContain("Follow each provider below"); + }); + + it("shows the exact trigger error", () => { + render( + , + ); + + expect(screen.getByRole("alert").textContent).toContain( + "Garmin credentials expired. Reconnect Garmin.", + ); + }); +}); diff --git a/packages/mobile/app/providers/sync-all-controls.tsx b/packages/mobile/app/providers/sync-all-controls.tsx new file mode 100644 index 0000000000..56e7d8fa53 --- /dev/null +++ b/packages/mobile/app/providers/sync-all-controls.tsx @@ -0,0 +1,216 @@ +import { SYNC_ALL_ACTIONS } from "@dofek/providers/sync-actions"; +import { type ComponentRef, useCallback, useRef, useState } from "react"; +import { + AccessibilityInfo, + ActivityIndicator, + Modal, + StyleSheet, + Text, + TouchableOpacity, + View, +} from "react-native"; +import { colors } from "../../theme"; + +export interface SyncAllControlsProps { + busy: boolean; + errorMessage?: string; + onFullSync: () => void; + onRecentSync: () => void; +} + +export function SyncAllControls({ + busy, + errorMessage, + onFullSync, + onRecentSync, +}: SyncAllControlsProps) { + const [confirmationOpen, setConfirmationOpen] = useState(false); + const cancelRef = useRef>(null); + const closeConfirmation = useCallback(() => setConfirmationOpen(false), []); + const focusCancel = useCallback(() => { + if (cancelRef.current) { + AccessibilityInfo.sendAccessibilityEvent(cancelRef.current, "focus"); + } + }, []); + + return ( + + + {busy ? ( + + ) : ( + {SYNC_ALL_ACTIONS.recent.label} + )} + + {SYNC_ALL_ACTIONS.recent.description} + setConfirmationOpen(true)} + disabled={busy} + activeOpacity={0.7} + accessibilityRole="button" + accessibilityLabel={SYNC_ALL_ACTIONS.full.accessibilityLabel} + accessibilityState={{ disabled: busy }} + > + {SYNC_ALL_ACTIONS.full.label} + + + {busy ? ( + + {SYNC_ALL_ACTIONS.progressMessage} + + ) : null} + {errorMessage ? ( + + {errorMessage} + + ) : null} + + + + + + {SYNC_ALL_ACTIONS.full.confirmationTitle} + + + {SYNC_ALL_ACTIONS.full.confirmationDescription} + + + + {SYNC_ALL_ACTIONS.full.cancelLabel} + + { + closeConfirmation(); + onFullSync(); + }} + activeOpacity={0.7} + accessibilityRole="button" + accessibilityLabel={SYNC_ALL_ACTIONS.full.confirmLabel} + > + {SYNC_ALL_ACTIONS.full.confirmLabel} + + + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + alignItems: "stretch", + marginBottom: 20, + }, + recentButton: { + alignItems: "center", + backgroundColor: colors.accent, + borderRadius: 12, + paddingVertical: 14, + }, + recentButtonText: { + color: colors.textInverse, + fontSize: 16, + fontWeight: "600", + }, + disabled: { + opacity: 0.5, + }, + description: { + color: colors.textSecondary, + fontSize: 12, + marginTop: 6, + textAlign: "center", + }, + fullButton: { + alignSelf: "center", + marginTop: 10, + paddingHorizontal: 12, + paddingVertical: 6, + }, + fullButtonText: { + color: colors.accent, + fontSize: 13, + fontWeight: "600", + }, + progress: { + color: colors.textSecondary, + fontSize: 12, + marginTop: 8, + textAlign: "center", + }, + error: { + color: colors.danger, + fontSize: 12, + marginTop: 8, + textAlign: "center", + }, + backdrop: { + backgroundColor: "rgba(0, 0, 0, 0.7)", + flex: 1, + justifyContent: "center", + padding: 24, + }, + dialog: { + backgroundColor: colors.surface, + borderRadius: 14, + padding: 20, + }, + title: { + color: colors.text, + fontSize: 18, + fontWeight: "700", + }, + confirmationDescription: { + color: colors.textSecondary, + fontSize: 13, + lineHeight: 19, + marginTop: 10, + }, + actions: { + alignItems: "center", + flexDirection: "row", + gap: 16, + justifyContent: "flex-end", + marginTop: 20, + }, + cancelText: { + color: colors.textSecondary, + fontSize: 14, + }, + confirmButton: { + backgroundColor: colors.accent, + borderRadius: 8, + paddingHorizontal: 14, + paddingVertical: 10, + }, + confirmButtonText: { + color: colors.textInverse, + fontSize: 13, + fontWeight: "600", + }, +}); diff --git a/packages/providers-meta/package.json b/packages/providers-meta/package.json index 5e80786d18..a525e79dab 100644 --- a/packages/providers-meta/package.json +++ b/packages/providers-meta/package.json @@ -11,7 +11,8 @@ "./whoop": "./src/whoop.ts", "./push-providers": "./src/push-providers.ts", "./processing-alerts": "./src/processing-alerts.ts", - "./processing-status": "./src/processing-status.ts" + "./processing-status": "./src/processing-status.ts", + "./sync-actions": "./src/sync-actions.ts" }, "dependencies": { "@dofek/format": "workspace:*" diff --git a/packages/providers-meta/src/sync-actions.test.ts b/packages/providers-meta/src/sync-actions.test.ts new file mode 100644 index 0000000000..5811724a0d --- /dev/null +++ b/packages/providers-meta/src/sync-actions.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import { ROUTINE_SYNC_DAYS, SYNC_ALL_ACTIONS } from "./sync-actions.ts"; + +describe("SYNC_ALL_ACTIONS", () => { + it("defines the routine action as a recent seven-day sync", () => { + expect(ROUTINE_SYNC_DAYS).toBe(7); + expect(SYNC_ALL_ACTIONS.recent.label).toMatch(/recent/i); + expect(SYNC_ALL_ACTIONS.recent.description).toMatch( + new RegExp(`last ${ROUTINE_SYNC_DAYS} days`, "i"), + ); + expect(SYNC_ALL_ACTIONS.recent.accessibilityLabel).toMatch(/all providers/i); + expect(SYNC_ALL_ACTIONS.recent.accessibilityLabel).toMatch( + new RegExp(`last ${ROUTINE_SYNC_DAYS} days`, "i"), + ); + }); + + it("explains the material full-history impact in layman-readable language", () => { + const explanation = SYNC_ALL_ACTIONS.full.confirmationDescription; + + expect(explanation).toMatch(/all history.*provider.*available/i); + expect(explanation).toMatch(/longer/i); + expect(explanation).toMatch(/more provider requests/i); + expect(explanation).toMatch(/matching records are updated/i); + expect(explanation).toMatch(/activities removed.*may be hidden/i); + expect(explanation).toMatch(/does not erase or rebuild unrelated Dofek data/i); + }); + + it("uses explicit confirmation and progress wording", () => { + expect(SYNC_ALL_ACTIONS.full.label).toMatch(/full history/i); + expect(SYNC_ALL_ACTIONS.full.confirmLabel).toBe("Start full sync"); + expect(SYNC_ALL_ACTIONS.full.cancelLabel).toBe("Cancel"); + expect(SYNC_ALL_ACTIONS.progressMessage).toMatch(/follow each provider below/i); + }); +}); diff --git a/packages/providers-meta/src/sync-actions.ts b/packages/providers-meta/src/sync-actions.ts new file mode 100644 index 0000000000..fbe2c1928c --- /dev/null +++ b/packages/providers-meta/src/sync-actions.ts @@ -0,0 +1,19 @@ +export const ROUTINE_SYNC_DAYS = 7; + +export const SYNC_ALL_ACTIONS = { + recent: { + label: "Sync recent data", + accessibilityLabel: `Sync all providers for the last ${ROUTINE_SYNC_DAYS} days`, + description: `Updates the last ${ROUTINE_SYNC_DAYS} days for every connected provider.`, + }, + full: { + label: "Sync full history…", + accessibilityLabel: "Sync full history for all providers", + confirmationTitle: "Sync full provider history?", + confirmationDescription: + "This requests all history each connected provider makes available. It may take longer and use more provider requests than a recent sync. Matching records are updated, and activities removed upstream may be hidden. It does not erase or rebuild unrelated Dofek data.", + confirmLabel: "Start full sync", + cancelLabel: "Cancel", + }, + progressMessage: "Sync in progress. Follow each provider below for progress.", +} as const; diff --git a/packages/server/src/routers/sync.test.ts b/packages/server/src/routers/sync.test.ts index aedf3f1fb5..f213cc8a5a 100644 --- a/packages/server/src/routers/sync.test.ts +++ b/packages/server/src/routers/sync.test.ts @@ -743,7 +743,10 @@ describe("syncRouter", () => { targetRefreshWindow: { type: "full" }, userId: "user-1", }), - expect.objectContaining({ attempts: 288 }), + expect.objectContaining({ + attempts: 288, + deduplication: { id: "sync:full:strava:user-1" }, + }), ); expect(mockAdd).toHaveBeenNthCalledWith( 2, @@ -755,7 +758,10 @@ describe("syncRouter", () => { targetRefreshWindow: { type: "full" }, userId: "user-1", }), - expect.objectContaining({ attempts: 288 }), + expect.objectContaining({ + attempts: 288, + deduplication: { id: "sync:full:wahoo:user-1" }, + }), ); }); @@ -830,6 +836,11 @@ describe("syncRouter", () => { }); const result = await caller.triggerSync({ providerId: "garmin" }); + expect(enqueueSpy).toHaveBeenCalledWith( + "garmin", + expect.objectContaining({ providerId: "garmin", userId: "user-1" }), + expect.objectContaining({ skipWhenRateLimited: true }), + ); enqueueSpy.mockRestore(); expect(result.providerResults).toEqual([ @@ -1078,6 +1089,7 @@ describe("syncRouter", () => { backoff: { type: "fixed", delay: 300_000 }, }), ); + expect(mockAdd.mock.calls[0]?.[2]).not.toHaveProperty("deduplication"); }); it("uses one sync window for every job in a sync-all fan-out", async () => { diff --git a/packages/server/src/routers/sync.ts b/packages/server/src/routers/sync.ts index a6bbbc40a2..b6af8ef103 100644 --- a/packages/server/src/routers/sync.ts +++ b/packages/server/src/routers/sync.ts @@ -639,7 +639,7 @@ function createTriggerSyncProcedure() { userId: ctx.userId, ...syncWindowToJobData(syncWindow, input.sinceDays), }, - { skipWhenRateLimited: true }, + { skipWhenRateLimited: true, singleFlightFullSync: true }, ); if (!job) { return { diff --git a/packages/web/src/components/DataSourcesPanel.test.tsx b/packages/web/src/components/DataSourcesPanel.test.tsx index 8af6b8fd7c..681e72128b 100644 --- a/packages/web/src/components/DataSourcesPanel.test.tsx +++ b/packages/web/src/components/DataSourcesPanel.test.tsx @@ -252,11 +252,11 @@ describe("DataSourcesPanel", () => { , ); - expect(screen.getAllByRole("heading", { name: "Data Sources" })).toHaveLength(1); + expect(screen.getAllByRole("heading", { name: "Data Sources" })).toHaveLength(2); expect(screen.getByRole("region", { name: "Available data sources" })).toBeTruthy(); }); - it("reserves one stable provider region while inventory loads", () => { + it("reserves stable action and provider regions while inventory loads", () => { mockProvidersQuery.mockReturnValue({ data: undefined, isLoading: true, @@ -269,8 +269,11 @@ describe("DataSourcesPanel", () => { }); const { rerender } = render(); + const actionRegion = screen.getByRole("heading", { name: "Data Sources" }).parentElement; const loadingRegion = screen.getByRole("region", { name: "Available data sources" }); + expect(actionRegion?.className).toContain("min-h-20"); + expect(screen.queryByRole("region", { name: "Sync all providers" })).toBeNull(); expect(loadingRegion.getAttribute("aria-busy")).toBe("true"); expect(loadingRegion.className).toContain("h-80"); expect(loadingRegion.className).toContain("sm:h-96"); @@ -289,6 +292,15 @@ describe("DataSourcesPanel", () => { pushOnly: false, needsReauth: false, }, + { + id: "whoop", + name: "WHOOP", + authorized: true, + authType: "oauth2", + importOnly: false, + pushOnly: false, + needsReauth: false, + }, ], isLoading: false, error: null, @@ -296,6 +308,8 @@ describe("DataSourcesPanel", () => { rerender(); const processingRegion = screen.getByRole("region", { name: "Available data sources" }); + expect(screen.getByRole("heading", { name: "Data Sources" }).parentElement).toBe(actionRegion); + expect(screen.getByRole("region", { name: "Sync all providers" })).toBeTruthy(); expect(processingRegion).toBe(loadingRegion); expect(processingRegion.getAttribute("aria-busy")).toBe("true"); expect(within(processingRegion).getByText("Loading processing status…")).toBeTruthy(); @@ -509,7 +523,7 @@ describe("DataSourcesPanel", () => { }); render(); - fireEvent.click(screen.getByText("Sync All")); + fireEvent.click(screen.getByText("Sync recent data")); const garminCard = within(screen.getByTestId("provider-card-garmin")); const wahooCard = within(screen.getByTestId("provider-card-wahoo")); @@ -550,7 +564,7 @@ describe("DataSourcesPanel", () => { }); render(); - fireEvent.click(screen.getByText("Sync All")); + fireEvent.click(screen.getByText("Sync recent data")); await waitFor(() => { expect(mockPollSyncJob).toHaveBeenCalledWith( @@ -595,19 +609,52 @@ describe("DataSourcesPanel", () => { mockSyncMutateAsync.mockRejectedValue(error); render(); - fireEvent.click(screen.getByText("Sync All")); + fireEvent.click(screen.getByText("Sync recent data")); await waitFor(() => expect( within(screen.getByTestId("provider-card-garmin")).getByText(error.message), ).toBeTruthy(), ); + expect(screen.getByRole("alert")).toHaveTextContent(error.message); expect(mockCaptureException).toHaveBeenCalledTimes(1); expect(mockCaptureException).toHaveBeenCalledWith(error, { operation: "sync.triggerSync", }); }); + it("keeps both bulk actions disabled while provider jobs are still polling", async () => { + mockSyncMutateAsync.mockResolvedValue({ + jobId: "garmin:job-garmin", + jobIds: ["garmin:job-garmin"], + providerJobs: [ + { providerId: "garmin", jobId: "garmin:job-garmin", queueName: "sync-garmin" }, + ], + providerResults: [ + { + providerId: "garmin", + status: "started", + jobId: "garmin:job-garmin", + queueName: "sync-garmin", + }, + ], + }); + mockPollSyncJob.mockImplementation(() => new Promise(() => {})); + + render(); + fireEvent.click(screen.getByText("Sync recent data")); + + await waitFor(() => { + expect( + screen.getByRole("button", { name: "Sync all providers for the last 7 days" }), + ).toBeDisabled(); + expect( + screen.getByRole("button", { name: "Sync full history for all providers" }), + ).toBeDisabled(); + }); + expect(screen.getByRole("status")).toHaveTextContent("Follow each provider below"); + }); + it("reports polling failures without the job id and skips the global duplicate", async () => { const pollError = new Error("Sync status unavailable"); const jobId = "secret-job-id"; diff --git a/packages/web/src/components/DataSourcesPanel.tsx b/packages/web/src/components/DataSourcesPanel.tsx index 5f24765bef..07e23e046c 100644 --- a/packages/web/src/components/DataSourcesPanel.tsx +++ b/packages/web/src/components/DataSourcesPanel.tsx @@ -1,3 +1,4 @@ +import { ROUTINE_SYNC_DAYS } from "@dofek/providers/sync-actions"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { z } from "zod"; import { useProcessingStatus } from "../hooks/useProcessingStatus.ts"; @@ -20,6 +21,7 @@ import { } from "./file-import-configs.ts"; import { ProcessingStatusWidget } from "./ProcessingStatusWidget.tsx"; import { QueryStatePanel } from "./QueryStatePanel.tsx"; +import { SyncAllControls } from "./SyncAllControls.tsx"; import { SyncProviderCard } from "./SyncProviderCard.tsx"; const oauthBroadcastMessage = z.object({ @@ -48,6 +50,7 @@ export function DataSourcesPanel() { const [providerStates, setProviderStates] = useState>({}); const [syncAllMode, setSyncAllMode] = useState<"sync" | "full" | null>(null); + const [syncAllError, setSyncAllError] = useState(); // Resume polling for any active sync jobs (e.g. navigated away and back) const activeSyncs = trpc.sync.activeSyncs.useQuery(undefined, { staleTime: 0 }); @@ -131,7 +134,7 @@ export function DataSourcesPanel() { try { const result = await syncMutation.mutateAsync({ providerId, - sinceDays: fullSync ? undefined : 7, + sinceDays: fullSync ? undefined : ROUTINE_SYNC_DAYS, }); const providerResult = result.providerResults?.find( (entry) => entry.providerId === providerId, @@ -166,6 +169,7 @@ export function DataSourcesPanel() { const handleSyncAll = useCallback( async (fullSync = false) => { + setSyncAllError(undefined); setSyncAllMode(fullSync ? "full" : "sync"); const enabled = (providers.data ?? []).filter( (p) => p.authorized && !p.importOnly && !p.pushOnly, @@ -180,7 +184,7 @@ export function DataSourcesPanel() { } try { const result = await syncMutation.mutateAsync({ - sinceDays: fullSync ? undefined : 7, + sinceDays: fullSync ? undefined : ROUTINE_SYNC_DAYS, }); const providerResults = result.providerResults; await Promise.all( @@ -206,10 +210,12 @@ export function DataSourcesPanel() { ); } catch (err: unknown) { captureException(err, { operation: "sync.triggerSync" }); + const message = err instanceof Error ? err.message : "Sync failed"; + setSyncAllError(message); for (const p of enabled) { updateState(p.id, { status: "error", - message: err instanceof Error ? err.message : "Sync failed", + message, }); } } finally { @@ -255,6 +261,13 @@ export function DataSourcesPanel() { (activeImports.data ?? []).map((activeImport) => [activeImport.providerId, activeImport]), ); const enabledSyncable = allProviders.filter((p) => !p.importOnly && !p.pushOnly); + const syncAllBusy = + syncMutation.isPending || + syncAllMode !== null || + Object.values(providerStates).some((state) => state.status === "syncing") || + (activeSyncs.data ?? []).some( + (activeSync) => activeSync.status === "running" || activeSync.status === "queued", + ); // Resume polling for sync jobs that were already running when the page loaded useEffect(() => { @@ -399,34 +412,15 @@ export function DataSourcesPanel() { return (
-
+
+

Data Sources

{enabledSyncable.length > 1 && ( -
- - -
+ void handleSyncAll()} + onFullSync={() => void handleSyncAll(true)} + /> )}
diff --git a/packages/web/src/components/SyncAllControls.stories.tsx b/packages/web/src/components/SyncAllControls.stories.tsx new file mode 100644 index 0000000000..10edf069ac --- /dev/null +++ b/packages/web/src/components/SyncAllControls.stories.tsx @@ -0,0 +1,37 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { within } from "storybook/test"; +import { SyncAllControls } from "./SyncAllControls.tsx"; + +const meta = { + title: "Providers/SyncAllControls", + component: SyncAllControls, + decorators: [(Story) =>
{Story()}
], + args: { + busy: false, + onFullSync: () => {}, + onRecentSync: () => {}, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const FullHistoryConfirmation: Story = { + play: async ({ canvasElement, userEvent }) => { + await userEvent.click( + within(canvasElement).getByRole("button", { + name: "Sync full history for all providers", + }), + ); + }, +}; + +export const Syncing: Story = { + args: { busy: true }, +}; + +export const ErrorState: Story = { + args: { errorMessage: "Garmin credentials expired. Reconnect Garmin." }, +}; diff --git a/packages/web/src/components/SyncAllControls.test.tsx b/packages/web/src/components/SyncAllControls.test.tsx new file mode 100644 index 0000000000..8cd7a97ae9 --- /dev/null +++ b/packages/web/src/components/SyncAllControls.test.tsx @@ -0,0 +1,101 @@ +// @vitest-environment jsdom +import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { SyncAllControls } from "./SyncAllControls.tsx"; + +afterEach(cleanup); + +describe("SyncAllControls", () => { + it("makes recent sync the primary directly available action", () => { + const onRecentSync = vi.fn(); + render(); + + const recent = screen.getByRole("button", { + name: "Sync all providers for the last 7 days", + }); + const full = screen.getByRole("button", { + name: "Sync full history for all providers", + }); + + expect(recent.compareDocumentPosition(full) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + expect(recent.className).toContain("bg-accent"); + expect(full.className).not.toContain("bg-accent text"); + expect(screen.getByText("Updates the last 7 days for every connected provider.")).toBeVisible(); + + fireEvent.click(recent); + expect(onRecentSync).toHaveBeenCalledOnce(); + }); + + it("explains full-history impact and requires explicit confirmation", async () => { + const onFullSync = vi.fn(); + render(); + + fireEvent.click(screen.getByRole("button", { name: "Sync full history for all providers" })); + + const dialog = screen.getByRole("dialog", { name: "Sync full provider history?" }); + expect( + within(dialog).getByText(/all history each connected provider makes available/i), + ).toBeVisible(); + expect(within(dialog).getByText(/more provider requests/i)).toBeVisible(); + expect(within(dialog).getByText(/matching records are updated/i)).toBeVisible(); + expect(within(dialog).getByText(/activities removed upstream may be hidden/i)).toBeVisible(); + expect( + within(dialog).getByText(/does not erase or rebuild unrelated Dofek data/i), + ).toBeVisible(); + expect(onFullSync).not.toHaveBeenCalled(); + + fireEvent.click(within(dialog).getByRole("button", { name: "Start full sync" })); + expect(onFullSync).toHaveBeenCalledOnce(); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + }); + + it("puts Cancel first, focuses it, and restores the trigger after Escape", async () => { + render(); + const trigger = screen.getByRole("button", { + name: "Sync full history for all providers", + }); + + trigger.focus(); + fireEvent.click(trigger); + + const dialog = screen.getByRole("dialog", { name: "Sync full provider history?" }); + const actions = within(dialog).getAllByRole("button"); + expect(actions.map((action) => action.textContent)).toEqual(["Cancel", "Start full sync"]); + const cancel = actions[0]; + if (!cancel) throw new Error("Expected the full-sync dialog to include Cancel"); + await waitFor(() => expect(cancel).toHaveFocus()); + + fireEvent.keyDown(cancel, { key: "Escape" }); + await waitFor(() => expect(trigger).toHaveFocus()); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + }); + + it("disables both actions for active work and keeps progress visible", () => { + render(); + + expect( + screen.getByRole("button", { name: "Sync all providers for the last 7 days" }), + ).toBeDisabled(); + expect( + screen.getByRole("button", { name: "Sync full history for all providers" }), + ).toBeDisabled(); + expect(screen.getByRole("status")).toHaveTextContent( + "Sync in progress. Follow each provider below for progress.", + ); + }); + + it("shows the exact trigger error", () => { + render( + , + ); + + expect(screen.getByRole("alert")).toHaveTextContent( + "Garmin credentials expired. Reconnect Garmin.", + ); + }); +}); diff --git a/packages/web/src/components/SyncAllControls.tsx b/packages/web/src/components/SyncAllControls.tsx new file mode 100644 index 0000000000..21198dbe40 --- /dev/null +++ b/packages/web/src/components/SyncAllControls.tsx @@ -0,0 +1,91 @@ +import { SYNC_ALL_ACTIONS } from "@dofek/providers/sync-actions"; +import { useRef, useState } from "react"; +import { ModalDialog, ModalDialogDescription, ModalDialogTitle } from "./ModalDialog.tsx"; + +export interface SyncAllControlsProps { + busy: boolean; + errorMessage?: string; + onFullSync: () => void; + onRecentSync: () => void; +} + +export function SyncAllControls({ + busy, + errorMessage, + onFullSync, + onRecentSync, +}: SyncAllControlsProps) { + const [confirmationOpen, setConfirmationOpen] = useState(false); + const cancelRef = useRef(null); + + return ( +
+
+ +

{SYNC_ALL_ACTIONS.recent.description}

+ +
+ + {busy ? ( + + {SYNC_ALL_ACTIONS.progressMessage} + + ) : null} + {errorMessage ? ( +

+ {errorMessage} +

+ ) : null} + + setConfirmationOpen(false)} + initialFocusRef={cancelRef} + contentClassName="w-[calc(100%-2rem)] max-w-md rounded-xl border border-border bg-surface p-5 shadow-xl" + > + + {SYNC_ALL_ACTIONS.full.confirmationTitle} + + + {SYNC_ALL_ACTIONS.full.confirmationDescription} + +
+ + +
+
+
+ ); +} diff --git a/src/jobs/enqueue-sync-job.test.ts b/src/jobs/enqueue-sync-job.test.ts index de291eeb19..29d0bf5e98 100644 --- a/src/jobs/enqueue-sync-job.test.ts +++ b/src/jobs/enqueue-sync-job.test.ts @@ -43,6 +43,65 @@ describe("enqueueSyncJob", () => { ); }); + it("adds lifecycle-scoped deduplication to a user-triggered initial full sync", async () => { + await enqueueSyncJob( + "garmin", + { + userId: "user-1", + providerId: "garmin", + sinceIso: "1970-01-01T00:00:00.000Z", + untilIso: "2026-07-29T17:00:00.000Z", + targetRefreshWindow: { type: "full" }, + }, + { singleFlightFullSync: true }, + ); + + expect(mockProviderQueueAdd).toHaveBeenCalledWith( + "sync", + expect.objectContaining({ targetRefreshWindow: { type: "full" } }), + expect.objectContaining({ + deduplication: { id: "sync:full:garmin:user-1" }, + }), + ); + }); + + it("does not reuse the initial full-sync deduplication key for a checkpoint continuation", async () => { + await enqueueSyncJob( + "garmin", + { + userId: "user-1", + providerId: "garmin", + sinceIso: "1970-01-01T00:00:00.000Z", + untilIso: "2026-07-29T17:00:00.000Z", + targetRefreshWindow: { type: "full" }, + checkpoint: { phase: "api", stepIndex: 2 }, + }, + { singleFlightFullSync: true }, + ); + + expect(mockProviderQueueAdd).toHaveBeenCalledWith( + "sync", + expect.objectContaining({ checkpoint: { phase: "api", stepIndex: 2 } }), + expect.not.objectContaining({ deduplication: expect.anything() }), + ); + }); + + it("does not lifecycle-deduplicate non-user-triggered full sync work", async () => { + await enqueueSyncJob("garmin", { + userId: "user-1", + providerId: "garmin", + sinceIso: "1970-01-01T00:00:00.000Z", + untilIso: "2026-07-29T17:00:00.000Z", + targetRefreshWindow: { type: "full" }, + }); + + expect(mockProviderQueueAdd).toHaveBeenCalledWith( + "sync", + expect.objectContaining({ targetRefreshWindow: { type: "full" } }), + expect.not.objectContaining({ deduplication: expect.anything() }), + ); + }); + it("enqueues with delay when a cooldown is active", async () => { mockGetActive.mockResolvedValue({ providerId: "garmin", diff --git a/src/jobs/enqueue-sync-job.ts b/src/jobs/enqueue-sync-job.ts index c18b037879..5fa99718bf 100644 --- a/src/jobs/enqueue-sync-job.ts +++ b/src/jobs/enqueue-sync-job.ts @@ -11,8 +11,25 @@ import { type EnqueuedSyncJob, enqueueSyncJobWithRequestDedup } from "./sync-req export type EnqueueSyncJobOptions = { /** When active, skip enqueue instead of scheduling a duplicate delayed job. */ skipWhenRateLimited?: boolean; + /** Coalesce a user-triggered initial full sync until that job completes or fails. */ + singleFlightFullSync?: boolean; }; +function initialFullSyncDeduplicationId( + providerId: string, + jobData: SyncJobData, + options?: EnqueueSyncJobOptions, +): string | undefined { + if ( + !options?.singleFlightFullSync || + jobData.targetRefreshWindow?.type !== "full" || + jobData.checkpoint !== undefined + ) { + return undefined; + } + return `sync:full:${providerId}:${jobData.userId}`; +} + export async function syncJobOptionsWithRateLimitCooldown( providerId: string, userId: string, @@ -36,11 +53,12 @@ export async function enqueueSyncJob( return null; } const jobOptions = await syncJobOptionsWithRateLimitCooldown(providerId, jobData.userId); + const deduplicationId = initialFullSyncDeduplicationId(providerId, jobData, options); const queue = getProviderSyncQueue(providerId); return enqueueSyncJobWithRequestDedup( providerId, jobData, - jobOptions, + deduplicationId ? { ...jobOptions, deduplication: { id: deduplicationId } } : jobOptions, (name, data, opts) => queue.add(name, data, opts), (jobId) => queue.getJob(jobId), ); diff --git a/src/jobs/sync-request-job.integration.test.ts b/src/jobs/sync-request-job.integration.test.ts new file mode 100644 index 0000000000..26a97ca21d --- /dev/null +++ b/src/jobs/sync-request-job.integration.test.ts @@ -0,0 +1,152 @@ +import { randomUUID } from "node:crypto"; +import type { ConnectionOptions } from "bullmq"; +import { Queue, QueueEvents, Worker } from "bullmq"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { registerSyncRequestQueryResolver } from "../lib/sync-request-query.ts"; +import { resolveWhoopSyncRequestQuery } from "../providers/whoop/sync-request-query.ts"; +import type { SyncJobData } from "./queues.ts"; +import { type EnqueuedSyncJob, enqueueSyncJobWithRequestDedup } from "./sync-request-job.ts"; + +registerSyncRequestQueryResolver("whoop", resolveWhoopSyncRequestQuery); + +function testRedisConnection(): ConnectionOptions { + const redisUrl = process.env.REDIS_URL; + if (!redisUrl) { + throw new Error("REDIS_URL is required for BullMQ integration tests"); + } + const parsed = new URL(redisUrl); + return { + host: parsed.hostname, + port: Number(parsed.port || "6379"), + password: parsed.password || undefined, + maxRetriesPerRequest: null, + }; +} + +describe("full sync BullMQ lifecycle deduplication", () => { + const cleanup: Array<() => Promise> = []; + + afterEach(async () => { + while (cleanup.length > 0) { + const close = cleanup.pop(); + if (close) await close(); + } + }); + + function createQueue(suffix: string) { + const connection = testRedisConnection(); + const queue = new Queue(`test-full-sync-${suffix}-${randomUUID()}`, { + connection, + }); + const events = new QueueEvents(queue.name, { connection }); + cleanup.push(async () => { + await queue.obliterate({ force: true }); + await queue.close(); + }); + cleanup.push(async () => events.close()); + return { connection, events, queue }; + } + + it("fails loudly when REDIS_URL is missing", () => { + vi.stubEnv("REDIS_URL", ""); + try { + expect(testRedisConnection).toThrow("REDIS_URL is required for BullMQ integration tests"); + } finally { + vi.unstubAllEnvs(); + } + }); + + async function enqueueFull( + queue: Queue, + untilIso: string, + providerId = "strava", + ): Promise { + const result = await enqueueSyncJobWithRequestDedup( + providerId, + { + userId: "user-1", + providerId, + sinceIso: "1970-01-01T00:00:00.000Z", + untilIso, + targetRefreshWindow: { type: "full" }, + }, + { deduplication: { id: `sync:full:${providerId}:user-1` } }, + (name, data, options) => queue.add(name, data, options), + (jobId) => queue.getJob(jobId), + ); + if (!result) throw new Error("Expected a queued full sync job"); + return result; + } + + it("coalesces pending full syncs and releases the key after completion", async () => { + const { connection, events, queue } = createQueue("complete"); + await events.waitUntilReady(); + + const first = await enqueueFull(queue, "2026-07-29T17:00:00.000Z"); + const duplicate = await enqueueFull(queue, "2026-07-29T17:00:01.000Z"); + + expect(duplicate.id).toBe(first.id); + expect(duplicate.alreadyQueued).toBe(true); + expect(await queue.getWaitingCount()).toBe(1); + + const worker = new Worker(queue.name, async () => undefined, { connection }); + cleanup.push(async () => worker.close()); + await first.waitUntilFinished(events); + + const later = await enqueueFull(queue, "2026-07-29T17:00:02.000Z"); + expect(later.id).not.toBe(first.id); + expect(later.alreadyQueued).toBe(false); + }); + + it("releases the key after failure", async () => { + const { connection, events, queue } = createQueue("failure"); + await events.waitUntilReady(); + + const first = await enqueueFull(queue, "2026-07-29T17:00:00.000Z"); + const worker = new Worker( + queue.name, + async () => { + throw new Error("provider rejected sync"); + }, + { connection }, + ); + cleanup.push(async () => worker.close()); + await expect(first.waitUntilFinished(events)).rejects.toThrow("provider rejected sync"); + + const later = await enqueueFull(queue, "2026-07-29T17:00:01.000Z"); + expect(later.id).not.toBe(first.id); + expect(later.alreadyQueued).toBe(false); + }); + + it("keeps checkpoint continuations distinct from the pending initial operation", async () => { + const { queue } = createQueue("checkpoint"); + const initial = await enqueueFull(queue, "2026-07-29T17:00:00.000Z", "whoop"); + + const continuation = await enqueueSyncJobWithRequestDedup( + "whoop", + { + userId: "user-1", + providerId: "whoop", + sinceIso: "1970-01-01T00:00:00.000Z", + untilIso: "2026-07-29T17:00:00.000Z", + targetRefreshWindow: { type: "full" }, + checkpoint: { + runId: "run-1", + recordsSynced: 0, + phase: "bootstrap", + cycleFetchCursorMs: 123, + cycles: [], + apiSteps: [], + apiStepIndex: 0, + presentExternalIds: [], + }, + }, + {}, + (name, data, options) => queue.add(name, data, options), + (jobId) => queue.getJob(jobId), + ); + + expect(continuation?.id).not.toBe(initial.id); + expect(await queue.getWaitingCount()).toBe(2); + }); +}); diff --git a/src/jobs/sync-request-job.test.ts b/src/jobs/sync-request-job.test.ts index d95b508ab5..049cdc091f 100644 --- a/src/jobs/sync-request-job.test.ts +++ b/src/jobs/sync-request-job.test.ts @@ -1,10 +1,15 @@ import type { JobsOptions } from "bullmq"; import { describe, expect, it, vi } from "vitest"; -import { buildSyncRequestJobId } from "../lib/sync-request-query.ts"; +import { + buildSyncRequestJobId, + registerSyncRequestQueryResolver, +} from "../lib/sync-request-query.ts"; import { resolveWhoopSyncRequestQuery } from "../providers/whoop/sync-request-query.ts"; import type { SyncJobData } from "./queues.ts"; import { enqueueSyncJobWithRequestDedup } from "./sync-request-job.ts"; +registerSyncRequestQueryResolver("whoop", resolveWhoopSyncRequestQuery); + describe("resolveWhoopSyncRequestQuery", () => { it("maps a fresh sync job to the first bootstrap cycles request", () => { expect( @@ -299,6 +304,100 @@ describe("enqueueSyncJobWithRequestDedup", () => { expect(result).toBe(newJob); }); + it("reports a BullMQ lifecycle-deduplicated job as already queued", async () => { + const existingJob = { id: "first-full-job" }; + const addJob = vi.fn().mockResolvedValue(existingJob); + const getJob = vi.fn().mockResolvedValue(undefined); + + const result = await enqueueSyncJobWithRequestDedup( + "strava", + { + userId: "user-1", + providerId: "strava", + sinceIso: "1970-01-01T00:00:00.000Z", + untilIso: "2026-07-29T17:00:01.000Z", + targetRefreshWindow: { type: "full" }, + }, + { deduplication: { id: "sync:full:strava:user-1" } }, + addJob, + getJob, + ); + + expect(addJob).toHaveBeenCalledWith( + "sync", + expect.anything(), + expect.objectContaining({ + jobId: expect.stringMatching(/^sync-req-strava-user-1-/), + deduplication: { id: "sync:full:strava:user-1" }, + }), + ); + expect(result).toBe(existingJob); + expect(result?.alreadyQueued).toBe(true); + }); + + it.each([ + { + name: "lifecycle deduplication is absent", + jobOptions: {}, + returnedJobId: "different-job-id", + jobData, + }, + { + name: "request job ID is absent", + jobOptions: { deduplication: { id: "sync:full:whoop:user-1" } }, + returnedJobId: "different-job-id", + jobData: { + userId: "user-1", + providerId: "whoop", + checkpoint: { + runId: "run-1", + recordsSynced: 0, + phase: "done" as const, + cycleFetchCursorMs: null, + cycles: [], + apiSteps: [], + apiStepIndex: 0, + presentExternalIds: [], + }, + }, + }, + { + name: "BullMQ returns no job ID", + jobOptions: { deduplication: { id: "sync:full:whoop:user-1" } }, + returnedJobId: undefined, + jobData, + }, + { + name: "BullMQ returns the requested job ID", + jobOptions: { deduplication: { id: "sync:full:whoop:user-1" } }, + returnedJobId: "requested-job-id", + jobData, + }, + ])("does not report already queued when $name", async ({ + jobOptions, + returnedJobId, + jobData, + }) => { + const returnedJob = { id: returnedJobId }; + const addJob = vi.fn().mockImplementation(async (_name, _data, options: JobsOptions) => { + if (returnedJobId === "requested-job-id") { + returnedJob.id = options.jobId; + } + return returnedJob; + }); + const getJob = vi.fn().mockResolvedValue(undefined); + + const result = await enqueueSyncJobWithRequestDedup( + "whoop", + jobData, + jobOptions, + addJob, + getJob, + ); + + expect(result?.alreadyQueued).toBe(false); + }); + it("returns an existing cooldown-delayed job without enqueueing a duplicate", async () => { const existing = { getState: vi.fn(), remove: vi.fn() }; existing.getState.mockResolvedValue("delayed"); diff --git a/src/jobs/sync-request-job.ts b/src/jobs/sync-request-job.ts index a3a5bb11c2..8f1d5526a5 100644 --- a/src/jobs/sync-request-job.ts +++ b/src/jobs/sync-request-job.ts @@ -36,5 +36,10 @@ export async function enqueueSyncJobWithRequestDedup( } const job = await addJob("sync", jobData, nextOptions); - return withAlreadyQueuedState(job, false); + const wasLifecycleDeduplicated = + nextOptions.deduplication != null && + nextOptions.jobId != null && + job.id != null && + String(job.id) !== nextOptions.jobId; + return withAlreadyQueuedState(job, wasLifecycleDeduplicated); }