Skip to content
Open
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
152 changes: 121 additions & 31 deletions wp-content/plugins/wporg-learn/inc/activity-kit-rest.php
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,8 @@ function handle_stats( $request ) {
add_filter( 'jetpack_fetch_stats_cache_expiration', __NAMESPACE__ . '\stats_cache_expiration' );

if ( 'both' === $metric || 'views' === $metric ) {
$views_map = get_jetpack_post_views( $range );
$kit_ids = wp_list_pluck( $kits, 'ID' );
$views_map = get_jetpack_post_views( $range, $kit_ids );
}
if ( 'both' === $metric || 'downloads' === $metric ) {
$downloads_map = get_jetpack_download_clicks( $range, $zip_url_map );
Expand Down Expand Up @@ -155,47 +156,136 @@ function handle_stats( $request ) {
/**
* Get per-post view counts from Jetpack Stats for a given time range.
*
* @param string $range One of '7d', '30d', '90d', 'all'.
* @return array Map of post_id (int) => view_count (int). Empty on failure.
* Uses get_total_post_views() rather than get_top_posts() so that views are
* fetched by post ID directly. get_top_posts() only returns the site-wide top N
* posts ranked by all-time views, which means newly published activity kits
* never appear — they're outranked by years of established content.
*
* API constraints (Jetpack / WPCOM /stats/views/posts endpoint):
* - `period` is silently discarded; only daily granularity is returned.
* - `num` is capped at 30 days per call (422 if larger).
* - `post_ids` accepts at most 100 IDs per call.
*
* To cover ranges longer than 30 days, multiple 30-day windows are issued with
* a date offset and the results are summed. Kit IDs are chunked into groups of
* 100 so the library can grow past 100 kits without silently losing data.
* '90d' uses 3 windows (90 days); 'all' uses 6 windows (≈ 180 days) to give a
* meaningful distinction from the 90-day range.
*
* @param string $range One of '7d', '30d', '90d', 'all'.
* @param int[] $kit_ids Post IDs of the activity kits to fetch views for.
* @return array Map of post_id (int) => view_count (int). Empty on failure.
*/
function get_jetpack_post_views( $range ) {
if ( ! class_exists( '\Automattic\Jetpack\Stats\WPCOM_Stats' ) ) {
function get_jetpack_post_views( $range, array $kit_ids ) {
if ( ! class_exists( '\Automattic\Jetpack\Stats\WPCOM_Stats' ) || empty( $kit_ids ) ) {
return array();
}

$stats = new \Automattic\Jetpack\Stats\WPCOM_Stats();

if ( 'all' === $range ) {
$period = 'month';
$num = 36;
} else {
$period = 'day';
$num = intval( str_replace( 'd', '', $range ) );
/*
* Map the UI range to one or more 30-day windows. Each window is defined by
* how many days back its end-date is offset from today. 'all' uses 6 windows
* (≈ 6 months) rather than 3, giving a meaningful distinction from '90d'.
* Extending further would multiply sequential API calls proportionally; 6 is
* a reasonable ceiling for an admin-only dashboard with a small post count.
*/
switch ( $range ) {
case '7d':
$windows = array(
array(
'num' => 7,
'offset' => 0,
),
);
break;
case '30d':
$windows = array(
array(
'num' => 30,
'offset' => 0,
),
);
break;
case '90d':
$windows = array(
array(
'num' => 30,
'offset' => 0,
),
array(
'num' => 30,
'offset' => 30,
),
array(
'num' => 30,
'offset' => 60,
),
);
break;
case 'all':
default:
$windows = array(
array(
'num' => 30,
'offset' => 0,
),
array(
'num' => 30,
'offset' => 30,
),
array(
'num' => 30,
'offset' => 60,
),
array(
'num' => 30,
'offset' => 90,
),
array(
'num' => 30,
'offset' => 120,
),
array(
'num' => 30,
'offset' => 150,
),
);
break;
}

$result = $stats->get_top_posts(
array(
'period' => $period,
'num' => $num,
'date' => gmdate( 'Y-m-d' ),
'summarize' => true,
'max' => 1000,
)
);
$chunks = array_chunk( $kit_ids, 100 );
$map = array();

if ( is_wp_error( $result ) || ! is_array( $result ) ) {
return array();
}
foreach ( $chunks as $chunk ) {
$post_ids_str = implode( ',', array_map( 'absint', $chunk ) );

$post_views = isset( $result['summary']['postviews'] ) ? $result['summary']['postviews'] : array();
if ( ! is_array( $post_views ) ) {
return array();
}
foreach ( $windows as $window ) {
$date = gmdate( 'Y-m-d', time() - $window['offset'] * DAY_IN_SECONDS );
$result = $stats->get_total_post_views(
array(
'post_ids' => $post_ids_str,
'num' => $window['num'],
'date' => $date,
)
);

$map = array();
foreach ( $post_views as $post_data ) {
if ( isset( $post_data['id'], $post_data['views'] ) ) {
$map[ (int) $post_data['id'] ] = (int) $post_data['views'];
if ( is_wp_error( $result ) || ! is_array( $result ) ) {
continue;
}

$post_views = isset( $result['posts'] ) ? $result['posts'] : array();
if ( ! is_array( $post_views ) ) {
continue;
}

foreach ( $post_views as $post_data ) {
// The views/posts API uses uppercase 'ID' (unlike top-posts which uses 'id').
if ( isset( $post_data['ID'], $post_data['views'] ) ) {
$id = (int) $post_data['ID'];
$map[ $id ] = ( isset( $map[ $id ] ) ? $map[ $id ] : 0 ) + (int) $post_data['views'];
}
}
}
}

Expand Down
41 changes: 34 additions & 7 deletions wp-content/plugins/wporg-learn/js/activity-kit-stats/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -71,9 +71,12 @@ if ( filterKit && tableBody && chartCanvas ) {
'7d': 'Last 7 days',
'30d': 'Last 30 days',
'90d': 'Last 90 days',
all: 'All time',
// 'All time' covers ~6 months (6 × 30-day windows) — the WPCOM
// /stats/views/posts API caps each call at 30 days; see
// get_jetpack_post_views() in activity-kit-rest.php.
all: 'All time (max ~6 months)',
};
return labels[ activeRange ] || 'All time';
return labels[ activeRange ] || 'All time (max ~6 months)';
}

const metricLabel = {
Expand Down Expand Up @@ -118,7 +121,9 @@ if ( filterKit && tableBody && chartCanvas ) {
const isSingle = !! activeKit;
const totalV = data.reduce( ( sum, row ) => sum + ( row.views ?? 0 ), 0 );
const totalD = data.reduce( ( sum, row ) => sum + ( row.downloads ?? 0 ), 0 );
const rate = totalV > 0 ? ( ( totalD / totalV ) * 100 ).toFixed( 1 ) + '%' : '—';
// Downloads are an all-time cumulative counter; rate is only meaningful
// when views cover the same all-time window.
const rate = activeRange === 'all' && totalV > 0 ? ( ( totalD / totalV ) * 100 ).toFixed( 1 ) + '%' : '—';

if ( summaryViews ) {
summaryViews.textContent = fmt( totalV );
Expand All @@ -142,8 +147,9 @@ if ( filterKit && tableBody && chartCanvas ) {
if ( boxDownloads ) {
boxDownloads.style.display = activeMetric === 'views' ? 'none' : '';
}
// Rate is only valid when views cover the same window as the all-time download counter.
if ( boxRate ) {
boxRate.style.display = isSingle ? '' : 'none';
boxRate.style.display = isSingle && activeRange === 'all' ? '' : 'none';
}
}

Expand Down Expand Up @@ -294,7 +300,8 @@ if ( filterKit && tableBody && chartCanvas ) {
sorted.forEach( ( row ) => {
const views = row.views ?? 0;
const downloads = row.downloads ?? 0;
const rate = views > 0 ? ( ( downloads / views ) * 100 ).toFixed( 1 ) + '%' : '—';
// Suppress rate when views are range-scoped but downloads are all-time.
const rate = activeRange === 'all' && views > 0 ? ( ( downloads / views ) * 100 ).toFixed( 1 ) + '%' : '—';
const isSelected = row.slug === activeKit;
const tableRow = document.createElement( 'tr' );
if ( isSelected ) {
Expand Down Expand Up @@ -380,6 +387,22 @@ if ( filterKit && tableBody && chartCanvas ) {
}
if ( thDownloads ) {
thDownloads.classList.toggle( 'ak-hidden-col', activeMetric === 'views' );
// Label the column so admins know downloads are always all-time when a
// range-scoped view window is selected.
const arrow = thDownloads.querySelector( '.ak-sort-arrow' );
thDownloads.textContent = activeRange === 'all' ? 'Downloads' : 'Downloads (all time)';
if ( arrow ) {
thDownloads.appendChild( arrow );
}
}
// Mirror the same label on the summary box.
if ( summaryDownloads ) {
const dlLabel = summaryDownloads.closest( '.ak-summary-box' )
? summaryDownloads.closest( '.ak-summary-box' ).querySelector( '.ak-stat-label' )
: null;
if ( dlLabel ) {
dlLabel.textContent = activeRange === 'all' ? 'Total Downloads' : 'Total Downloads (all time)';
}
}

if ( backLinkBar ) {
Expand Down Expand Up @@ -467,11 +490,15 @@ if ( filterKit && tableBody && chartCanvas ) {

function exportCSV() {
const data = activeKit ? allData.filter( ( row ) => row.slug === activeKit ) : allData;
const rows = [ [ 'Kit Name', 'Views', 'Downloads', 'Download Rate', 'Last Updated' ] ];
// Downloads column header clarifies scope when views are range-filtered.
const dlHeader = activeRange === 'all' ? 'Downloads' : 'Downloads (all time)';
const rows = [ [ 'Kit Name', 'Views', dlHeader, 'Download Rate', 'Last Updated' ] ];
data.forEach( ( row ) => {
const views = row.views ?? 0;
const downloads = row.downloads ?? 0;
const rate = views > 0 ? ( ( downloads / views ) * 100 ).toFixed( 1 ) + '%' : '0%';
// Rate is only meaningful when views and downloads cover the same window.
const rate =
activeRange === 'all' && views > 0 ? ( ( downloads / views ) * 100 ).toFixed( 1 ) + '%' : 'N/A';
rows.push( [ row.title, views, downloads, rate, row.updated || '' ] );
} );
const csv = rows.map( ( row ) => row.map( csvCell ).join( ',' ) ).join( '\n' );
Expand Down
Loading