diff --git a/projects/packages/premium-analytics/changelog/wooa7s-1317-integrate-routing-package-into-analytics b/projects/packages/premium-analytics/changelog/wooa7s-1317-integrate-routing-package-into-analytics new file mode 100644 index 000000000000..904cd0aaf523 --- /dev/null +++ b/projects/packages/premium-analytics/changelog/wooa7s-1317-integrate-routing-package-into-analytics @@ -0,0 +1,4 @@ +Significance: patch +Type: added + +Port routing package (date-range/comparison search-param helpers and the staged-search hook) as an internal package from next-woocommerce-analytics. diff --git a/projects/packages/premium-analytics/eslint.config.mjs b/projects/packages/premium-analytics/eslint.config.mjs index 7016c1fa7907..352e814ffb97 100644 --- a/projects/packages/premium-analytics/eslint.config.mjs +++ b/projects/packages/premium-analytics/eslint.config.mjs @@ -44,5 +44,18 @@ export default defineConfig( '@typescript-eslint/no-explicit-any': 'off', 'import/no-extraneous-dependencies': 'off', }, + }, + { + // The routing port also imports `react` directly (the staged-search + // hook), flagged as extraneous because the internal package's deps are + // declared on the parent manifest. + files: [ 'packages/routing/**' ], + rules: { + 'jsdoc/require-jsdoc': 'off', + 'jsdoc/require-param': 'off', + 'jsdoc/require-returns': 'off', + 'jsdoc/check-indentation': 'off', + 'import/no-extraneous-dependencies': 'off', + }, } ); diff --git a/projects/packages/premium-analytics/packages/routing/README.md b/projects/packages/premium-analytics/packages/routing/README.md new file mode 100644 index 000000000000..bf6b61d9823e --- /dev/null +++ b/projects/packages/premium-analytics/packages/routing/README.md @@ -0,0 +1,138 @@ +# @automattic/jetpack-premium-analytics-routing + +Utilities for handling **routing and URL search parameters** in +WooCommerce Analytics with TypeScript integration. + +This package centralizes logic for encoding and decoding route params so +that date ranges, filters, comparison parameters, and other query +parameters are handled consistently across the application. + +## Features + +- **Date range encoding** – Convert `DateRange` objects into ISO strings + with timezone support +- **Comparison parameters** – Handle `compare_from`, `compare_to`, + `compare_preset`, and `comp` flags +- **@wordpress/route integration** – Type-safe navigation with search + parameter management +- **Timezone handling** – Automatic timezone conversion for consistent + date handling +- **URL state persistence** – Maintains filter and comparison state + across page refreshes +- **Navigation utilities** – Write encoded parameters directly to the URL + using router navigation + +## Usage Examples + +### Date Range Navigation + +```typescript +import { writeDateRangeToSearch } from '@jetpack-premium-analytics/routing'; +import { useNavigate } from '@wordpress/route'; + +function DateRangeSelector() { + const navigate = useNavigate(); + + const handleRangeChange = nextRange => { + writeDateRangeToSearch( { + navigate, + to: '/wc-analytics/dashboard', + range: nextRange, + search: { interval: 'day' }, // Preserve other params + } ); + }; +} +``` + +### Comparison Parameter Management + +```typescript +import { writeComparisonToSearch } from '@jetpack-premium-analytics/routing'; + +function ComparisonSelector() { + const navigate = useNavigate(); + + const handleComparisonChange = ( range, presetId ) => { + writeComparisonToSearch( { + navigate, + to: '/wc-analytics/dashboard', + range, + presetId, + enabled: !! range, + } ); + }; +} +``` + +## API Reference + +### `writeDateRangeToSearch( options )` + +Writes a `DateRange` to the URL using the provided `navigate` function. + +**Parameters:** + +- **`navigate`** – Navigation function from `useNavigate()` (`@wordpress/route`) +- **`to`** – Destination path (e.g., `'/wc-analytics/dashboard'`) +- **`range`** – `{ from: Date | undefined; to?: Date | undefined }` +- **`timezone?`** _(optional)_ – Override timezone for date conversion +- **`search?`** _(optional)_ – Additional search params to preserve/set + +**URL Parameters Generated:** + +- `from` – ISO string with timezone offset +- `to` – ISO string with timezone offset + +### `writeComparisonToSearch( options )` + +Writes comparison parameters to the URL for period-over-period analysis. + +**Parameters:** + +- **`navigate`** – Navigation function from `@wordpress/route` +- **`to`** – Destination path +- **`range?`** – Comparison date range +- **`presetId?`** – Preset identifier (e.g., 'previous_period') +- **`enabled?`** – Whether comparison is active +- **`timezone?`** – Override timezone +- **`search?`** – Additional search params + +**URL Parameters Generated:** + +- `compare_from` – Comparison start date (ISO string) +- `compare_to` – Comparison end date (ISO string) +- `compare_preset` – Preset identifier +- `comp` – '1' when comparison enabled, undefined when disabled + +### `encodeDateToSearchParam( date?, timezone? )` + +Low-level function to convert a Date to an ISO string with timezone. + +**Parameters:** + +- **`date?`** – Date to encode (returns undefined if not provided) +- **`timezone?`** – Timezone override + +**Returns:** ISO string with timezone offset or undefined + +## Architecture + +### URL Parameter Structure + +``` +/wc-analytics/dashboard? + from=2025-01-01T00:00:00-08:00& # Primary date range + to=2025-01-31T23:59:59-08:00& + interval=day& # Data granularity + compare_from=2024-12-01T00:00:00-08:00& # Comparison range + compare_to=2024-12-31T23:59:59-08:00& + compare_preset=previous_period& # Comparison preset + comp=1 # Comparison enabled flag +``` + +### Timezone Handling + +1. **Local Timezone Detection**: Uses site timezone from WordPress settings +2. **ISO String Generation**: Converts dates to ISO strings with timezone offset +3. **Consistent API Calls**: Ensures all API requests use properly formatted dates +4. **Cross-browser Support**: Handles timezone differences across different environments diff --git a/projects/packages/premium-analytics/packages/routing/package.json b/projects/packages/premium-analytics/packages/routing/package.json new file mode 100644 index 000000000000..8533c37fae25 --- /dev/null +++ b/projects/packages/premium-analytics/packages/routing/package.json @@ -0,0 +1,14 @@ +{ + "name": "@automattic/jetpack-premium-analytics-routing", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "src/index.ts", + "types": "src/index.ts", + "sideEffects": false, + "dependencies": { + "@jetpack-premium-analytics/data": "workspace:*", + "@jetpack-premium-analytics/datetime": "workspace:*", + "@wordpress/route": "0.12.0" + } +} diff --git a/projects/packages/premium-analytics/packages/routing/src/hooks/index.ts b/projects/packages/premium-analytics/packages/routing/src/hooks/index.ts new file mode 100644 index 000000000000..a29505a5a269 --- /dev/null +++ b/projects/packages/premium-analytics/packages/routing/src/hooks/index.ts @@ -0,0 +1 @@ +export { useStagedSearch } from './use-staged-search'; diff --git a/projects/packages/premium-analytics/packages/routing/src/hooks/use-date-range-search/index.ts b/projects/packages/premium-analytics/packages/routing/src/hooks/use-date-range-search/index.ts new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/projects/packages/premium-analytics/packages/routing/src/hooks/use-date-range-search/use-date-range-search.ts b/projects/packages/premium-analytics/packages/routing/src/hooks/use-date-range-search/use-date-range-search.ts new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/projects/packages/premium-analytics/packages/routing/src/hooks/use-staged-search/README.md b/projects/packages/premium-analytics/packages/routing/src/hooks/use-staged-search/README.md new file mode 100644 index 000000000000..4fb040e06e24 --- /dev/null +++ b/projects/packages/premium-analytics/packages/routing/src/hooks/use-staged-search/README.md @@ -0,0 +1,146 @@ +# `useStagedSearch` — staged UI + atomic URL commits + +Make the UI react instantly while the URL stays the source of truth. Edits are +staged locally and then committed atomically to the URL (one navigation). Back/ +Forward stays smooth. + +--- + +## Concepts + +- **committed**: current URL state (`useSearch`). +- **staged**: optimistic local edits (what the user is changing now). +- **effective**: `staged` merged over `committed` (ignoring `undefined`). + +Atomic commit: + +- `commit()` writes all staged changes in one `navigate({ search })`. +- Optional debounced auto-commit uses `replace: true` to avoid dirty history. +- On confirm, call `commit({ replace: false })` to push a history entry. + +--- + +## API + +```ts +type UseStagedSearchOptions< TFrom extends string > = { + from: TFrom; // TanStack route id/path + autoCommitDebounceMs?: number; // optional debounce in ms +}; + +type UseStagedSearchReturn< TSearch > = { + committed: TSearch; // current URL state + staged: TSearch; // optimistic local snapshot + effective: TSearch; // staged over committed per key + isSyncing: boolean; // true while committing + isDirty: boolean; // staged differs from committed + stage( patch: Partial< TSearch > ): void; + commit( opts?: { replace?: boolean } ): void; + revert(): void; + cancelAutoCommit(): void; +}; +``` + +Notes: + +- Internally uses `useSearch( { from } )` and `useNavigate( { from } )`. +- No `to` is passed on commit, so the current route is preserved. + +--- + +## Minimal usage + +```tsx +import { useMemo, useCallback } from 'react'; +import { useStagedSearch, encodeDateToSearchParam } from '@jetpack-premium-analytics/routing'; +import { localTZDate } from '@jetpack-premium-analytics/data'; +import type { DateRange } from '@jetpack-premium-analytics/datetime'; + +type Search = { + from?: string; + to?: string; + compare_preset?: string; + comp?: string; +}; + +export function DashboardHeader() { + const { effective, stage, commit } = useStagedSearch< Search, '/wc-analytics/dashboard' >( { + from: '/wc-analytics/dashboard', + // autoCommitDebounceMs: 250, + } ); + + const range = useMemo( + () => ( { + from: effective.from ? localTZDate( effective.from ) : undefined, + to: effective.to ? localTZDate( effective.to ) : undefined, + } ), + [ effective.from, effective.to ] + ); + + const onRangeChange = useCallback( + ( next: DateRange | undefined ) => { + if ( ! next ) { + return; + } + stage( { + from: encodeDateToSearchParam( next.from ), + to: encodeDateToSearchParam( next.to ), + } ); + commit( { replace: false } ); + }, + [ stage, commit ] + ); + + // ... +} +``` + +--- + +## Best practices + +**What to use when** + +- Render and fetch: **`effective`** +- Inputs being edited: **`staged`** +- URL-driven side effects / analytics / share links: **`committed`** + +**Navigation and history** + +- Do not pass `to` on commit; update only `search` for SPA smoothness. +- Explicit commit: `commit( { replace: false } )` pushes history. +- Auto-commit (debounce): `replace: true` during continuous edits. +- The URL→UI mirror keeps Back/Forward fluid and flicker-free. + +**Data fetching** + +```ts +const { effective, isSyncing } = useStagedSearch< Search >( { + from: '/wc-analytics/dashboard', +} ); + +const query = useQuery( { + queryKey: [ 'orders', effective ], + enabled: ! isSyncing, + queryFn: () => fetchOrders( effective ), +} ); +``` + +**Debounce guidance** + +- `autoCommitDebounceMs`: 200–300 ms works well for date pickers. +- During edits → debounced replace-commits. +- On confirm (Apply/close) → `commit( { replace: false } )`. + +**Removing params** + +- `effective` ignores `undefined` in `staged`. To remove a key, stage it as + `undefined` and commit; the updater omits the key in the URL. + +**Avoid** + +- Writing the URL from multiple components (breaks atomicity). +- Mixing `useSearch()` reads in children that also depend on staging. +- Always using `replace: true` on explicit commits. + +--- diff --git a/projects/packages/premium-analytics/packages/routing/src/hooks/use-staged-search/index.ts b/projects/packages/premium-analytics/packages/routing/src/hooks/use-staged-search/index.ts new file mode 100644 index 000000000000..a29505a5a269 --- /dev/null +++ b/projects/packages/premium-analytics/packages/routing/src/hooks/use-staged-search/index.ts @@ -0,0 +1 @@ +export { useStagedSearch } from './use-staged-search'; diff --git a/projects/packages/premium-analytics/packages/routing/src/hooks/use-staged-search/use-staged-search.tsx b/projects/packages/premium-analytics/packages/routing/src/hooks/use-staged-search/use-staged-search.tsx new file mode 100644 index 000000000000..bcfc24e7d504 --- /dev/null +++ b/projects/packages/premium-analytics/packages/routing/src/hooks/use-staged-search/use-staged-search.tsx @@ -0,0 +1,273 @@ +/** + * External dependencies + */ +import { useNavigate, useSearch } from '@wordpress/route'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; + +type AnyObject = Record< string, unknown >; + +export type UseStagedSearchOptions< TFrom extends string > = { + from: TFrom; // e.g., '/wc-analytics/dashboard', + + /** + * If provided, stage() will schedule an automatic debounced commit + * after the given milliseconds. Those auto-commits use replace: true + * to avoid polluting the browser history during continuous interaction. + */ + autoCommitDebounceMs?: number; +}; + +export type UseStagedSearchReturn< TSearch extends AnyObject > = { + /** + * The current URL state. + */ + committed: TSearch; + + /** + * The optimistic snapshot for immediate UI. + */ + staged: TSearch; + + /** + * The effective state for rendering and data fetching. + */ + effective: TSearch; + + /** + * Whether the process is syncing. + */ + isSyncing: boolean; + + /** + * Whether the staged state differs from the committed state. + */ + isDirty: boolean; + + /** + * Stage a local patch without touching the URL. + */ + stage: ( patch: Partial< TSearch > ) => void; + + /** + * Commit all staged changes in a single atomic navigate(). + */ + commit: ( opts?: { replace?: boolean } ) => void; + + /** + * Discard local changes and return to committed snapshot. + */ + revert: () => void; + + /** + * Cancel pending debounced commit. + */ + cancelAutoCommit: () => void; +}; + +function shallowEqual( a: AnyObject, b: AnyObject ) { + if ( a === b ) { + return true; + } + + const ak = Object.keys( a ); + const bk = Object.keys( b ); + if ( ak.length !== bk.length ) { + return false; + } + + for ( const k of ak ) { + if ( a[ k ] !== b[ k ] ) { + return false; + } + } + + return true; +} + +function mergeDefined< T extends AnyObject >( base: T, patch: Partial< T > ): T { + const out: AnyObject = { ...base }; + for ( const key in patch ) { + const val = patch[ key as keyof T ]; + if ( val !== undefined ) { + out[ key ] = val as unknown; + } + } + return out as T; +} + +export function useStagedSearch< TSearch extends AnyObject, TFrom extends string >( + opts: UseStagedSearchOptions< TFrom > +): UseStagedSearchReturn< TSearch > { + const navigate = useNavigate( { from: opts.from } ); + const committed = useSearch( { from: opts.from } ) as TSearch; + + /* + * Stage the search params. + */ + const [ staged, setStaged ] = useState< TSearch >( committed ); + + /* + * Track if the process is syncing. + */ + const [ isSyncing, setIsSyncing ] = useState( false ); // not used yet + + // Buffer for not-yet-committed changes. + const bufferRef = useRef< Partial< TSearch > >( {} ); + + // Debounce timer for auto-commit. + const timerRef = useRef< ReturnType< typeof setTimeout > | null >( null ); + + /** + * Mirror URL -> staged. + * If URL changes (back/forward or external writes), align staged snapshot. + * Also clear syncing flag after the router applies the new committed state. + */ + useEffect( () => { + setStaged( committed ); + bufferRef.current = {}; + if ( isSyncing ) { + setIsSyncing( false ); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ committed ] ); + + /** + * Cancel pending debounced auto-commit. + */ + const cancelAutoCommit = useCallback( () => { + if ( timerRef.current ) { + clearTimeout( timerRef.current ); + timerRef.current = null; + } + }, [] ); + + /** + * Cleanup on unmount. + */ + useEffect( () => { + return () => { + cancelAutoCommit(); + }; + }, [ cancelAutoCommit ] ); + + /** + * Stage a local patch without touching the URL immediately. + * If autoCommitDebounceMs is set, schedule a debounced replace-commit. + */ + const stage = useCallback( + ( patch: Partial< TSearch > ) => { + setStaged( prev => ( { ...prev, ...patch } ) ); + bufferRef.current = { ...bufferRef.current, ...patch }; + + if ( typeof opts.autoCommitDebounceMs === 'number' ) { + cancelAutoCommit(); + timerRef.current = setTimeout( () => { + navigate( { + replace: true, // do not pollute history while interacting + search: prev => ( { + ...prev, + ...( bufferRef.current as Partial< TSearch > ), + } ), + } ); + timerRef.current = null; + }, opts.autoCommitDebounceMs ); + } + }, + [ navigate, opts.autoCommitDebounceMs, cancelAutoCommit ] + ); + + /** + * Commit all staged changes in a single atomic navigate(). + * - No `to`: keep the current route (prevents heavy remounts). + * - Default `replace` to false so history is preserved on explicit commits. + * - Cancels any pending debounced commit. + */ + const commit = useCallback( + ( commitOpts?: { replace?: boolean } ) => { + const patch = bufferRef.current; + const hasPatch = patch && Object.keys( patch ).length > 0; + + // Cancel any pending debounced replace-commit + cancelAutoCommit(); + + // If buffer is empty but staged differs from committed, compute a minimal diff + let diff: Partial< TSearch > | null = null; + if ( ! hasPatch ) { + const merged = { + ...( committed as AnyObject ), + ...( staged as AnyObject ), + } as TSearch; + + if ( ! shallowEqual( merged as AnyObject, committed as AnyObject ) ) { + diff = {}; + for ( const key in merged ) { + // eslint-disable-next-line no-prototype-builtins + if ( ( committed as AnyObject ).hasOwnProperty( key ) ) { + if ( ( committed as AnyObject )[ key ] !== ( staged as AnyObject )[ key ] ) { + ( diff as AnyObject )[ key ] = ( staged as AnyObject )[ key ]; + } + } else { + ( diff as AnyObject )[ key ] = ( staged as AnyObject )[ key ]; + } + } + } + } + + const finalPatch = hasPatch ? ( patch as Partial< TSearch > ) : diff; + + if ( ! finalPatch || Object.keys( finalPatch ).length === 0 ) { + return; + } + + setIsSyncing( true ); + + navigate( { + replace: commitOpts?.replace ?? false, // explicit commits push into history + search: prev => ( { + ...prev, + ...( finalPatch as Partial< TSearch > ), + } ), + } ); + + // isSyncing is flipped off by the committed->staged mirror effect. + }, + [ navigate, committed, staged, cancelAutoCommit ] + ); + + /** + * Discard local changes and return to committed snapshot. + */ + const revert = useCallback( () => { + cancelAutoCommit(); + bufferRef.current = {}; + setStaged( committed ); + }, [ committed, cancelAutoCommit ] ); + + /** + * Effective = committed merged with defined staged keys. + * Use this as the single source for rendering and data fetching. + */ + const effective = useMemo( + () => mergeDefined( committed, staged ), + [ committed, staged ] + ) as TSearch; + + /* + * Dirty if there is a buffer or staged differs from committed. + */ + const isDirty = + Object.keys( bufferRef.current ).length > 0 && + ! shallowEqual( staged as AnyObject, committed as AnyObject ); + + return { + committed, + staged, + effective, + isSyncing, + isDirty, + stage, + commit, + revert, + cancelAutoCommit, + }; +} diff --git a/projects/packages/premium-analytics/packages/routing/src/index.ts b/projects/packages/premium-analytics/packages/routing/src/index.ts new file mode 100644 index 000000000000..b6518f4c17a1 --- /dev/null +++ b/projects/packages/premium-analytics/packages/routing/src/index.ts @@ -0,0 +1,8 @@ +export { + encodeDateToSearchParam, + writeDateRangeToSearch, + writeComparisonToSearch, +} from './search/date-range'; + +export { deriveComparisonRange } from './search/comparison'; +export { useStagedSearch } from './hooks'; diff --git a/projects/packages/premium-analytics/packages/routing/src/search/comparison/derive-comparison-range.ts b/projects/packages/premium-analytics/packages/routing/src/search/comparison/derive-comparison-range.ts new file mode 100644 index 000000000000..421098bb12e0 --- /dev/null +++ b/projects/packages/premium-analytics/packages/routing/src/search/comparison/derive-comparison-range.ts @@ -0,0 +1,95 @@ +/** + * External dependencies + */ +import { + normalizeReportParams, + dateToISOStringWithLocalTZ, + getSiteTimezone, +} from '@jetpack-premium-analytics/data'; +import { + getComparisonRangeFromPreset, + type ComparisonPresetId, + startOfDayTZ, + endOfDayTZ, +} from '@jetpack-premium-analytics/datetime'; + +type ReportParams = NonNullable< Parameters< typeof normalizeReportParams >[ 0 ] >; + +/** + * Normalize URL/UI comparison preset IDs to canonical ComparisonPresetId. + * Accepts variants with hyphen or underscore for robustness. + * + * @param value - Raw preset ID from URL or UI (e.g., 'previous_period' or 'previous-period') + * @return Canonical ComparisonPresetId or undefined if invalid + */ +const toComparisonPresetId = ( value?: string ): ComparisonPresetId | undefined => { + switch ( value ) { + case 'previous-period': + case 'previous_period': + return 'previous-period'; + case 'previous-week': + case 'previous_week': + return 'previous-week'; + case 'previous-month': + case 'previous_month': + return 'previous-month'; + case 'previous-year': + case 'previous_year': + return 'previous-year'; + default: + return undefined; + } +}; + +/** + * Derive compare_from/compare_to from the main range + preset, + * honoring the site's timezone via existing data utils. + * + * Rules: + * - Only derive when comparison is enabled (comp === "1") AND a preset is present. + * - Normalize main range to site-local day bounds before computing presets. + * - Return ISO strings WITH site offset (same format you write to the URL). + */ +export function deriveComparisonRange( opts: ReportParams ): + | { + compare_from: string; + compare_to: string; + } + | undefined { + // Require comparison enabled + preset + const presetId = toComparisonPresetId( opts.compare_preset ); + if ( opts.comp !== '1' || ! presetId ) { + return undefined; + } + + // Need valid main range + if ( ! opts.from || ! opts.to ) { + return undefined; + } + + // Parse URL params (ISO+offset) to instants + const fromInstant = new Date( opts.from ); + const toInstant = new Date( opts.to ); + if ( isNaN( fromInstant.getTime() ) || isNaN( toInstant.getTime() ) ) { + return undefined; + } + + // Normalize to site-local day bounds + const timezone = getSiteTimezone(); + const reference = { + from: startOfDayTZ( fromInstant, timezone ), + to: endOfDayTZ( toInstant, timezone ), + }; + + // Compute comparison range (Dates) + const cmp = getComparisonRangeFromPreset( reference, presetId ); + if ( ! cmp?.from || ! cmp?.to ) { + return undefined; + } + + // Serialize back to ISO with site offset (string-to-string stable) + return { + compare_from: dateToISOStringWithLocalTZ( cmp.from ), + compare_to: dateToISOStringWithLocalTZ( cmp.to ), + }; +} diff --git a/projects/packages/premium-analytics/packages/routing/src/search/comparison/index.ts b/projects/packages/premium-analytics/packages/routing/src/search/comparison/index.ts new file mode 100644 index 000000000000..33bfe8d0427a --- /dev/null +++ b/projects/packages/premium-analytics/packages/routing/src/search/comparison/index.ts @@ -0,0 +1 @@ +export { deriveComparisonRange } from './derive-comparison-range'; diff --git a/projects/packages/premium-analytics/packages/routing/src/search/date-range/date-range.ts b/projects/packages/premium-analytics/packages/routing/src/search/date-range/date-range.ts new file mode 100644 index 000000000000..3bb31a216d42 --- /dev/null +++ b/projects/packages/premium-analytics/packages/routing/src/search/date-range/date-range.ts @@ -0,0 +1,98 @@ +/** + * External dependencies + */ +import { localTZDate, dateToISOStringWithLocalTZ } from '@jetpack-premium-analytics/data'; +import type { DateRange } from '@jetpack-premium-analytics/datetime'; + +/** + * Serializes a Date into an ISO string with the site's timezone + * (or returns an empty string if no date is provided). + * Useful for writing dates to the URL and for API requests. + */ +export function encodeDateToSearchParam( date?: Date, timezone?: string ): string | undefined { + return date ? dateToISOStringWithLocalTZ( localTZDate( date, timezone ) ) : undefined; +} + +type WriteDateRangeToSearchProps = { + navigate: ( opts: { + to: string; + search: + | Record< string, string | undefined > + | ( ( prev: Record< string, string | undefined > ) => Record< string, string | undefined > ); + } ) => void; + to: string; + range: DateRange; + timezone?: string; + search?: Record< string, string | undefined | null >; +}; + +/** + * Writes a DateRange to the URL using navigate(). + * + * - Centralizes the conversion from Date -> ISO(+offset) according to + * the site's timezone. + * - If you need to preserve/propagate `interval` or other params, + * pass them in `search`. + * - Note: whether other existing params are preserved depends on the + * router's navigate implementation. This helper sets an explicit object. + */ +export function writeDateRangeToSearch( { + navigate, + to: toPath, + range, + timezone, + search, +}: WriteDateRangeToSearchProps ) { + const fromParam = encodeDateToSearchParam( range?.from, timezone ); + const toParam = encodeDateToSearchParam( range?.to, timezone ); + + navigate( { + to: toPath, + search: ( prev: Record< string, string | undefined > ) => ( { + ...prev, + from: fromParam, + to: toParam, + ...search, + } ), + } ); +} + +type WriteComparisonToSearchProps = { + navigate: ( opts: { + to: string; + search: + | Record< string, string | undefined > + | ( ( prev: Record< string, string | undefined > ) => Record< string, string | undefined > ); + } ) => void; + to: string; + range?: DateRange; + presetId?: string; + enabled?: boolean; + timezone?: string; + search?: Record< string, string | undefined | null >; +}; + +export function writeComparisonToSearch( { + navigate, + to: toPath, + range, + presetId, + enabled, + timezone, + search, +}: WriteComparisonToSearchProps ) { + const fromParam = encodeDateToSearchParam( range?.from, timezone ); + const toParam = encodeDateToSearchParam( range?.to, timezone ); + + navigate( { + to: toPath, + search: ( prev: Record< string, string | undefined > ) => ( { + ...prev, + compare_from: fromParam, + compare_to: toParam, + compare_preset: presetId ?? undefined, + comp: enabled ? '1' : undefined, + ...search, + } ), + } ); +} diff --git a/projects/packages/premium-analytics/packages/routing/src/search/date-range/index.ts b/projects/packages/premium-analytics/packages/routing/src/search/date-range/index.ts new file mode 100644 index 000000000000..4d8482211148 --- /dev/null +++ b/projects/packages/premium-analytics/packages/routing/src/search/date-range/index.ts @@ -0,0 +1,5 @@ +export { + encodeDateToSearchParam, + writeDateRangeToSearch, + writeComparisonToSearch, +} from './date-range';