Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Significance: minor
Type: added

Premium Analytics: add the single post/page detail traffic view, with its own route, header, tabs, and customizable per-tab widget grids.
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@
"dependencies": {
"@jetpack-premium-analytics/data": "workspace:*",
"@jetpack-premium-analytics/datetime": "workspace:*",
"@wordpress/route": "0.15.0"
"@wordpress/core-data": "7.49.1",
"@wordpress/data": "10.49.0",
"@wordpress/route": "0.15.0",
"date-fns": "4.1.0",
"react": "18.3.1"
}
}
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export { useStagedSearch } from './use-staged-search';
export { useReportDateFilters, type ReportDateFilters } from './use-report-date-filters';
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { useReportDateFilters, type ReportDateFilters } from './use-report-date-filters';
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
/**
* External dependencies
*/
import {
getSiteTimezone,
localTZDate,
type ReportQueryParams,
} from '@jetpack-premium-analytics/data';
import {
type ComparisonPresetId,
type DateRange,
type PrimaryPresetId,
} from '@jetpack-premium-analytics/datetime';
import { store as coreStore } from '@wordpress/core-data';
import { useSelect } from '@wordpress/data';
import { endOfDay, isValid } from 'date-fns';
import { useCallback, useMemo } from 'react';
/**
* Internal dependencies
*/
import { deriveComparisonRange } from '../../search/comparison';
import { encodeDateToSearchParam } from '../../search/date-range';
import { useStagedSearch } from '../use-staged-search';

type ReportQuerySearchParams = Partial<
ReportQueryParams & {
preset?: PrimaryPresetId;
compare_preset?: ComparisonPresetId;
comp?: '1';
}
>;

/**
* The values and callbacks that drive `DateFiltersPanel`, minus the
* `containerElement` ref which the consuming page owns.
*/
type PickerRange = { from: Date | undefined; to: Date | undefined };

export type ReportDateFilters = {
presetId?: PrimaryPresetId;
range: PickerRange;
appliedPresetId?: PrimaryPresetId;
appliedRange: PickerRange;
comparisonPresetId?: ComparisonPresetId;
onChange: ( range?: DateRange, presetId?: PrimaryPresetId ) => void;
onComparisonChange: ( range: DateRange | undefined, presetId?: ComparisonPresetId ) => void;
onApply: () => void;
onCancel: () => void;
canApply: boolean;
timeZone: string;
};

/**
* Parse search-param dates into a picker range, dropping unparseable values to
* `undefined`. The picker reads these straight from the URL, so a malformed
* `from`/`to` (e.g. a hand-edited or under-encoded deep link where the `+`
* offset decoded to a space) must not become an invalid Date — `formatDate`
* throws "Invalid time value" on one and would white-screen the page.
*
* @param from - The `from` search param.
* @param to - The `to` search param.
* @return The parsed range, with invalid endpoints as `undefined`.
*/
function toPickerRange( from?: string, to?: string ) {
const parse = ( value?: string ) => {
if ( ! value ) {
return undefined;
}
const date = localTZDate( value );
return isValid( date ) ? date : undefined;
};

return {
from: parse( from ),
to: parse( to ),
};
}

