diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9b8c6fdd7a0c..38b810e139a6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3866,6 +3866,12 @@ importers: projects/packages/premium-analytics: dependencies: + '@automattic/jetpack-script-data': + specifier: workspace:* + version: link:../../js-packages/script-data + '@wordpress/api-fetch': + specifier: ^7.22.0 + version: 7.46.0 '@wordpress/boot': specifier: 0.13.0 version: 0.13.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -3888,18 +3894,45 @@ importers: specifier: 18.3.1 version: 18.3.1(react@18.3.1) devDependencies: + '@automattic/jetpack-webpack-config': + specifier: workspace:* + version: link:../../js-packages/webpack-config '@babel/core': specifier: 7.29.0 version: 7.29.0 + '@babel/preset-react': + specifier: 7.28.5 + version: 7.28.5(@babel/core@7.29.0) + '@babel/preset-typescript': + specifier: 7.28.5 + version: 7.28.5(@babel/core@7.29.0) + '@babel/runtime': + specifier: 7.29.2 + version: 7.29.2 + '@testing-library/dom': + specifier: 10.4.1 + version: 10.4.1 + '@testing-library/react': + specifier: 16.3.2 + version: 16.3.2(@testing-library/dom@10.4.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@types/jest': + specifier: 30.0.0 + version: 30.0.0 '@typescript/native-preview': specifier: 7.0.0-dev.20260225.1 version: 7.0.0-dev.20260225.1 '@wordpress/build': specifier: 0.14.0 version: 0.14.0(@babel/core@7.29.0)(@wordpress/boot@0.13.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@wordpress/route@0.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(browserslist@4.28.2) + babel-jest: + specifier: 30.4.1 + version: 30.4.1(@babel/core@7.29.0) browserslist: specifier: 4.28.2 version: 4.28.2 + jest: + specifier: 30.4.2 + version: 30.4.2 projects/packages/protect-models: {} diff --git a/projects/packages/premium-analytics/changelog/add-site-sync b/projects/packages/premium-analytics/changelog/add-site-sync new file mode 100644 index 000000000000..78c519e2c855 --- /dev/null +++ b/projects/packages/premium-analytics/changelog/add-site-sync @@ -0,0 +1,4 @@ +Significance: minor +Type: added + +Add site-sync package (useSyncStatus hook) and configure apiFetch auth in init. diff --git a/projects/packages/premium-analytics/jest.config.cjs b/projects/packages/premium-analytics/jest.config.cjs new file mode 100644 index 000000000000..3769cabf2379 --- /dev/null +++ b/projects/packages/premium-analytics/jest.config.cjs @@ -0,0 +1,21 @@ +const baseConfig = require( 'jetpack-js-tools/jest/config.base.js' ); + +module.exports = { + ...baseConfig, + rootDir: '.', + collectCoverageFrom: [ + '/packages/**/src/**/*.{js,jsx,ts,tsx}', + ...baseConfig.collectCoverageFrom, + ], + // Compile TS/JSX to CommonJS (rather than leaving native ESM). `jest.mock` + // hoisting relies on `require`, which is undefined when `.ts` runs as ESM + // under `--experimental-vm-modules`; emitting CJS keeps mocks working without + // that flag. Mirrors the shared transform used by js-packages/charts. + transform: { + ...baseConfig.transform, + '\\.[jt]sx?$': require( 'jetpack-js-tools/jest/babel-jest-config-factory.js' )( + require.resolve + ), + }, + extensionsToTreatAsEsm: [], +}; diff --git a/projects/packages/premium-analytics/package.json b/projects/packages/premium-analytics/package.json index 31d5de969868..65413779079a 100644 --- a/projects/packages/premium-analytics/package.json +++ b/projects/packages/premium-analytics/package.json @@ -6,6 +6,7 @@ "scripts": { "build": "wp-build && mkdir -p build/modules/boot && cp shims/boot-asset.php build/modules/boot/index.min.asset.php", "build-production": "NODE_ENV=production wp-build && mkdir -p build/modules/boot && cp shims/boot-asset.php build/modules/boot/index.min.asset.php", + "test": "jest", "typecheck": "tsgo --noEmit", "watch": "wp-build --watch" }, @@ -29,6 +30,8 @@ } }, "dependencies": { + "@automattic/jetpack-script-data": "workspace:*", + "@wordpress/api-fetch": "^7.22.0", "@wordpress/boot": "0.13.0", "@wordpress/data": "10.46.0", "@wordpress/i18n": "^6.9.0", @@ -38,9 +41,18 @@ "react-dom": "18.3.1" }, "devDependencies": { + "@automattic/jetpack-webpack-config": "workspace:*", "@babel/core": "7.29.0", + "@babel/preset-react": "7.28.5", + "@babel/preset-typescript": "7.28.5", + "@babel/runtime": "7.29.2", + "@testing-library/dom": "10.4.1", + "@testing-library/react": "16.3.2", + "@types/jest": "30.0.0", "@typescript/native-preview": "7.0.0-dev.20260225.1", "@wordpress/build": "0.14.0", - "browserslist": "4.28.2" + "babel-jest": "30.4.1", + "browserslist": "4.28.2", + "jest": "30.4.2" } } diff --git a/projects/packages/premium-analytics/packages/init/package.json b/projects/packages/premium-analytics/packages/init/package.json index 70de0aea9d5b..a9c75f14d5e4 100644 --- a/projects/packages/premium-analytics/packages/init/package.json +++ b/projects/packages/premium-analytics/packages/init/package.json @@ -7,6 +7,8 @@ "module": "build-module/index.mjs", "wpScriptModuleExports": "./build-module/index.mjs", "dependencies": { + "@automattic/jetpack-script-data": "workspace:*", + "@wordpress/api-fetch": "^7.22.0", "@wordpress/boot": "0.13.0", "@wordpress/data": "10.46.0", "@wordpress/icons": "^13.0.0" diff --git a/projects/packages/premium-analytics/packages/init/src/index.ts b/projects/packages/premium-analytics/packages/init/src/index.ts index f4a5da3c37c8..3955304e7c49 100644 --- a/projects/packages/premium-analytics/packages/init/src/index.ts +++ b/projects/packages/premium-analytics/packages/init/src/index.ts @@ -1,6 +1,8 @@ /** * External dependencies */ +import { getScriptData } from '@automattic/jetpack-script-data'; +import apiFetch from '@wordpress/api-fetch'; import { store as bootStore } from '@wordpress/boot'; import { dispatch } from '@wordpress/data'; import { chartBar } from '@wordpress/icons'; @@ -10,6 +12,16 @@ import { chartBar } from '@wordpress/icons'; * Runs before routes render. */ export async function init(): Promise< void > { + // Point apiFetch at this site's REST API and authenticate requests. Required + // before any package (e.g. site-sync) calls apiFetch against /jetpack/v4/*. + const site = getScriptData()?.site; + if ( site?.rest_root ) { + apiFetch.use( apiFetch.createRootURLMiddleware( site.rest_root ) ); + } + if ( site?.rest_nonce ) { + apiFetch.use( apiFetch.createNonceMiddleware( site.rest_nonce ) ); + } + dispatch( bootStore ).updateMenuItem( 'dashboard', { icon: chartBar, } ); diff --git a/projects/packages/premium-analytics/packages/site-sync/package.json b/projects/packages/premium-analytics/packages/site-sync/package.json new file mode 100644 index 000000000000..bbf6d4b8f2a4 --- /dev/null +++ b/projects/packages/premium-analytics/packages/site-sync/package.json @@ -0,0 +1,18 @@ +{ + "private": true, + "name": "@automattic/jetpack-premium-analytics-site-sync", + "version": "0.1.0", + "type": "module", + "wpScript": true, + "module": "build-module/index.mjs", + "wpScriptModuleExports": "./build-module/index.mjs", + "dependencies": { + "@automattic/jetpack-script-data": "workspace:*", + "@wordpress/api-fetch": "^7.22.0", + "@wordpress/i18n": "^6.9.0", + "react": "18.3.1" + }, + "devDependencies": { + "@testing-library/react": "16.3.2" + } +} diff --git a/projects/packages/premium-analytics/packages/site-sync/src/api/fetch-sync-status.ts b/projects/packages/premium-analytics/packages/site-sync/src/api/fetch-sync-status.ts new file mode 100644 index 000000000000..017c6e5c9a7f --- /dev/null +++ b/projects/packages/premium-analytics/packages/site-sync/src/api/fetch-sync-status.ts @@ -0,0 +1,18 @@ +/** + * External dependencies + */ +import apiFetch from '@wordpress/api-fetch'; +/** + * Internal dependencies + */ +import { SYNC_STATUS_PATH } from '../constants'; +import type { SyncStatusApiResponse } from '../types'; + +/** + * Fetch the current sync status from Jetpack core. + * + * @return The current sync status. + */ +export function fetchSyncStatus(): Promise< SyncStatusApiResponse > { + return apiFetch( { path: SYNC_STATUS_PATH } ); +} diff --git a/projects/packages/premium-analytics/packages/site-sync/src/api/trigger-full-sync.ts b/projects/packages/premium-analytics/packages/site-sync/src/api/trigger-full-sync.ts new file mode 100644 index 000000000000..ee16471a2d5b --- /dev/null +++ b/projects/packages/premium-analytics/packages/site-sync/src/api/trigger-full-sync.ts @@ -0,0 +1,17 @@ +/** + * External dependencies + */ +import apiFetch from '@wordpress/api-fetch'; +/** + * Internal dependencies + */ +import { FULL_SYNC_PATH } from '../constants'; + +/** + * Trigger a Jetpack full sync. + * + * @return The full-sync trigger response. + */ +export function triggerFullSync(): Promise< unknown > { + return apiFetch( { path: FULL_SYNC_PATH, method: 'POST' } ); +} diff --git a/projects/packages/premium-analytics/packages/site-sync/src/constants.ts b/projects/packages/premium-analytics/packages/site-sync/src/constants.ts new file mode 100644 index 000000000000..a76673f5072c --- /dev/null +++ b/projects/packages/premium-analytics/packages/site-sync/src/constants.ts @@ -0,0 +1,20 @@ +/** + * Polling interval in milliseconds. + */ +export const POLL_INTERVAL = 3_000; + +/** + * Jetpack core sync status endpoint (queue + full-sync state). + */ +export const SYNC_STATUS_PATH = '/jetpack/v4/sync/status'; + +/** + * Jetpack core full-sync trigger endpoint. + */ +export const FULL_SYNC_PATH = '/jetpack/v4/sync/full-sync'; + +/** + * Sync-module key whose progress gates the analytics dashboard. Mirrors the + * backend default (`Sync_Status_Tracker::ANALYTICS_SYNC_MODULE`). + */ +export const ANALYTICS_SYNC_MODULE = 'woocommerce_analytics'; diff --git a/projects/packages/premium-analytics/packages/site-sync/src/hooks/__tests__/use-sync-status.test.ts b/projects/packages/premium-analytics/packages/site-sync/src/hooks/__tests__/use-sync-status.test.ts new file mode 100644 index 000000000000..5aabc33db11a --- /dev/null +++ b/projects/packages/premium-analytics/packages/site-sync/src/hooks/__tests__/use-sync-status.test.ts @@ -0,0 +1,120 @@ +/** + * External dependencies + */ +import { getScriptData } from '@automattic/jetpack-script-data'; +import { renderHook, act, waitFor } from '@testing-library/react'; +/** + * Internal dependencies + */ +import { fetchSyncStatus } from '../../api/fetch-sync-status'; +import { triggerFullSync } from '../../api/trigger-full-sync'; +import { useSyncStatus } from '../use-sync-status'; +import type { SyncStatusApiResponse } from '../../types'; + +jest.mock( '../../api/fetch-sync-status' ); +jest.mock( '../../api/trigger-full-sync' ); +jest.mock( '@automattic/jetpack-script-data' ); + +const mockFetch = fetchSyncStatus as jest.MockedFunction< typeof fetchSyncStatus >; +const mockTrigger = triggerFullSync as jest.MockedFunction< typeof triggerFullSync >; +const mockScriptData = getScriptData as jest.MockedFunction< typeof getScriptData >; + +/** + * Build a raw sync-status API response for tests. + * + * @param overrides - Fields to override on the default running-analytics response. + * @return A raw sync-status API response. + */ +function rawStatus( overrides: Partial< SyncStatusApiResponse > = {} ): SyncStatusApiResponse { + return { + started: true, + finished: false, + progress: { woocommerce_analytics: { sent: 1, total: 2 } }, + ...overrides, + }; +} + +beforeEach( () => { + jest.useFakeTimers(); + // Default: milestone not set. + mockScriptData.mockReturnValue( { + premium_analytics: { initial_full_sync_finished: 0 }, + } as ReturnType< typeof getScriptData > ); + mockFetch.mockResolvedValue( rawStatus() ); + mockTrigger.mockResolvedValue( undefined ); +} ); + +afterEach( () => { + jest.clearAllTimers(); + jest.useRealTimers(); + jest.clearAllMocks(); +} ); + +describe( 'useSyncStatus', () => { + it( 'exposes normalized progress after the first poll', async () => { + const { result } = renderHook( () => useSyncStatus() ); + + await waitFor( () => expect( result.current.isLoading ).toBe( false ) ); + expect( result.current.data?.percentage ).toBe( 50 ); + expect( result.current.data?.isRunning ).toBe( true ); + expect( result.current.error ).toBeNull(); + } ); + + it( 'reports complete and stops polling when analytics reaches 100', async () => { + mockFetch.mockResolvedValue( + rawStatus( { + finished: true, + progress: { woocommerce_analytics: { sent: 2, total: 2 } }, + } ) + ); + const { result } = renderHook( () => useSyncStatus() ); + + await waitFor( () => expect( result.current.isComplete ).toBe( true ) ); + const callsAfterComplete = mockFetch.mock.calls.length; + + await act( async () => { + jest.advanceTimersByTime( 10_000 ); + } ); + expect( mockFetch.mock.calls ).toHaveLength( callsAfterComplete ); + } ); + + it( 'flags a stalled sync with an error', async () => { + mockFetch.mockResolvedValue( + rawStatus( { + started: true, + finished: true, + progress: { woocommerce_analytics: { sent: 1, total: 2 } }, + } ) + ); + const { result } = renderHook( () => useSyncStatus() ); + + await waitFor( () => expect( result.current.isStalled ).toBe( true ) ); + expect( result.current.error ).toBeInstanceOf( Error ); + } ); + + it( 'surfaces fetch errors and never rejects triggerSync', async () => { + mockFetch.mockRejectedValueOnce( new Error( 'boom' ) ); + const { result } = renderHook( () => useSyncStatus() ); + + await waitFor( () => expect( result.current.error ).toBeInstanceOf( Error ) ); + expect( result.current.error?.message ).toBe( 'boom' ); + + // triggerSync resolves even if the trigger call fails. + mockTrigger.mockRejectedValueOnce( new Error( 'nope' ) ); + await act( async () => { + await result.current.triggerSync(); + } ); + expect( result.current.error?.message ).toBe( 'nope' ); + } ); + + it( 'starts complete and skips polling when the milestone is set', async () => { + mockScriptData.mockReturnValue( { + premium_analytics: { initial_full_sync_finished: 1_700_000_000 }, + } as ReturnType< typeof getScriptData > ); + + const { result } = renderHook( () => useSyncStatus() ); + + await waitFor( () => expect( result.current.isComplete ).toBe( true ) ); + expect( mockFetch ).not.toHaveBeenCalled(); + } ); +} ); diff --git a/projects/packages/premium-analytics/packages/site-sync/src/hooks/use-sync-status.ts b/projects/packages/premium-analytics/packages/site-sync/src/hooks/use-sync-status.ts new file mode 100644 index 000000000000..8c79e2248a7d --- /dev/null +++ b/projects/packages/premium-analytics/packages/site-sync/src/hooks/use-sync-status.ts @@ -0,0 +1,127 @@ +/** + * External dependencies + */ +import { getScriptData } from '@automattic/jetpack-script-data'; +import { __ } from '@wordpress/i18n'; +import { useState, useEffect, useRef, useCallback } from 'react'; +/** + * Internal dependencies + */ +import { fetchSyncStatus } from '../api/fetch-sync-status'; +import { triggerFullSync } from '../api/trigger-full-sync'; +import { POLL_INTERVAL } from '../constants'; +import { toSyncStatus, isSyncComplete, isSyncStalled } from '../status'; +import type { SyncStatus, UseSyncStatusReturn } from '../types'; + +/** + * Read the page-load milestone injected by the backend Sync_Status_Tracker. + * Static for the lifetime of the page — never re-read while polling. + * + * @return The initial full-sync milestone (unix ts), or 0 if never finished. + */ +function readMilestone(): number { + return getScriptData()?.premium_analytics?.initial_full_sync_finished ?? 0; +} + +/** + * Polls Jetpack's sync status and returns analytics-scoped progress. + * + * Polling auto-stops when the sync is complete, stalled, or errors. If the + * page-load milestone is already set, the dashboard is gated open immediately + * and no polling occurs. `triggerSync` POSTs the full-sync trigger and resumes + * polling; it never rejects (failures surface via `error`). + * + * @return The current sync state plus a `triggerSync` action. + */ +export function useSyncStatus(): UseSyncStatusReturn { + const milestoneRef = useRef< number >( readMilestone() ); + const [ data, setData ] = useState< SyncStatus >(); + const [ error, setError ] = useState< Error | null >( null ); + const [ isStalled, setIsStalled ] = useState( false ); + + const intervalRef = useRef< ReturnType< typeof setInterval > | null >( null ); + // Hold the latest `poll` in a ref so the interval always calls the current + // closure. Preserves the original package's pollRef pattern and keeps the + // interval stable if `poll`'s identity ever changes. + const pollRef = useRef< () => void >(); + + const clearPolling = useCallback( () => { + if ( intervalRef.current ) { + clearInterval( intervalRef.current ); + intervalRef.current = null; + } + }, [] ); + + const poll = useCallback( () => { + fetchSyncStatus() + .then( raw => { + const status = toSyncStatus( raw, milestoneRef.current ); + setData( status ); + setError( null ); + setIsStalled( false ); + + if ( isSyncComplete( status ) ) { + clearPolling(); + return; + } + + if ( isSyncStalled( status ) ) { + clearPolling(); + setIsStalled( true ); + setError( + new Error( __( 'Sync has stalled. Please try again.', 'jetpack-premium-analytics' ) ) + ); + } + } ) + .catch( ( e: unknown ) => { + clearPolling(); + const message = + e instanceof Error + ? e.message + : __( 'Unable to get sync status.', 'jetpack-premium-analytics' ); + setError( new Error( message ) ); + } ); + }, [ clearPolling ] ); + + pollRef.current = poll; + + const startPolling = useCallback( () => { + clearPolling(); + intervalRef.current = setInterval( () => { + pollRef.current?.(); + }, POLL_INTERVAL ); + }, [ clearPolling ] ); + + const triggerSync = useCallback( async () => { + clearPolling(); + setError( null ); + setIsStalled( false ); + + try { + await triggerFullSync(); + poll(); + startPolling(); + } catch ( e: unknown ) { + const message = + e instanceof Error ? e.message : __( 'Unable to start sync.', 'jetpack-premium-analytics' ); + setError( new Error( message ) ); + } + }, [ clearPolling, poll, startPolling ] ); + + useEffect( () => { + // Already finished before this page load — gate open, no polling needed. + if ( milestoneRef.current > 0 ) { + setData( toSyncStatus( {}, milestoneRef.current ) ); + return; + } + + poll(); + startPolling(); + return clearPolling; + }, [ poll, startPolling, clearPolling ] ); + + const isComplete = data ? isSyncComplete( data ) : false; + const isLoading = ! data && ! error; + + return { data, error, isLoading, isComplete, isStalled, triggerSync }; +} diff --git a/projects/packages/premium-analytics/packages/site-sync/src/index.ts b/projects/packages/premium-analytics/packages/site-sync/src/index.ts new file mode 100644 index 000000000000..db2c2897a38a --- /dev/null +++ b/projects/packages/premium-analytics/packages/site-sync/src/index.ts @@ -0,0 +1,2 @@ +export { useSyncStatus } from './hooks/use-sync-status'; +export type { SyncStatus, SyncStatusApiResponse, UseSyncStatusReturn } from './types'; diff --git a/projects/packages/premium-analytics/packages/site-sync/src/jetpack-script-data.d.ts b/projects/packages/premium-analytics/packages/site-sync/src/jetpack-script-data.d.ts new file mode 100644 index 000000000000..f366df3d7891 --- /dev/null +++ b/projects/packages/premium-analytics/packages/site-sync/src/jetpack-script-data.d.ts @@ -0,0 +1,14 @@ +/** + * The backend `Sync_Status_Tracker` (jetpack PR #49211) injects this block into + * `window.JetpackScriptData` via the `jetpack_admin_js_script_data` filter. The + * base `@automattic/jetpack-script-data` types don't know about it, so augment. + */ +import '@automattic/jetpack-script-data'; + +declare module '@automattic/jetpack-script-data' { + interface JetpackScriptData { + premium_analytics?: { + initial_full_sync_finished: number; + }; + } +} diff --git a/projects/packages/premium-analytics/packages/site-sync/src/status.test.ts b/projects/packages/premium-analytics/packages/site-sync/src/status.test.ts new file mode 100644 index 000000000000..b5da2b27e181 --- /dev/null +++ b/projects/packages/premium-analytics/packages/site-sync/src/status.test.ts @@ -0,0 +1,160 @@ +import { toSyncStatus, isSyncComplete, isSyncStalled } from './status'; + +describe( 'toSyncStatus', () => { + it( 'reports not-started when the sync has never run', () => { + const status = toSyncStatus( { started: false }, 0 ); + expect( status ).toEqual( { + isStarted: false, + isRunning: false, + percentage: 0, + initialFullSyncFinished: 0, + } ); + } ); + + it( 'computes analytics-scoped percentage from the module bucket', () => { + const status = toSyncStatus( + { + started: true, + finished: false, + progress: { woocommerce_analytics: { sent: 1, total: 4 } }, + }, + 0 + ); + expect( status.isRunning ).toBe( true ); + expect( status.percentage ).toBe( 25 ); + } ); + + it( 'ignores non-analytics modules when computing percentage', () => { + const status = toSyncStatus( + { + started: true, + finished: false, + progress: { + posts: { sent: 100, total: 100 }, + woocommerce_analytics: { sent: 1, total: 2 }, + }, + }, + 0 + ); + expect( status.percentage ).toBe( 50 ); + } ); + + it( 'is 100% when the page-load milestone is set', () => { + const status = toSyncStatus( { started: false }, 1_700_000_000 ); + expect( status.percentage ).toBe( 100 ); + expect( status.initialFullSyncFinished ).toBe( 1_700_000_000 ); + } ); + + it( 'caps percentage at 100', () => { + const status = toSyncStatus( + { + started: true, + finished: false, + progress: { woocommerce_analytics: { sent: 9, total: 4 } }, + }, + 0 + ); + expect( status.percentage ).toBe( 100 ); + } ); + + it( 'is complete (and gates open) when analytics hits 100% even if finished is still false', () => { + const status = toSyncStatus( + { + started: true, + finished: false, + progress: { woocommerce_analytics: { sent: 2, total: 2 } }, + }, + 0 + ); + expect( status.percentage ).toBe( 100 ); + expect( status.isRunning ).toBe( true ); + expect( isSyncComplete( status ) ).toBe( true ); + expect( isSyncStalled( status ) ).toBe( false ); + } ); + + it( 'treats a numeric finished timestamp as finished', () => { + const status = toSyncStatus( + { + started: true, + finished: 1_700_000_000, + progress: { woocommerce_analytics: { sent: 1, total: 2 } }, + }, + 0 + ); + // total > 0 branch: floor(1/2 * 100) = 50; numeric finished coerces to true + // so isRunning = started && !finished = false. + expect( status.percentage ).toBe( 50 ); + expect( status.isRunning ).toBe( false ); + expect( isSyncStalled( status ) ).toBe( true ); + } ); +} ); + +describe( 'isSyncComplete', () => { + it( 'is complete when the milestone is set', () => { + expect( + isSyncComplete( { + isStarted: false, + isRunning: false, + percentage: 100, + initialFullSyncFinished: 1_700_000_000, + } ) + ).toBe( true ); + } ); + + it( 'is complete when analytics progress reaches 100 this session', () => { + expect( + isSyncComplete( { + isStarted: true, + isRunning: false, + percentage: 100, + initialFullSyncFinished: 0, + } ) + ).toBe( true ); + } ); + + it( 'is not complete mid-progress', () => { + expect( + isSyncComplete( { + isStarted: true, + isRunning: true, + percentage: 50, + initialFullSyncFinished: 0, + } ) + ).toBe( false ); + } ); +} ); + +describe( 'isSyncStalled', () => { + it( 'is stalled when started, no longer running, and not complete', () => { + expect( + isSyncStalled( { + isStarted: true, + isRunning: false, + percentage: 50, + initialFullSyncFinished: 0, + } ) + ).toBe( true ); + } ); + + it( 'is not stalled when it never started', () => { + expect( + isSyncStalled( { + isStarted: false, + isRunning: false, + percentage: 0, + initialFullSyncFinished: 0, + } ) + ).toBe( false ); + } ); + + it( 'is not stalled when complete', () => { + expect( + isSyncStalled( { + isStarted: true, + isRunning: false, + percentage: 100, + initialFullSyncFinished: 1_700_000_000, + } ) + ).toBe( false ); + } ); +} ); diff --git a/projects/packages/premium-analytics/packages/site-sync/src/status.ts b/projects/packages/premium-analytics/packages/site-sync/src/status.ts new file mode 100644 index 000000000000..c88b416a4d55 --- /dev/null +++ b/projects/packages/premium-analytics/packages/site-sync/src/status.ts @@ -0,0 +1,56 @@ +/** + * Internal dependencies + */ +import { ANALYTICS_SYNC_MODULE } from './constants'; +import type { SyncStatus, SyncStatusApiResponse } from './types'; + +/** + * Normalize Jetpack's raw sync status into the analytics-scoped shape. + * + * @param raw - Raw GET /jetpack/v4/sync/status response. + * @param milestone - Page-load milestone (unix ts, or 0 if never finished). + * @return Analytics-scoped sync status. + */ +export function toSyncStatus( raw: SyncStatusApiResponse, milestone: number ): SyncStatus { + const started = Boolean( raw.started ); + const finished = Boolean( raw.finished ); + const bucket = raw.progress?.[ ANALYTICS_SYNC_MODULE ]; + const total = bucket?.total ?? 0; + const sent = bucket?.sent ?? 0; + + let percentage = 0; + if ( total > 0 ) { + percentage = Math.min( 100, Math.floor( ( sent / total ) * 100 ) ); + } else if ( milestone > 0 || finished ) { + // No analytics bucket in this batch, but the sync has finished (now or + // before) — treat analytics as fully synced. + percentage = 100; + } + + return { + isStarted: started, + isRunning: started && ! finished, + percentage, + initialFullSyncFinished: milestone, + }; +} + +/** + * The analytics initial sync has finished — either before this page load + * (milestone) or analytics progress reached 100 during this session. + * @param status - Normalized sync status. + * @return Whether the analytics initial sync has finished. + */ +export function isSyncComplete( status: SyncStatus ): boolean { + return status.initialFullSyncFinished > 0 || status.percentage >= 100; +} + +/** + * Stalled = the sync started but is no longer running and hasn't completed. A + * sync that never started is NOT stalled — it just needs to be triggered. + * @param status - Normalized sync status. + * @return Whether the sync has stalled. + */ +export function isSyncStalled( status: SyncStatus ): boolean { + return status.isStarted && ! status.isRunning && ! isSyncComplete( status ); +} diff --git a/projects/packages/premium-analytics/packages/site-sync/src/types.ts b/projects/packages/premium-analytics/packages/site-sync/src/types.ts new file mode 100644 index 000000000000..07262f6da459 --- /dev/null +++ b/projects/packages/premium-analytics/packages/site-sync/src/types.ts @@ -0,0 +1,39 @@ +/** + * Subset of Jetpack core's GET /jetpack/v4/sync/status response that this + * package consumes. `progress` is keyed by sync-module name; each module + * reports items `sent` of `total`. + */ +export type SyncStatusApiResponse = { + started?: boolean; + finished?: boolean | number; + progress?: Record< string, { sent?: number; total?: number } >; +}; + +/** + * Normalized, analytics-scoped sync status. + */ +export type SyncStatus = { + isStarted: boolean; + isRunning: boolean; + /** Analytics-module progress, 0–100, computed client-side. */ + percentage: number; + /** Page-load milestone: unix ts when the initial analytics sync first finished, else 0. */ + initialFullSyncFinished: number; +}; + +/** + * Return type for the useSyncStatus hook. + */ +export type UseSyncStatusReturn = { + data: SyncStatus | undefined; + error: Error | null; + isLoading: boolean; + isComplete: boolean; + isStalled: boolean; + /** + * POST the full-sync trigger and resume polling. The returned promise always + * resolves; failures surface via `error` so callers can `void triggerSync()` + * from event handlers without an unhandled rejection. + */ + triggerSync: () => Promise< void >; +};