/**
* Controller for the date-range and comparison filters, backed by the URL
* search params on a given route.
*
* Edits are staged locally and committed atomically on Apply (or immediately
* for comparison changes), so widgets re-fetch only on commit. The hook returns
* everything `DateFiltersPanel` needs except the responsive-measurement
* `containerElement`, which the page owns. Shared by every analytics page that
* mounts the panel so the staged-search behavior stays identical across them.
*
* @param from - The route path the search params are bound to (e.g. `/`).
* @return Props for `DateFiltersPanel`.
*/
export function useReportDateFilters< TFrom extends string >( from: TFrom ): ReportDateFilters {
const { committed, effective, stage, commit, revert, isDirty } = useStagedSearch<
ReportQuerySearchParams,
TFrom
>( { from } );

const presetId = useMemo( () => effective.preset ?? undefined, [ effective.preset ] );
const range = useMemo(
() => toPickerRange( effective.from, effective.to ),
[ effective.from, effective.to ]
);

const appliedPresetId = useMemo( () => committed.preset ?? undefined, [ committed.preset ] );
const appliedRange = useMemo(
() => toPickerRange( committed.from, committed.to ),
[ committed.from, committed.to ]
);

const onChange = useCallback(
( nextRange?: DateRange, nextPresetId?: PrimaryPresetId ) => {
if ( ! nextRange && ! nextPresetId ) {
return;
}

if ( nextRange && nextRange.from && nextRange.to ) {
/*
* Stage `from` and `to` as ISO strings. The `to` date is adjusted
* to the end of the day, since the date picker core component sets
* the time to 00:00:00.
*/
const rangeFrom = encodeDateToSearchParam( nextRange.from );
const rangeTo = encodeDateToSearchParam( endOfDay( nextRange.to ) );
const patch: ReportQuerySearchParams = { from: rangeFrom, to: rangeTo };

/*
* When comparison is enabled, re-derive the comparison window from
* the new primary range so widgets compare against the matching
* previous period instead of the stale dates.
*/
if ( effective.comp === '1' ) {
const derived = deriveComparisonRange( { ...effective, from: rangeFrom, to: rangeTo } );
if ( derived ) {
patch.compare_from = derived.compare_from;
patch.compare_to = derived.compare_to;
}
}

stage( patch );
}

if ( nextPresetId ) {
stage( { preset: nextPresetId } );
}
},
[ stage, effective ]
);

const comparisonPresetId = useMemo(
() => effective.compare_preset ?? undefined,
[ effective.compare_preset ]
);

/**
* Comparison changes commit immediately — but only when the primary date
* isn't mid-edit. If a primary edit is staged but not yet applied, the
* comparison change rides along and commits together on Apply, so tweaking
* the comparison never commits an un-applied primary draft.
*/
const onComparisonChange = useCallback(
( nextComparisonRange: DateRange | undefined, nextComparisonPresetId?: ComparisonPresetId ) => {
stage( {
compare_from: encodeDateToSearchParam( nextComparisonRange?.from ),
compare_to: encodeDateToSearchParam( nextComparisonRange?.to ),
compare_preset: nextComparisonPresetId ?? undefined,
comp: nextComparisonRange ? '1' : undefined,
} );

const hasPrimaryDraft =
effective.from !== committed.from ||
effective.to !== committed.to ||
effective.preset !== committed.preset;

if ( ! hasPrimaryDraft ) {
commit();
}
},
[ stage, commit, effective, committed ]
);

const onApply = useCallback( () => commit(), [ commit ] );
const onCancel = useCallback( () => revert(), [ revert ] );

/*
* Read the site timezone reactively. A fully-specified deep link skips the
* seed's `ensureCoreSettingsReady()` await, so core `site` settings may not
* be loaded on first paint; subscribing here re-renders with the real site
* timezone once they resolve, instead of sticking with the browser fallback.
*/
const timeZone = useSelect( select => {
void (
select( coreStore ) as unknown as {
getEntityRecord: ( kind: string, name: string ) => unknown;
}
).getEntityRecord( 'root', 'site' );
return getSiteTimezone();
}, [] );

return {
presetId,
range,
appliedPresetId,
appliedRange,
comparisonPresetId,
onChange,
onComparisonChange,
onApply,
onCancel,
canApply: isDirty,
timeZone,
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,5 @@ export {
} from './search/date-range';

export { deriveComparisonRange } from './search/comparison';
export { useStagedSearch } from './hooks';
export { REPORT_DATE_PARAM_KEYS, pickReportDateParams } from './search/report-params';
export { useStagedSearch, useReportDateFilters, type ReportDateFilters } from './hooks';
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { REPORT_DATE_PARAM_KEYS, pickReportDateParams } from './report-params';
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
/**
* The URL search params that describe the shared report window (date range,
* interval, and comparison) — the state every analytics surface has in common.
*
* Page-owned scope params such as `post_id` and `section` are deliberately
* excluded, so this set is safe to carry between routes without leaking one
* page's scope onto another.
*/
export const REPORT_DATE_PARAM_KEYS = [
'from',
'to',
'interval',
'preset',
'period',
'date_type',
'compare_from',
'compare_to',
'compare_preset',
'comp',
] as const;

/**
* Pick only the shared report-window params from a URL search object.
*
* Used when navigating between analytics routes (e.g. a detail page back to the
* dashboard) to carry the date range and comparison through without also
* carrying page-scoped params like `post_id` or `section`.
*
* @param search - The current route search params.
* @return A new object with only the shared report-window params that are set.
*/
export function pickReportDateParams(
search: Record< string, unknown > | undefined
): Record< string, unknown > {
if ( ! search ) {
return {};
}

const picked: Record< string, unknown > = {};
for ( const key of REPORT_DATE_PARAM_KEYS ) {
if ( search[ key ] !== undefined ) {
picked[ key ] = search[ key ];
}
}
return picked;
}
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ export function MyWidget() {
| -------------- | -------------------------------------- | --------------------------------------------------------------- |
| `attributes` | `Partial<ReportParamsFieldAttributes>` | Widget attributes, may include `reportParams` |
| `children` | `ReactNode` | Child components (widgets) |
| `options.from` | `string` | Router path for URL params (default: `/`) |
| `options.from` | `string` | Deprecated/ignored — params are always read from the current matched route |

### useWidgetRootContext

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,28 +42,31 @@ type WidgetRootProps = {
*/
options?: {
/**
* The source of the search params.
* @default '/'
* Deprecated. Report params are now always read from the current matched
* route, so this no longer affects resolution. Retained for backward
* compatibility with widgets that still pass it.
*/
from?: string;
};
};

const DEFAULT_SEARCH_FROM = '/';

/**
* Hook that resolves widget attributes:
* - `reportParams`: with URL search params when it's not provided
*/
function useResolveReportParams(
attributes?: Partial< ReportParamsFieldAttributes >,
from?: string
) {
function useResolveReportParams( attributes?: Partial< ReportParamsFieldAttributes > ) {
let search: Record< string, unknown > = {};

/*
* Read the search params of the current route. `{ strict: false }` returns
* whatever route is matched, so widgets pick up the date range (and the
* single-resource scope like `post_id`) on any page — not only the dashboard
* at `/`. `useSearch` throws when rendered outside a matched route (e.g.
* Storybook), so the empty fallback stands in there.
*/
try {
// eslint-disable-next-line react-hooks/rules-of-hooks -- useSearch may throw outside a matched route
search = useSearch( { from: from ?? DEFAULT_SEARCH_FROM } );
search = useSearch( { strict: false } );
} catch {
// Do nothing
}
Expand Down Expand Up @@ -103,9 +106,9 @@ function useResolveReportParams(
* }
* ```
*/
export function WidgetRoot( { attributes, children, setError, options }: WidgetRootProps ) {
export function WidgetRoot( { attributes, children, setError }: WidgetRootProps ) {
const chartTheme = useChartTheme();
const rawReportParams = useResolveReportParams( attributes, options?.from );
const rawReportParams = useResolveReportParams( attributes );

const { launchedDate } = getStoreInfo();
const defaultPreset = getDefaultPreset( launchedDate );
Expand Down
Loading