From 735d0cae536fcf316a393d39b6a652a754225a3b Mon Sep 17 00:00:00 2001 From: Dognose Date: Fri, 10 Jul 2026 09:20:56 +0800 Subject: [PATCH 01/30] Stats: split video details into a dedicated page modeled on post details Move the videodetails module out of the summary switch into its own stats-video-detail page component, keeping the existing URL. Adds a video details header card (title, published date, thumbnail linking to the media library) and the shared stats breadcrumb trail. Part of STATS-299. Co-Authored-By: Claude Fable 5 --- client/my-sites/stats/controller.jsx | 22 +++- .../stats/stats-video-detail/index.tsx | 78 +++++++++++ .../stats/stats-video-detail/style.scss | 122 ++++++++++++++++++ .../stats-video-detail/video-details-card.tsx | 79 ++++++++++++ client/my-sites/stats/summary/index.jsx | 50 +------ 5 files changed, 299 insertions(+), 52 deletions(-) create mode 100644 client/my-sites/stats/stats-video-detail/index.tsx create mode 100644 client/my-sites/stats/stats-video-detail/style.scss create mode 100644 client/my-sites/stats/stats-video-detail/video-details-card.tsx diff --git a/client/my-sites/stats/controller.jsx b/client/my-sites/stats/controller.jsx index dddf717218e8..7a6acb8527ef 100644 --- a/client/my-sites/stats/controller.jsx +++ b/client/my-sites/stats/controller.jsx @@ -26,6 +26,10 @@ const loadStatsPostDetail = () => import( /* webpackChunkName: "async-load-calypso-my-sites-stats-stats-post-detail" */ './stats-post-detail' ); +const loadStatsVideoDetail = () => + import( + /* webpackChunkName: "async-load-calypso-my-sites-stats-stats-video-detail" */ './stats-video-detail' + ); const loadCommentFollows = () => import( /* webpackChunkName: "async-load-calypso-my-sites-stats-comment-follows" */ './comment-follows' @@ -309,8 +313,21 @@ export function summary( context, next ) { const isValidRange = isValidEndDate && ! endDate.isBefore( date ); const dateRange = isValidRange ? { startDate: date, endDate: endDate } : null; - const extraProps = - context.params.module === 'videodetails' ? { postId: parseInt( queryOptions.post, 10 ) } : {}; + // Video details has its own dedicated page, modeled on the single post page. + if ( context.params.module === 'videodetails' ) { + context.primary = ( + + + + ); + return next(); + } // The option is used for stats queries only. const statsQueryOptions = pick( queryOptions, [ 'num', 'summarize', 'geoMode', 'viewType' ] ); @@ -330,7 +347,6 @@ export function summary( context, next ) { dateRange={ dateRange } context={ context } period={ period } - { ...extraProps } /> ); diff --git a/client/my-sites/stats/stats-video-detail/index.tsx b/client/my-sites/stats/stats-video-detail/index.tsx new file mode 100644 index 000000000000..edda636be0d1 --- /dev/null +++ b/client/my-sites/stats/stats-video-detail/index.tsx @@ -0,0 +1,78 @@ +import { useTranslate } from 'i18n-calypso'; +import { useEffect } from 'react'; +import titlecase from 'to-title-case'; +import QueryMedia from 'calypso/components/data/query-media'; +import Main from 'calypso/my-sites/stats/components/stats-main'; +import { + useStatsBreadcrumbTrail, + recordCurrentScreen, +} from 'calypso/my-sites/stats/hooks/use-stats-navigation-history'; +import { useSelector } from 'calypso/state'; +import getMediaItem from 'calypso/state/selectors/get-media-item'; +import { getSelectedSiteId } from 'calypso/state/ui/selectors'; +import PageViewTracker from '../stats-page-view-tracker'; +import VideoPlayDetails from '../stats-video-details'; +import StatsVideoSummary from '../stats-video-summary'; +import VideoDetailsCard, { VideoMediaItem } from './video-details-card'; + +interface StatsVideoDetailProps { + postId: number; + period: { + period: string; + }; + context: { + query: Record< string, string >; + }; +} + +export default function StatsVideoDetail( { postId, period, context }: StatsVideoDetailProps ) { + const translate = useTranslate(); + const siteId = useSelector( getSelectedSiteId ); + const media = useSelector( + ( state ) => getMediaItem( state, siteId, postId ) as VideoMediaItem | null + ); + const breadcrumbTrail = useStatsBreadcrumbTrail(); + const statType = context.query.statType ?? null; + + useEffect( () => { + window.scrollTo( 0, 0 ); + }, [] ); + + useEffect( () => { + recordCurrentScreen( 'videodetails', { + queryParams: context.query, + period: period.period, + } ); + }, [ context.query, period.period ] ); + + const title = media?.title || translate( 'Video', { textOnly: true } ); + + return ( +
( { + label: item.label, + to: item.url ?? undefined, + } ) ), + { label: title }, + ] } + > + ${ titlecase( period.period ) } > Videodetails` } + /> + { siteId && } +
+
+ + + +
+
+
+ ); +} diff --git a/client/my-sites/stats/stats-video-detail/style.scss b/client/my-sites/stats/stats-video-detail/style.scss new file mode 100644 index 000000000000..75167f277293 --- /dev/null +++ b/client/my-sites/stats/stats-video-detail/style.scss @@ -0,0 +1,122 @@ +@import "@wordpress/base-styles/breakpoints"; +@import "@automattic/typography/styles/fonts"; +@import "@automattic/components/src/styles/typography"; +@import "@automattic/components/src/styles/mixins"; + +$card-padding: 24px; +$border-radius: 5px; // stylelint-disable-line scales/radii + +@keyframes stats-video-details-card-shine { + to { + background-position-x: -200%; + } +} + +.stats-video-detail { + display: flex; + flex-direction: column; + gap: 24px; +} + +.stats-video-details-card { + border-color: var(--studio-gray-5); + border-radius: $border-radius; + display: grid; + font-family: $font-sf-pro-text; + font-size: $font-body-small; + grid-template-columns: minmax(10px, 1.5fr) minmax(0, auto); + grid-template-rows: 26px auto; + grid-template-areas: + "heading thumbnail" + "info thumbnail"; + margin-bottom: 0; + max-width: 100%; + max-height: 255px; + padding: $card-padding; + gap: $card-padding; + + &.card::after { + display: none; + } + + &.is-loading { + .stats-video-details-card__heading, + .stats-video-details-card__info { + position: relative; + + &::after { + content: ""; + position: absolute; + inset: 0; + background: linear-gradient(94deg, var(--studio-gray-5) 8%, var(--studio-gray-0) 18%, var(--studio-gray-5) 33%); + border-radius: 4px; + background-size: 200% 100%; + animation: 1.5s stats-video-details-card-shine linear infinite; + } + } + } +} + +.stats-video-details-card__heading { + color: var(--studio-gray-100); + font-family: $font-sf-pro-display; + font-size: $font-size-header-small; + font-weight: 500; + grid-area: heading; + line-height: 1.3; +} + +.stats-video-details-card__info { + grid-area: info; + + .stats-video-details-card__title { + @include stats-section-header; + display: block; + margin-bottom: 4px; + min-height: 40px; + } + + .stats-video-details-card__date { + color: var(--studio-gray-60); + font-weight: 400; + line-height: 1.5; + } +} + +.stats-video-details-card__thumbnail-link { + grid-area: thumbnail; + display: block; + height: calc(100% + #{$card-padding * 2}); + margin: -1 * $card-padding -1 * $card-padding -1 * $card-padding 0; + max-width: 300px; + width: calc(100% + #{$card-padding}); +} + +.stats-video-details-card__thumbnail { + border-radius: 0 $border-radius $border-radius 0; + display: block; + height: 100%; + object-fit: cover; + width: 100%; +} + +@media (max-width: $break-small) { + .stats-video-details-card { + gap: 12px 0; + grid-template-areas: + "heading heading" + "info thumbnail"; + max-height: initial; + } + + .stats-video-details-card__thumbnail-link { + height: 84px; + margin: 0; + width: 84px; + max-width: initial; + } + + .stats-video-details-card__thumbnail { + border-radius: $border-radius; + } +} diff --git a/client/my-sites/stats/stats-video-detail/video-details-card.tsx b/client/my-sites/stats/stats-video-detail/video-details-card.tsx new file mode 100644 index 000000000000..acb21f5c420a --- /dev/null +++ b/client/my-sites/stats/stats-video-detail/video-details-card.tsx @@ -0,0 +1,79 @@ +import config from '@automattic/calypso-config'; +import { Card } from '@automattic/components'; +import { useTranslate } from 'i18n-calypso'; +import { useLocalizedMoment } from 'calypso/components/localized-moment'; +import { useSelector } from 'calypso/state'; +import { getSiteSlug } from 'calypso/state/sites/selectors'; +import getSiteAdminUrl from 'calypso/state/sites/selectors/get-site-admin-url'; +import { getSelectedSiteId } from 'calypso/state/ui/selectors'; + +import './style.scss'; + +export interface VideoMediaItem { + ID: number; + title?: string; + date?: string; + thumbnails?: Record< string, string >; +} + +const THUMBNAIL_SIZES = [ 'fmt_hd', 'fmt_dvd', 'fmt_std' ]; + +export default function VideoDetailsCard( { + media, + mediaId, +}: { + media: VideoMediaItem | null; + mediaId: number; +} ) { + const translate = useTranslate(); + const moment = useLocalizedMoment(); + const siteId = useSelector( getSelectedSiteId ); + const siteSlug = useSelector( ( state ) => getSiteSlug( state, siteId ) ); + const adminBaseUrl = useSelector( ( state ) => getSiteAdminUrl( state, siteId ) ); + + const isLoading = ! media; + const isOdyssey = config.isEnabled( 'is_odyssey' ); + const mediaLibraryUrl = isOdyssey + ? adminBaseUrl && `${ adminBaseUrl }upload.php?item=${ mediaId }` + : siteSlug && `/media/${ siteSlug }/${ mediaId }`; + + const thumbnailSize = THUMBNAIL_SIZES.find( ( size ) => media?.thumbnails?.[ size ] ); + const thumbnailUrl = thumbnailSize && media?.thumbnails?.[ thumbnailSize ]; + + const classes = isLoading ? 'stats-video-details-card is-loading' : 'stats-video-details-card'; + + return ( + +

{ translate( 'Video details' ) }

+
+
{ media?.title }
+ { ( isLoading || media?.date ) && ( +
+ { media?.date && + translate( 'Published %(date)s', { + args: { date: moment( media.date ).format( 'll' ) }, + comment: 'Date when the video was uploaded.', + } ) } +
+ ) } +
+ { thumbnailUrl && ( + + { + + ) } +
+ ); +} diff --git a/client/my-sites/stats/summary/index.jsx b/client/my-sites/stats/summary/index.jsx index 3108092b4ca2..1c8bed5bbbdd 100644 --- a/client/my-sites/stats/summary/index.jsx +++ b/client/my-sites/stats/summary/index.jsx @@ -4,7 +4,6 @@ import { localize } from 'i18n-calypso'; import { Component, Fragment } from 'react'; import { connect } from 'react-redux'; import titlecase from 'to-title-case'; -import QueryMedia from 'calypso/components/data/query-media'; import AnnualSiteStats from 'calypso/my-sites/stats/annual-site-stats'; import Main from 'calypso/my-sites/stats/components/stats-main'; import StatsModuleAuthors from 'calypso/my-sites/stats/features/modules/stats-authors'; @@ -18,7 +17,6 @@ import { useStatsBreadcrumbTrail, recordCurrentScreen, } from 'calypso/my-sites/stats/hooks/use-stats-navigation-history'; -import getMediaItem from 'calypso/state/selectors/get-media-item'; import getEnvStatsFeatureSupportChecks from 'calypso/state/sites/selectors/get-env-stats-feature-supports'; import { getSelectedSiteId, getSelectedSiteSlug } from 'calypso/state/ui/selectors'; import { STATS_FEATURE_DOWNLOAD_CSV } from '../constants'; @@ -39,8 +37,6 @@ import DownloadCsv from '../stats-download-csv'; import DownloadCsvUpsell from '../stats-download-csv-upsell'; import AllTimeNav from '../stats-module/all-time-nav'; import PageViewTracker from '../stats-page-view-tracker'; -import VideoPlayDetails from '../stats-video-details'; -import StatsVideoSummary from '../stats-video-summary'; import VideoPressStatsModule from '../videopress-stats-module'; import './style.scss'; @@ -136,8 +132,6 @@ class StatsSummary extends Component { const summaryViews = []; let title; let summaryView; - let chartTitle; - let barChart; let path; let statType; @@ -337,47 +331,6 @@ class StatsSummary extends Component { ); break; - case 'videodetails': - title = translate( 'Video' ); - if ( this.props.media ) { - title = this.props.media.title; - } - - // TODO: a separate StatsSectionTitle component should be created - /* eslint-disable wpcalypso/jsx-classname-namespace */ - chartTitle = ( -

- { translate( 'Video Details' ) } -

- ); - /* eslint-enable wpcalypso/jsx-classname-namespace */ - - if ( siteId ) { - summaryViews.push( - - ); - } - summaryViews.push( chartTitle ); - barChart = ( - - ); - - summaryViews.push( barChart ); - summaryView = ( - - ); - break; - case 'searchterms': title = translate( 'Search Terms' ); path = 'searchterms'; @@ -511,7 +464,7 @@ const StatsSummaryWrapper = ( props ) => { ); }; -export default connect( ( state, { context, postId } ) => { +export default connect( ( state ) => { const siteId = getSelectedSiteId( state ); const { supportsUTMStats, supportsArchiveStats } = getEnvStatsFeatureSupportChecks( @@ -522,7 +475,6 @@ export default connect( ( state, { context, postId } ) => { return { siteId: getSelectedSiteId( state ), siteSlug: getSelectedSiteSlug( state, siteId ), - media: context.params.module === 'videodetails' ? getMediaItem( state, siteId, postId ) : false, supportsUTMStats, supportsArchiveStats, shouldGateStatsCsvDownload: shouldGateStats( state, siteId, STATS_FEATURE_DOWNLOAD_CSV ), From 3cbcffb62dac3f1eb256e7ed100222c6860186ee Mon Sep 17 00:00:00 2001 From: Dognose Date: Fri, 10 Jul 2026 09:30:57 +0800 Subject: [PATCH 02/30] Stats: add period navigation to the video details chart Replace the old single-metric StatsVideoSummary with a VideoSummary section modeled on stats-post-summary: a date label with previous/next arrows and a Days/Weeks/Months/Years segmented control driving the statsVideo query. Daily data is paged 30 bars at a time like the post chart. Removes the now-unused stats-video-summary component. Part of STATS-299. Co-Authored-By: Claude Fable 5 --- .../stats/stats-video-detail/index.tsx | 4 +- .../stats-video-detail/video-summary.tsx | 219 ++++++++++++++++++ .../stats/stats-video-summary/index.jsx | 93 -------- 3 files changed, 221 insertions(+), 95 deletions(-) create mode 100644 client/my-sites/stats/stats-video-detail/video-summary.tsx delete mode 100644 client/my-sites/stats/stats-video-summary/index.jsx diff --git a/client/my-sites/stats/stats-video-detail/index.tsx b/client/my-sites/stats/stats-video-detail/index.tsx index edda636be0d1..3f90d53e2c0c 100644 --- a/client/my-sites/stats/stats-video-detail/index.tsx +++ b/client/my-sites/stats/stats-video-detail/index.tsx @@ -12,8 +12,8 @@ import getMediaItem from 'calypso/state/selectors/get-media-item'; import { getSelectedSiteId } from 'calypso/state/ui/selectors'; import PageViewTracker from '../stats-page-view-tracker'; import VideoPlayDetails from '../stats-video-details'; -import StatsVideoSummary from '../stats-video-summary'; import VideoDetailsCard, { VideoMediaItem } from './video-details-card'; +import VideoSummary from './video-summary'; interface StatsVideoDetailProps { postId: number; @@ -69,7 +69,7 @@ export default function StatsVideoDetail( { postId, period, context }: StatsVide className="stats-summary-view stats-summary__positioned stats-video-detail" > - + diff --git a/client/my-sites/stats/stats-video-detail/video-summary.tsx b/client/my-sites/stats/stats-video-detail/video-summary.tsx new file mode 100644 index 000000000000..2166c1daabc8 --- /dev/null +++ b/client/my-sites/stats/stats-video-detail/video-summary.tsx @@ -0,0 +1,219 @@ +import { SegmentedControl } from '@automattic/components'; +import clsx from 'clsx'; +import { useTranslate } from 'i18n-calypso'; +import { useMemo, useState } from 'react'; +import QuerySiteStats from 'calypso/components/data/query-site-stats'; +import { useLocalizedMoment } from 'calypso/components/localized-moment'; +import { useSelector } from 'calypso/state'; +import { + getSiteStatsNormalizedData, + isRequestingSiteStatsForQuery, +} from 'calypso/state/stats/lists/selectors'; +import { getSelectedSiteId } from 'calypso/state/ui/selectors'; +import DatePicker from '../stats-date-label'; +import StatsPeriodHeader from '../stats-period-header'; +import StatsPeriodNavigation from '../stats-period-navigation'; +import SummaryChart from '../stats-summary'; + +type Period = 'day' | 'week' | 'month' | 'year'; + +interface ChartRecord { + period: string; + periodLabel: string; + startDate: string; + value: number; +} + +interface VideoSummaryData { + data?: Array< { period: string; value: number } >; +} + +const MAX_RECORDS_PER_DAY = 30; + +export default function VideoSummary( { + postId, + statType, +}: { + postId: number; + statType: string | null; +} ) { + const translate = useTranslate(); + const moment = useLocalizedMoment(); + const siteId = useSelector( getSelectedSiteId ); + const [ period, setPeriod ] = useState< Period >( 'day' ); + const [ selectedRecord, setSelectedRecord ] = useState< ChartRecord | null >( null ); + const [ page, setPage ] = useState( 1 ); + + const query = useMemo( () => ( { postId, statType, period } ), [ postId, statType, period ] ); + const summaryData = useSelector( + ( state ) => + getSiteStatsNormalizedData( state, siteId, 'statsVideo', query ) as VideoSummaryData | null + ); + const isRequesting = useSelector( ( state ) => + siteId ? isRequestingSiteStatsForQuery( state, siteId, 'statsVideo', query ) : false + ); + + const allRecords: ChartRecord[] = useMemo( + () => + ( summaryData?.data ?? [] ).map( ( { period: date, value } ) => { + const start = moment( date ); + switch ( period ) { + case 'week': + return { + period: start.format( 'MMM D' ), + periodLabel: `${ start.format( 'L' ) } - ${ moment( date ) + .add( 6, 'days' ) + .format( 'L' ) }`, + startDate: date, + value, + }; + case 'month': + return { + period: start.format( 'MMM YYYY' ), + periodLabel: start.format( 'MMMM YYYY' ), + startDate: date, + value, + }; + case 'year': + return { + period: start.format( 'YYYY' ), + periodLabel: start.format( 'YYYY' ), + startDate: date, + value, + }; + default: + return { + period: start.format( 'MMM D' ), + periodLabel: start.format( 'LL' ), + startDate: date, + value, + }; + } + } ), + [ summaryData, period, moment ] + ); + + const getPageRecords = ( pageNum: number ) => { + if ( period !== 'day' ) { + return allRecords; + } + const start = Math.max( allRecords.length - MAX_RECORDS_PER_DAY * pageNum, 0 ); + const end = Math.max( allRecords.length - MAX_RECORDS_PER_DAY * ( pageNum - 1 ), 0 ); + return allRecords.slice( start, end ); + }; + + const chartData = getPageRecords( page ); + const selected = + selectedRecord ?? ( chartData.length ? chartData[ chartData.length - 1 ] : null ); + + const selectPeriod = ( newPeriod: Period ) => () => { + setPeriod( newPeriod ); + setSelectedRecord( null ); + setPage( 1 ); + }; + + const handleArrows = ( { direction }: { direction: string } ) => { + if ( ! chartData.length || ! selected ) { + return; + } + + const recordIndex = chartData.findIndex( ( record ) => record.period === selected.period ); + + if ( direction === 'previous' ) { + if ( recordIndex > 0 ) { + setSelectedRecord( chartData[ recordIndex - 1 ] ); + } else { + const previousPage = getPageRecords( page + 1 ); + if ( previousPage.length ) { + setPage( page + 1 ); + setSelectedRecord( previousPage[ previousPage.length - 1 ] ); + } + } + } else if ( direction === 'next' ) { + if ( recordIndex < chartData.length - 1 ) { + setSelectedRecord( chartData[ recordIndex + 1 ] ); + } else if ( page > 1 ) { + const nextPage = getPageRecords( page - 1 ); + setPage( page - 1 ); + setSelectedRecord( nextPage[ 0 ] ); + } + } + }; + + const selectedIndex = selected + ? chartData.findIndex( ( record ) => record.period === selected.period ) + : -1; + let disablePreviousArrow = false; + let disableNextArrow = false; + if ( period === 'day' && allRecords.length ) { + const maxPages = Math.ceil( allRecords.length / MAX_RECORDS_PER_DAY ); + disablePreviousArrow = page >= maxPages && selectedIndex === 0; + disableNextArrow = page === 1 && selectedIndex === chartData.length - 1; + } else { + disablePreviousArrow = selectedIndex <= 0; + disableNextArrow = selectedIndex === chartData.length - 1; + } + + let tabLabel = translate( 'Views' ); + if ( statType === 'impressions' ) { + tabLabel = translate( 'Impressions' ); + } else if ( statType === 'watch_time' ) { + tabLabel = translate( 'Hours Watched' ); + } else if ( statType === 'retention_rate' ) { + tabLabel = translate( 'Retention Rate' ); + } + + const periods: Array< { id: Period; label: string } > = [ + { id: 'day', label: translate( 'Days', { textOnly: true } ) }, + { id: 'week', label: translate( 'Weeks', { textOnly: true } ) }, + { id: 'month', label: translate( 'Months', { textOnly: true } ) }, + { id: 'year', label: translate( 'Years', { textOnly: true } ) }, + ]; + + return ( +
+ { siteId && } + + + + + + + { periods.map( ( { id, label } ) => ( + + { label } + + ) ) } + + + + +
+ ); +} diff --git a/client/my-sites/stats/stats-video-summary/index.jsx b/client/my-sites/stats/stats-video-summary/index.jsx deleted file mode 100644 index 31a0caa78275..000000000000 --- a/client/my-sites/stats/stats-video-summary/index.jsx +++ /dev/null @@ -1,93 +0,0 @@ -import { localize } from 'i18n-calypso'; -import PropTypes from 'prop-types'; -import { Component } from 'react'; -import { connect } from 'react-redux'; -import { compose } from 'redux'; -import QuerySiteStats from 'calypso/components/data/query-site-stats'; -import { withLocalizedMoment } from 'calypso/components/localized-moment'; -import { - getSiteStatsNormalizedData, - isRequestingSiteStatsForQuery, -} from 'calypso/state/stats/lists/selectors'; -import { getSelectedSiteId } from 'calypso/state/ui/selectors'; -import SummaryChart from '../stats-summary'; - -class StatsVideoSummary extends Component { - static propTypes = { - query: PropTypes.object, - isRequesting: PropTypes.bool, - siteId: PropTypes.number, - summaryData: PropTypes.object, - }; - - state = { - selectedBar: null, - }; - - selectBar = ( bar ) => { - this.setState( { - selectedBar: bar, - } ); - }; - - render() { - const { query, isRequesting, moment, siteId, summaryData, translate } = this.props; - const data = - summaryData && summaryData.data - ? summaryData.data.map( ( item ) => { - return { - ...item, - period: moment( item.period ).format( 'year' === query.period ? 'MMM' : 'MMM D' ), - }; - } ) - : []; - let selectedBar = this.state.selectedBar; - if ( ! selectedBar && !! data.length ) { - selectedBar = data[ data.length - 1 ]; - } - - let tabLabel = translate( 'Views' ); - if ( 'impressions' === query.statType ) { - tabLabel = translate( 'Impressions' ); - } - if ( 'watch_time' === query.statType ) { - tabLabel = translate( 'Hours Watched' ); - } - if ( 'retention_rate' === query.statType ) { - tabLabel = translate( 'Retention Rate' ); - } - - return ( -
- - -
- ); - } -} - -const connectComponent = connect( ( state, { postId, statType, period } ) => { - const query = { postId, statType, period }; - const siteId = getSelectedSiteId( state ); - - return { - summaryData: getSiteStatsNormalizedData( state, siteId, 'statsVideo', query ), - isRequesting: isRequestingSiteStatsForQuery( state, siteId, 'statsVideo', query ), - query, - siteId, - }; -} ); - -export default compose( connectComponent, localize, withLocalizedMoment )( StatsVideoSummary ); From 6b711dcee2d2a3f68575cea9a75ca8f646dbbe02 Mon Sep 17 00:00:00 2001 From: Dognose Date: Fri, 10 Jul 2026 09:57:57 +0800 Subject: [PATCH 03/30] Stats: add metric highlight tabs to the video details chart Fetch all four statsVideo series (views, impressions, watch time, retention rate) and render them as selectable cards below the chart, matching the post details layout from the JA designs. Card totals are computed over the dates visible in the chart; retention rate uses a views-weighted average. The chart's statType now lives in page state, seeded from the statType query param so existing links keep working. Part of STATS-299. Co-Authored-By: Claude Fable 5 --- client/my-sites/stats/stats-summary/index.jsx | 4 +- .../stats/stats-video-detail/index.tsx | 2 +- .../stats/stats-video-detail/style.scss | 65 ++++++++ .../stats-video-detail/video-metric-tabs.tsx | 86 ++++++++++ .../stats-video-detail/video-summary.tsx | 151 ++++++++++++++++-- 5 files changed, 289 insertions(+), 19 deletions(-) create mode 100644 client/my-sites/stats/stats-video-detail/video-metric-tabs.tsx diff --git a/client/my-sites/stats/stats-summary/index.jsx b/client/my-sites/stats/stats-summary/index.jsx index 44454642b8b7..8237fcde282c 100644 --- a/client/my-sites/stats/stats-summary/index.jsx +++ b/client/my-sites/stats/stats-summary/index.jsx @@ -99,8 +99,8 @@ class StatsSummaryChart extends Component { label: tabLabel + label, }; - // The StatsPostSummary has been modernized to fresh styling. - const isModernized = 'post' === type; + // The post and video summaries have been modernized to fresh styling. + const isModernized = 'post' === type || 'video' === type; return isModernized ? (
diff --git a/client/my-sites/stats/stats-video-detail/index.tsx b/client/my-sites/stats/stats-video-detail/index.tsx index 3f90d53e2c0c..b2ba63575eb9 100644 --- a/client/my-sites/stats/stats-video-detail/index.tsx +++ b/client/my-sites/stats/stats-video-detail/index.tsx @@ -69,7 +69,7 @@ export default function StatsVideoDetail( { postId, period, context }: StatsVide className="stats-summary-view stats-summary__positioned stats-video-detail" > - +
diff --git a/client/my-sites/stats/stats-video-detail/style.scss b/client/my-sites/stats/stats-video-detail/style.scss index 75167f277293..6221c21b5c1c 100644 --- a/client/my-sites/stats/stats-video-detail/style.scss +++ b/client/my-sites/stats/stats-video-detail/style.scss @@ -100,7 +100,72 @@ $border-radius: 5px; // stylelint-disable-line scales/radii width: 100%; } +.stats-video-metric-tabs { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 16px; + list-style: none; + margin: 16px 0 0; + padding: 0; +} + +.stats-video-metric-tabs__item { + margin: 0; +} + +.stats-video-metric-tabs__tab { + background: var(--color-surface); + border: 1px solid var(--studio-gray-5); + border-radius: 5px; /* stylelint-disable-line scales/radii */ + box-sizing: border-box; + cursor: pointer; + display: flex; + flex-direction: column; + font-family: $font-sf-pro-text; + gap: 12px; + padding: 16px 24px; + text-align: start; + width: 100%; + + &:hover { + border-color: var(--studio-gray-30); + } + + &.is-selected { + border-color: var(--color-primary); + box-shadow: inset 0 0 0 1px var(--color-primary); + } +} + +.stats-video-metric-tabs__header { + align-items: center; + color: var(--studio-gray-100); + display: flex; + font-size: $font-body-small; + gap: 8px; + line-height: 1.5; + + svg { + fill: currentColor; + } +} + +.stats-video-metric-tabs__value { + @include stats-section-header; + color: var(--studio-gray-100); +} + +@media (max-width: $break-medium) { + .stats-video-metric-tabs { + grid-template-columns: repeat(2, 1fr); + } +} + @media (max-width: $break-small) { + .stats-video-metric-tabs { + grid-template-columns: 1fr; + } + .stats-video-details-card { gap: 12px 0; grid-template-areas: diff --git a/client/my-sites/stats/stats-video-detail/video-metric-tabs.tsx b/client/my-sites/stats/stats-video-detail/video-metric-tabs.tsx new file mode 100644 index 000000000000..53a7908ddfbb --- /dev/null +++ b/client/my-sites/stats/stats-video-detail/video-metric-tabs.tsx @@ -0,0 +1,86 @@ +import { Gridicon } from '@automattic/components'; +import { formatNumber, formatNumberCompact } from '@automattic/number-formatters'; +import { Icon, seen, video, dashboard } from '@wordpress/icons'; +import clsx from 'clsx'; +import { useTranslate } from 'i18n-calypso'; + +export type VideoStatType = 'views' | 'impressions' | 'watch_time' | 'retention_rate'; + +export type VideoMetricValues = Record< VideoStatType, number | null >; + +function formatValue( statType: VideoStatType, value: number | null ) { + if ( value === null ) { + return '-'; + } + + switch ( statType ) { + case 'watch_time': + return value > 1 + ? formatNumber( value, { decimals: 1 } ) + : `< ${ formatNumber( 1, { decimals: 1 } ) }`; + case 'retention_rate': + return `${ formatNumber( value, { decimals: 1 } ) }%`; + default: + return formatNumberCompact( value ); + } +} + +export default function VideoMetricTabs( { + values, + selected, + onSelect, +}: { + values: VideoMetricValues; + selected: VideoStatType; + onSelect: ( statType: VideoStatType ) => void; +} ) { + const translate = useTranslate(); + + const tabs: Array< { key: VideoStatType; label: string; icon: React.ReactNode } > = [ + { + key: 'views', + label: translate( 'Views', { textOnly: true } ), + icon: , + }, + { + key: 'impressions', + label: translate( 'Impressions', { textOnly: true } ), + icon: , + }, + { + key: 'watch_time', + label: translate( 'Hours watched', { textOnly: true } ), + icon: , + }, + { + key: 'retention_rate', + label: translate( 'Retention rate', { textOnly: true } ), + icon: , + }, + ]; + + return ( +
    + { tabs.map( ( tab ) => ( +
  • + +
  • + ) ) } +
+ ); +} diff --git a/client/my-sites/stats/stats-video-detail/video-summary.tsx b/client/my-sites/stats/stats-video-detail/video-summary.tsx index 2166c1daabc8..2efd21e692e8 100644 --- a/client/my-sites/stats/stats-video-detail/video-summary.tsx +++ b/client/my-sites/stats/stats-video-detail/video-summary.tsx @@ -14,6 +14,7 @@ import DatePicker from '../stats-date-label'; import StatsPeriodHeader from '../stats-period-header'; import StatsPeriodNavigation from '../stats-period-navigation'; import SummaryChart from '../stats-summary'; +import VideoMetricTabs, { VideoStatType, VideoMetricValues } from './video-metric-tabs'; type Period = 'day' | 'week' | 'month' | 'year'; @@ -28,31 +29,90 @@ interface VideoSummaryData { data?: Array< { period: string; value: number } >; } +const STAT_TYPES: VideoStatType[] = [ 'views', 'impressions', 'watch_time', 'retention_rate' ]; + const MAX_RECORDS_PER_DAY = 30; +function isVideoStatType( value: string | null ): value is VideoStatType { + return !! value && ( STAT_TYPES as string[] ).includes( value ); +} + export default function VideoSummary( { postId, - statType, + initialStatType, }: { postId: number; - statType: string | null; + initialStatType: string | null; } ) { const translate = useTranslate(); const moment = useLocalizedMoment(); const siteId = useSelector( getSelectedSiteId ); const [ period, setPeriod ] = useState< Period >( 'day' ); + const [ statType, setStatType ] = useState< VideoStatType >( + isVideoStatType( initialStatType ) ? initialStatType : 'views' + ); const [ selectedRecord, setSelectedRecord ] = useState< ChartRecord | null >( null ); const [ page, setPage ] = useState( 1 ); - const query = useMemo( () => ( { postId, statType, period } ), [ postId, statType, period ] ); - const summaryData = useSelector( + const queries = useMemo( + () => + Object.fromEntries( + STAT_TYPES.map( ( type ) => [ type, { postId, statType: type, period } ] ) + ) as Record< VideoStatType, { postId: number; statType: VideoStatType; period: Period } >, + [ postId, period ] + ); + + const viewsData = useSelector( + ( state ) => + getSiteStatsNormalizedData( + state, + siteId, + 'statsVideo', + queries.views + ) as VideoSummaryData | null + ); + const impressionsData = useSelector( ( state ) => - getSiteStatsNormalizedData( state, siteId, 'statsVideo', query ) as VideoSummaryData | null + getSiteStatsNormalizedData( + state, + siteId, + 'statsVideo', + queries.impressions + ) as VideoSummaryData | null + ); + const watchTimeData = useSelector( + ( state ) => + getSiteStatsNormalizedData( + state, + siteId, + 'statsVideo', + queries.watch_time + ) as VideoSummaryData | null + ); + const retentionData = useSelector( + ( state ) => + getSiteStatsNormalizedData( + state, + siteId, + 'statsVideo', + queries.retention_rate + ) as VideoSummaryData | null ); const isRequesting = useSelector( ( state ) => - siteId ? isRequestingSiteStatsForQuery( state, siteId, 'statsVideo', query ) : false + siteId + ? isRequestingSiteStatsForQuery( state, siteId, 'statsVideo', queries[ statType ] ) + : false ); + const seriesByType: Record< VideoStatType, VideoSummaryData | null > = { + views: viewsData, + impressions: impressionsData, + watch_time: watchTimeData, + retention_rate: retentionData, + }; + + const summaryData = seriesByType[ statType ]; + const allRecords: ChartRecord[] = useMemo( () => ( summaryData?.data ?? [] ).map( ( { period: date, value } ) => { @@ -106,12 +166,63 @@ export default function VideoSummary( { const selected = selectedRecord ?? ( chartData.length ? chartData[ chartData.length - 1 ] : null ); + // Metric totals are computed over the dates visible in the chart, so the + // cards always agree with what the chart displays. Retention rate is not + // summable, so it is averaged weighted by views. + const visibleDates = useMemo( + () => new Set( chartData.map( ( record ) => record.startDate ) ), + [ chartData ] + ); + + const sumVisible = ( data: VideoSummaryData | null ) => { + if ( ! data?.data ) { + return null; + } + return data.data + .filter( ( record ) => visibleDates.has( record.period ) ) + .reduce( ( total, record ) => total + record.value, 0 ); + }; + + const retentionRate = useMemo( () => { + if ( ! retentionData?.data || ! viewsData?.data ) { + return null; + } + const viewsByDate = new Map( + viewsData.data + .filter( ( record ) => visibleDates.has( record.period ) ) + .map( ( record ) => [ record.period, record.value ] ) + ); + let weightedTotal = 0; + let viewsTotal = 0; + for ( const record of retentionData.data ) { + if ( ! visibleDates.has( record.period ) ) { + continue; + } + const views = viewsByDate.get( record.period ) ?? 0; + weightedTotal += record.value * views; + viewsTotal += views; + } + return viewsTotal > 0 ? weightedTotal / viewsTotal : null; + }, [ retentionData, viewsData, visibleDates ] ); + + const metricValues: VideoMetricValues = { + views: sumVisible( viewsData ), + impressions: sumVisible( impressionsData ), + watch_time: sumVisible( watchTimeData ), + retention_rate: retentionRate, + }; + const selectPeriod = ( newPeriod: Period ) => () => { setPeriod( newPeriod ); setSelectedRecord( null ); setPage( 1 ); }; + const selectStatType = ( newStatType: VideoStatType ) => { + setStatType( newStatType ); + setSelectedRecord( null ); + }; + const handleArrows = ( { direction }: { direction: string } ) => { if ( ! chartData.length || ! selected ) { return; @@ -154,14 +265,12 @@ export default function VideoSummary( { disableNextArrow = selectedIndex === chartData.length - 1; } - let tabLabel = translate( 'Views' ); - if ( statType === 'impressions' ) { - tabLabel = translate( 'Impressions' ); - } else if ( statType === 'watch_time' ) { - tabLabel = translate( 'Hours Watched' ); - } else if ( statType === 'retention_rate' ) { - tabLabel = translate( 'Retention Rate' ); - } + const tabLabels: Record< VideoStatType, string > = { + views: translate( 'Views', { textOnly: true } ), + impressions: translate( 'Impressions', { textOnly: true } ), + watch_time: translate( 'Hours watched', { textOnly: true } ), + retention_rate: translate( 'Retention rate', { textOnly: true } ), + }; const periods: Array< { id: Period; label: string } > = [ { id: 'day', label: translate( 'Days', { textOnly: true } ) }, @@ -176,7 +285,15 @@ export default function VideoSummary( { 'is-period-year': period === 'year', } ) } > - { siteId && } + { siteId && + STAT_TYPES.map( ( type ) => ( + + ) ) } + + ); } From b171e3c874ca9c140338e25ae34ed566f9fe4f8f Mon Sep 17 00:00:00 2001 From: Dognose Date: Fri, 10 Jul 2026 10:11:45 +0800 Subject: [PATCH 04/30] Stats: modernize the video details Embedded pages module Replace the legacy StatModuleVideoDetails list with an Embedded pages card matching the rest of the redesigned video details page. Pages still display as URLs since the stats/video endpoint only returns embed URLs; showing post titles needs the API to return post IDs/titles. Part of STATS-299. Co-Authored-By: Claude Fable 5 --- .../stats/stats-video-detail/index.tsx | 4 +- .../stats/stats-video-detail/style.scss | 42 ++++++++++++++ .../stats-video-detail/video-embeds-card.tsx | 55 +++++++++++++++++++ .../stats/stats-video-details/index.jsx | 46 ---------------- 4 files changed, 99 insertions(+), 48 deletions(-) create mode 100644 client/my-sites/stats/stats-video-detail/video-embeds-card.tsx delete mode 100644 client/my-sites/stats/stats-video-details/index.jsx diff --git a/client/my-sites/stats/stats-video-detail/index.tsx b/client/my-sites/stats/stats-video-detail/index.tsx index b2ba63575eb9..f4fbebf9fe81 100644 --- a/client/my-sites/stats/stats-video-detail/index.tsx +++ b/client/my-sites/stats/stats-video-detail/index.tsx @@ -11,8 +11,8 @@ import { useSelector } from 'calypso/state'; import getMediaItem from 'calypso/state/selectors/get-media-item'; import { getSelectedSiteId } from 'calypso/state/ui/selectors'; import PageViewTracker from '../stats-page-view-tracker'; -import VideoPlayDetails from '../stats-video-details'; import VideoDetailsCard, { VideoMediaItem } from './video-details-card'; +import VideoEmbedsCard from './video-embeds-card'; import VideoSummary from './video-summary'; interface StatsVideoDetailProps { @@ -70,7 +70,7 @@ export default function StatsVideoDetail( { postId, period, context }: StatsVide > - + diff --git a/client/my-sites/stats/stats-video-detail/style.scss b/client/my-sites/stats/stats-video-detail/style.scss index 6221c21b5c1c..9e0ae1646d34 100644 --- a/client/my-sites/stats/stats-video-detail/style.scss +++ b/client/my-sites/stats/stats-video-detail/style.scss @@ -100,6 +100,48 @@ $border-radius: 5px; // stylelint-disable-line scales/radii width: 100%; } +.stats-video-embeds-card { + border-color: var(--studio-gray-5); + border-radius: 5px; /* stylelint-disable-line scales/radii */ + font-family: $font-sf-pro-text; + font-size: $font-body-small; + padding: 24px; + + &.card::after { + display: none; + } +} + +.stats-video-embeds-card__heading { + color: var(--studio-gray-100); + font-family: $font-sf-pro-display; + font-size: $font-size-header-small; + font-weight: 500; + line-height: 1.3; + margin-bottom: 16px; +} + +.stats-video-embeds-card__list { + list-style: none; + margin: 0; + padding: 0; +} + +.stats-video-embeds-card__item { + line-height: 1.5; + margin: 0; + overflow-wrap: anywhere; + + + .stats-video-embeds-card__item { + margin-top: 12px; + } +} + +.stats-video-embeds-card__empty { + color: var(--studio-gray-60); + line-height: 1.5; +} + .stats-video-metric-tabs { display: grid; grid-template-columns: repeat(4, 1fr); diff --git a/client/my-sites/stats/stats-video-detail/video-embeds-card.tsx b/client/my-sites/stats/stats-video-detail/video-embeds-card.tsx new file mode 100644 index 000000000000..4b16701d112d --- /dev/null +++ b/client/my-sites/stats/stats-video-detail/video-embeds-card.tsx @@ -0,0 +1,55 @@ +import { Card } from '@automattic/components'; +import { useTranslate } from 'i18n-calypso'; +import { useMemo } from 'react'; +import QuerySiteStats from 'calypso/components/data/query-site-stats'; +import { useSelector } from 'calypso/state'; +import { + getSiteStatsNormalizedData, + isRequestingSiteStatsForQuery, +} from 'calypso/state/stats/lists/selectors'; +import { getSelectedSiteId } from 'calypso/state/ui/selectors'; +import StatsModulePlaceholder from '../stats-module/placeholder'; + +interface VideoEmbedsData { + pages?: Array< { label: string; link: string } >; +} + +export default function VideoEmbedsCard( { postId }: { postId: number } ) { + const translate = useTranslate(); + const siteId = useSelector( getSelectedSiteId ); + const query = useMemo( () => ( { postId } ), [ postId ] ); + + const data = useSelector( + ( state ) => + getSiteStatsNormalizedData( state, siteId, 'statsVideo', query ) as VideoEmbedsData | null + ); + const isLoading = useSelector( ( state ) => + siteId ? isRequestingSiteStatsForQuery( state, siteId, 'statsVideo', query ) && ! data : ! data + ); + + const pages = data?.pages ?? []; + + return ( + + { siteId && } +

{ translate( 'Embedded pages' ) }

+ + { ! isLoading && ! pages.length && ( +
+ { translate( 'No pages have embedded this video yet.' ) } +
+ ) } + { pages.length > 0 && ( + + ) } +
+ ); +} diff --git a/client/my-sites/stats/stats-video-details/index.jsx b/client/my-sites/stats/stats-video-details/index.jsx deleted file mode 100644 index ecef37200687..000000000000 --- a/client/my-sites/stats/stats-video-details/index.jsx +++ /dev/null @@ -1,46 +0,0 @@ -import { Card } from '@automattic/components'; -import clsx from 'clsx'; -import { localize } from 'i18n-calypso'; -import { connect } from 'react-redux'; -import QuerySiteStats from 'calypso/components/data/query-site-stats'; -import { - isRequestingSiteStatsForQuery, - getSiteStatsNormalizedData, -} from 'calypso/state/stats/lists/selectors'; -import { getSelectedSiteId } from 'calypso/state/ui/selectors'; -import StatsList from '../stats-list'; -import StatsListLegend from '../stats-list/legend'; -import StatsModuleHeader from '../stats-module/header'; -import StatsModulePlaceholder from '../stats-module/placeholder'; - -const StatModuleVideoDetails = ( props ) => { - const { data, query, requesting, siteId, translate } = props; - const isLoading = requesting && ! data; - - const classes = clsx( 'stats-module', 'is-expanded', 'summary', { - 'is-loading': isLoading, - 'has-no-data': ! data, - } ); - - return ( - - { siteId && } - - - - - - ); -}; - -export default connect( ( state, { postId, statType } ) => { - const siteId = getSelectedSiteId( state ); - const query = { postId, statType }; - - return { - requesting: isRequestingSiteStatsForQuery( state, siteId, 'statsVideo', query ), - data: getSiteStatsNormalizedData( state, siteId, 'statsVideo', query ), - query, - siteId, - }; -} )( localize( StatModuleVideoDetails ) ); From 59fe4ce6d26e25b5dbf4e1b3c8aaae514110eac1 Mon Sep 17 00:00:00 2001 From: Dognose Date: Fri, 10 Jul 2026 12:19:56 +0800 Subject: [PATCH 05/30] Stats: zero-fill the video details chart series The stats/video endpoint only returns buckets from the video's first activity onwards, so a young video yields one or two bars, which the is-chart-tabs layout (max-width bars + space-between) then scatters away from the axis labels. Fill gaps between buckets, extend the day view backwards to a full 30-bar window per the designs, and add the has-less-than-three-bars fallback class used by the traffic chart. Part of STATS-299. Co-Authored-By: Claude Fable 5 --- .../stats/stats-video-detail/index.tsx | 6 +- .../stats-video-detail/video-summary.tsx | 66 ++++++++++++++++--- 2 files changed, 63 insertions(+), 9 deletions(-) diff --git a/client/my-sites/stats/stats-video-detail/index.tsx b/client/my-sites/stats/stats-video-detail/index.tsx index f4fbebf9fe81..3c2414da9280 100644 --- a/client/my-sites/stats/stats-video-detail/index.tsx +++ b/client/my-sites/stats/stats-video-detail/index.tsx @@ -69,7 +69,11 @@ export default function StatsVideoDetail( { postId, period, context }: StatsVide className="stats-summary-view stats-summary__positioned stats-video-detail" > - + diff --git a/client/my-sites/stats/stats-video-detail/video-summary.tsx b/client/my-sites/stats/stats-video-detail/video-summary.tsx index 2efd21e692e8..62b3cae1cd1d 100644 --- a/client/my-sites/stats/stats-video-detail/video-summary.tsx +++ b/client/my-sites/stats/stats-video-detail/video-summary.tsx @@ -40,9 +40,11 @@ function isVideoStatType( value: string | null ): value is VideoStatType { export default function VideoSummary( { postId, initialStatType, + uploadDate, }: { postId: number; initialStatType: string | null; + uploadDate: string | null; } ) { const translate = useTranslate(); const moment = useLocalizedMoment(); @@ -113,9 +115,54 @@ export default function VideoSummary( { const summaryData = seriesByType[ statType ]; + // Normalizes a raw API date to its period bucket, so generated buckets and + // API records use the same keys regardless of the API's date format. + const bucketKey = ( date: string ) => moment( date ).startOf( period ).format( 'YYYY-MM-DD' ); + + // The API only returns buckets from the video's first activity onwards, so + // a young video yields one or two bars that the chart lays out poorly. + // Zero-fill gaps and, for the day view, extend the window backwards to a + // full page of bars, matching the designs. + const zeroFilledData = useMemo( () => { + const raw = summaryData?.data ?? []; + if ( ! raw.length ) { + return raw; + } + + const valuesByBucket = new Map( + raw.map( ( { period: date, value } ) => [ bucketKey( date ), value ] ) + ); + const end = moment( raw[ raw.length - 1 ].period ).startOf( period ); + let start = moment( raw[ 0 ].period ).startOf( period ); + if ( uploadDate ) { + const uploadStart = moment( uploadDate ).startOf( period ); + if ( uploadStart.isValid() && uploadStart.isBefore( start ) ) { + start = uploadStart; + } + } + if ( period === 'day' ) { + const minStart = end.clone().subtract( MAX_RECORDS_PER_DAY - 1, 'day' ); + if ( minStart.isBefore( start ) ) { + start = minStart; + } + } + const maxBuckets = 1000; + if ( end.diff( start, period ) + 1 > maxBuckets ) { + start = end.clone().subtract( maxBuckets - 1, period ); + } + + const buckets = []; + for ( const cursor = start.clone(); ! cursor.isAfter( end ); cursor.add( 1, period ) ) { + const key = cursor.format( 'YYYY-MM-DD' ); + buckets.push( { period: key, value: valuesByBucket.get( key ) ?? 0 } ); + } + return buckets; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ summaryData, period, uploadDate, moment ] ); + const allRecords: ChartRecord[] = useMemo( () => - ( summaryData?.data ?? [] ).map( ( { period: date, value } ) => { + zeroFilledData.map( ( { period: date, value } ) => { const start = moment( date ); switch ( period ) { case 'week': @@ -150,7 +197,7 @@ export default function VideoSummary( { }; } } ), - [ summaryData, period, moment ] + [ zeroFilledData, period, moment ] ); const getPageRecords = ( pageNum: number ) => { @@ -179,7 +226,7 @@ export default function VideoSummary( { return null; } return data.data - .filter( ( record ) => visibleDates.has( record.period ) ) + .filter( ( record ) => visibleDates.has( bucketKey( record.period ) ) ) .reduce( ( total, record ) => total + record.value, 0 ); }; @@ -189,21 +236,23 @@ export default function VideoSummary( { } const viewsByDate = new Map( viewsData.data - .filter( ( record ) => visibleDates.has( record.period ) ) - .map( ( record ) => [ record.period, record.value ] ) + .filter( ( record ) => visibleDates.has( bucketKey( record.period ) ) ) + .map( ( record ) => [ bucketKey( record.period ), record.value ] ) ); let weightedTotal = 0; let viewsTotal = 0; for ( const record of retentionData.data ) { - if ( ! visibleDates.has( record.period ) ) { + const key = bucketKey( record.period ); + if ( ! visibleDates.has( key ) ) { continue; } - const views = viewsByDate.get( record.period ) ?? 0; + const views = viewsByDate.get( key ) ?? 0; weightedTotal += record.value * views; viewsTotal += views; } return viewsTotal > 0 ? weightedTotal / viewsTotal : null; - }, [ retentionData, viewsData, visibleDates ] ); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ retentionData, viewsData, visibleDates, period ] ); const metricValues: VideoMetricValues = { views: sumVisible( viewsData ), @@ -283,6 +332,7 @@ export default function VideoSummary( {
0 && chartData.length < 3, } ) } > { siteId && From db3e82002adf6fa035441e8fd95aa7b41bc24c9d Mon Sep 17 00:00:00 2001 From: Dognose Date: Fri, 10 Jul 2026 14:08:31 +0800 Subject: [PATCH 06/30] Stats: align the video details chart with the real stats/video semantics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A backend investigation confirmed stats/video/:id treats period as a fixed trailing-window selector (day yields only 2 daily buckets, year yields ~13 monthly buckets) rather than a bucket granularity, and that its statType only recognizes watch_time and impressions — views falls back to the plays column and there is no retention series at all. Rework the summary accordingly: fetch the 30-day daily window for the Days/Weeks views and the 365-day monthly window for Months/Years, and aggregate buckets client-side. Views now covers the same 30-day window and plays metric as the Traffic Videos module and the All videos page. Retention rate is derived per bucket and in total with the canonical complete_stats formula, (watch time / plays) / video duration, using the media item duration — replacing the bogus fourth query whose response was silently the plays series. Also harden the statsVideo normalizer against the endpoint's empty-window response shape, and drop the now-unneeded zero-fill and paging logic. Part of STATS-299. Co-Authored-By: Claude Fable 5 --- .../stats/stats-video-detail/index.tsx | 2 +- .../stats-video-detail/video-details-card.tsx | 2 + .../stats-video-detail/video-summary.tsx | 343 ++++++++---------- client/state/stats/lists/utils.js | 12 +- 4 files changed, 165 insertions(+), 194 deletions(-) diff --git a/client/my-sites/stats/stats-video-detail/index.tsx b/client/my-sites/stats/stats-video-detail/index.tsx index 3c2414da9280..da495fda1164 100644 --- a/client/my-sites/stats/stats-video-detail/index.tsx +++ b/client/my-sites/stats/stats-video-detail/index.tsx @@ -72,7 +72,7 @@ export default function StatsVideoDetail( { postId, period, context }: StatsVide
diff --git a/client/my-sites/stats/stats-video-detail/video-details-card.tsx b/client/my-sites/stats/stats-video-detail/video-details-card.tsx index acb21f5c420a..93c613fa8b56 100644 --- a/client/my-sites/stats/stats-video-detail/video-details-card.tsx +++ b/client/my-sites/stats/stats-video-detail/video-details-card.tsx @@ -13,6 +13,8 @@ export interface VideoMediaItem { ID: number; title?: string; date?: string; + /** Video duration in seconds. */ + length?: number; thumbnails?: Record< string, string >; } diff --git a/client/my-sites/stats/stats-video-detail/video-summary.tsx b/client/my-sites/stats/stats-video-detail/video-summary.tsx index 62b3cae1cd1d..f42ff7246080 100644 --- a/client/my-sites/stats/stats-video-detail/video-summary.tsx +++ b/client/my-sites/stats/stats-video-detail/video-summary.tsx @@ -16,7 +16,22 @@ import StatsPeriodNavigation from '../stats-period-navigation'; import SummaryChart from '../stats-summary'; import VideoMetricTabs, { VideoStatType, VideoMetricValues } from './video-metric-tabs'; -type Period = 'day' | 'week' | 'month' | 'year'; +type UiPeriod = 'day' | 'week' | 'month' | 'year'; + +// The stats/video/:id endpoint treats `period` as a fixed trailing-window +// selector, not a bucket granularity: `month` returns ~31 daily buckets and +// `year` returns ~13 monthly buckets (there are no weekly or yearly buckets, +// and `day`/`week` windows are too small to chart). So we fetch the daily +// window for the Days/Weeks views and the monthly window for Months/Years, +// then aggregate client-side. +type ApiPeriod = 'month' | 'year'; + +// The endpoint only recognizes statType=watch_time|impressions; `views` falls +// back to the plays column, which is the same metric the Videos module and the +// All videos page label "Views". There is no retention series — retention is +// derived below with the same formula the video-plays complete_stats endpoint +// uses: (watch time / plays) / video duration. +const FETCHED_STAT_TYPES = [ 'views', 'impressions', 'watch_time' ] as const; interface ChartRecord { period: string; @@ -25,46 +40,83 @@ interface ChartRecord { value: number; } +interface BucketRecord { + key: string; + plays: number; + impressions: number; + watchTime: number; + retention: number; +} + interface VideoSummaryData { data?: Array< { period: string; value: number } >; } const STAT_TYPES: VideoStatType[] = [ 'views', 'impressions', 'watch_time', 'retention_rate' ]; -const MAX_RECORDS_PER_DAY = 30; - function isVideoStatType( value: string | null ): value is VideoStatType { return !! value && ( STAT_TYPES as string[] ).includes( value ); } +function metricOfBucket( bucket: BucketRecord, type: VideoStatType ): number { + switch ( type ) { + case 'impressions': + return bucket.impressions; + case 'watch_time': + return bucket.watchTime; + case 'retention_rate': + return bucket.retention; + default: + return bucket.plays; + } +} + +function computeRetention( + watchTimeHours: number, + plays: number, + videoDuration: number | null +): number { + if ( ! videoDuration || plays <= 0 ) { + return 0; + } + return ( ( watchTimeHours * 3600 ) / plays / videoDuration ) * 100; +} + export default function VideoSummary( { postId, initialStatType, - uploadDate, + videoDuration, }: { postId: number; initialStatType: string | null; - uploadDate: string | null; + videoDuration: number | null; } ) { const translate = useTranslate(); const moment = useLocalizedMoment(); const siteId = useSelector( getSelectedSiteId ); - const [ period, setPeriod ] = useState< Period >( 'day' ); + const [ uiPeriod, setUiPeriod ] = useState< UiPeriod >( 'day' ); const [ statType, setStatType ] = useState< VideoStatType >( isVideoStatType( initialStatType ) ? initialStatType : 'views' ); const [ selectedRecord, setSelectedRecord ] = useState< ChartRecord | null >( null ); - const [ page, setPage ] = useState( 1 ); + + const apiPeriod: ApiPeriod = uiPeriod === 'day' || uiPeriod === 'week' ? 'month' : 'year'; const queries = useMemo( () => Object.fromEntries( - STAT_TYPES.map( ( type ) => [ type, { postId, statType: type, period } ] ) - ) as Record< VideoStatType, { postId: number; statType: VideoStatType; period: Period } >, - [ postId, period ] + FETCHED_STAT_TYPES.map( ( type ) => [ + type, + { postId, statType: type, period: apiPeriod }, + ] ) + ) as Record< + ( typeof FETCHED_STAT_TYPES )[ number ], + { postId: number; statType: string; period: ApiPeriod } + >, + [ postId, apiPeriod ] ); - const viewsData = useSelector( + const playsData = useSelector( ( state ) => getSiteStatsNormalizedData( state, @@ -91,180 +143,120 @@ export default function VideoSummary( { queries.watch_time ) as VideoSummaryData | null ); - const retentionData = useSelector( - ( state ) => - getSiteStatsNormalizedData( - state, - siteId, - 'statsVideo', - queries.retention_rate - ) as VideoSummaryData | null - ); const isRequesting = useSelector( ( state ) => siteId - ? isRequestingSiteStatsForQuery( state, siteId, 'statsVideo', queries[ statType ] ) + ? FETCHED_STAT_TYPES.some( ( type ) => + isRequestingSiteStatsForQuery( state, siteId, 'statsVideo', queries[ type ] ) + ) : false ); - const seriesByType: Record< VideoStatType, VideoSummaryData | null > = { - views: viewsData, - impressions: impressionsData, - watch_time: watchTimeData, - retention_rate: retentionData, - }; - - const summaryData = seriesByType[ statType ]; - - // Normalizes a raw API date to its period bucket, so generated buckets and - // API records use the same keys regardless of the API's date format. - const bucketKey = ( date: string ) => moment( date ).startOf( period ).format( 'YYYY-MM-DD' ); - - // The API only returns buckets from the video's first activity onwards, so - // a young video yields one or two bars that the chart lays out poorly. - // Zero-fill gaps and, for the day view, extend the window backwards to a - // full page of bars, matching the designs. - const zeroFilledData = useMemo( () => { - const raw = summaryData?.data ?? []; - if ( ! raw.length ) { - return raw; - } - - const valuesByBucket = new Map( - raw.map( ( { period: date, value } ) => [ bucketKey( date ), value ] ) - ); - const end = moment( raw[ raw.length - 1 ].period ).startOf( period ); - let start = moment( raw[ 0 ].period ).startOf( period ); - if ( uploadDate ) { - const uploadStart = moment( uploadDate ).startOf( period ); - if ( uploadStart.isValid() && uploadStart.isBefore( start ) ) { - start = uploadStart; - } - } - if ( period === 'day' ) { - const minStart = end.clone().subtract( MAX_RECORDS_PER_DAY - 1, 'day' ); - if ( minStart.isBefore( start ) ) { - start = minStart; + // Group the fetched buckets (daily or monthly) into the buckets the UI + // period wants, summing values. Bucket keys are normalized ISO dates. + const buckets: BucketRecord[] = useMemo( () => { + const unit = uiPeriod; + const toBucketMap = ( data?: Array< { period: string; value: number } > ) => { + const map = new Map< string, number >(); + for ( const { period: date, value } of data ?? [] ) { + const parsed = moment( date ); + if ( ! parsed.isValid() ) { + continue; + } + const key = parsed.startOf( unit ).format( 'YYYY-MM-DD' ); + map.set( key, ( map.get( key ) ?? 0 ) + value ); } - } - const maxBuckets = 1000; - if ( end.diff( start, period ) + 1 > maxBuckets ) { - start = end.clone().subtract( maxBuckets - 1, period ); - } - - const buckets = []; - for ( const cursor = start.clone(); ! cursor.isAfter( end ); cursor.add( 1, period ) ) { - const key = cursor.format( 'YYYY-MM-DD' ); - buckets.push( { period: key, value: valuesByBucket.get( key ) ?? 0 } ); - } - return buckets; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [ summaryData, period, uploadDate, moment ] ); - - const allRecords: ChartRecord[] = useMemo( + return map; + }; + + const playsByBucket = toBucketMap( playsData?.data ); + const impressionsByBucket = toBucketMap( impressionsData?.data ); + const watchTimeByBucket = toBucketMap( watchTimeData?.data ); + + const keys = Array.from( + new Set( [ ...playsByBucket.keys(), ...impressionsByBucket.keys() ] ) + ).sort(); + + return keys.map( ( key ) => { + const plays = playsByBucket.get( key ) ?? 0; + const watchTime = watchTimeByBucket.get( key ) ?? 0; + return { + key, + plays, + impressions: impressionsByBucket.get( key ) ?? 0, + watchTime, + retention: computeRetention( watchTime, plays, videoDuration ), + }; + } ); + }, [ playsData, impressionsData, watchTimeData, uiPeriod, videoDuration, moment ] ); + + const chartData: ChartRecord[] = useMemo( () => - zeroFilledData.map( ( { period: date, value } ) => { - const start = moment( date ); - switch ( period ) { + buckets.map( ( bucket ) => { + const start = moment( bucket.key ); + const value = metricOfBucket( bucket, statType ); + switch ( uiPeriod ) { case 'week': return { period: start.format( 'MMM D' ), - periodLabel: `${ start.format( 'L' ) } - ${ moment( date ) + periodLabel: `${ start.format( 'L' ) } - ${ moment( bucket.key ) .add( 6, 'days' ) .format( 'L' ) }`, - startDate: date, + startDate: bucket.key, value, }; case 'month': return { period: start.format( 'MMM YYYY' ), periodLabel: start.format( 'MMMM YYYY' ), - startDate: date, + startDate: bucket.key, value, }; case 'year': return { period: start.format( 'YYYY' ), periodLabel: start.format( 'YYYY' ), - startDate: date, + startDate: bucket.key, value, }; default: return { period: start.format( 'MMM D' ), periodLabel: start.format( 'LL' ), - startDate: date, + startDate: bucket.key, value, }; } } ), - [ zeroFilledData, period, moment ] + [ buckets, statType, uiPeriod, moment ] ); - const getPageRecords = ( pageNum: number ) => { - if ( period !== 'day' ) { - return allRecords; - } - const start = Math.max( allRecords.length - MAX_RECORDS_PER_DAY * pageNum, 0 ); - const end = Math.max( allRecords.length - MAX_RECORDS_PER_DAY * ( pageNum - 1 ), 0 ); - return allRecords.slice( start, end ); - }; - - const chartData = getPageRecords( page ); const selected = selectedRecord ?? ( chartData.length ? chartData[ chartData.length - 1 ] : null ); - // Metric totals are computed over the dates visible in the chart, so the - // cards always agree with what the chart displays. Retention rate is not - // summable, so it is averaged weighted by views. - const visibleDates = useMemo( - () => new Set( chartData.map( ( record ) => record.startDate ) ), - [ chartData ] - ); - - const sumVisible = ( data: VideoSummaryData | null ) => { - if ( ! data?.data ) { - return null; - } - return data.data - .filter( ( record ) => visibleDates.has( bucketKey( record.period ) ) ) - .reduce( ( total, record ) => total + record.value, 0 ); - }; - - const retentionRate = useMemo( () => { - if ( ! retentionData?.data || ! viewsData?.data ) { - return null; - } - const viewsByDate = new Map( - viewsData.data - .filter( ( record ) => visibleDates.has( bucketKey( record.period ) ) ) - .map( ( record ) => [ bucketKey( record.period ), record.value ] ) - ); - let weightedTotal = 0; - let viewsTotal = 0; - for ( const record of retentionData.data ) { - const key = bucketKey( record.period ); - if ( ! visibleDates.has( key ) ) { - continue; - } - const views = viewsByDate.get( key ) ?? 0; - weightedTotal += record.value * views; - viewsTotal += views; - } - return viewsTotal > 0 ? weightedTotal / viewsTotal : null; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [ retentionData, viewsData, visibleDates, period ] ); - - const metricValues: VideoMetricValues = { - views: sumVisible( viewsData ), - impressions: sumVisible( impressionsData ), - watch_time: sumVisible( watchTimeData ), - retention_rate: retentionRate, - }; - - const selectPeriod = ( newPeriod: Period ) => () => { - setPeriod( newPeriod ); + // Card totals cover the whole window shown in the chart. Retention is not + // summable, so the total uses the same canonical formula over the sums. + const metricValues: VideoMetricValues = useMemo( () => { + const sum = ( data: VideoSummaryData | null, pick: ( bucket: BucketRecord ) => number ) => + data ? buckets.reduce( ( total, bucket ) => total + pick( bucket ), 0 ) : null; + + const playsTotal = sum( playsData, ( bucket ) => bucket.plays ); + const watchTimeTotal = sum( watchTimeData, ( bucket ) => bucket.watchTime ); + const retentionTotal = + playsTotal !== null && watchTimeTotal !== null && videoDuration && playsTotal > 0 + ? computeRetention( watchTimeTotal, playsTotal, videoDuration ) + : null; + + return { + views: playsTotal, + impressions: sum( impressionsData, ( bucket ) => bucket.impressions ), + watch_time: watchTimeTotal, + retention_rate: retentionTotal, + }; + }, [ buckets, playsData, impressionsData, watchTimeData, videoDuration ] ); + + const selectPeriod = ( newPeriod: UiPeriod ) => () => { + setUiPeriod( newPeriod ); setSelectedRecord( null ); - setPage( 1 ); }; const selectStatType = ( newStatType: VideoStatType ) => { @@ -272,48 +264,21 @@ export default function VideoSummary( { setSelectedRecord( null ); }; + const selectedIndex = selected + ? chartData.findIndex( ( record ) => record.period === selected.period ) + : -1; + const handleArrows = ( { direction }: { direction: string } ) => { - if ( ! chartData.length || ! selected ) { + if ( selectedIndex === -1 ) { return; } - - const recordIndex = chartData.findIndex( ( record ) => record.period === selected.period ); - - if ( direction === 'previous' ) { - if ( recordIndex > 0 ) { - setSelectedRecord( chartData[ recordIndex - 1 ] ); - } else { - const previousPage = getPageRecords( page + 1 ); - if ( previousPage.length ) { - setPage( page + 1 ); - setSelectedRecord( previousPage[ previousPage.length - 1 ] ); - } - } - } else if ( direction === 'next' ) { - if ( recordIndex < chartData.length - 1 ) { - setSelectedRecord( chartData[ recordIndex + 1 ] ); - } else if ( page > 1 ) { - const nextPage = getPageRecords( page - 1 ); - setPage( page - 1 ); - setSelectedRecord( nextPage[ 0 ] ); - } + if ( direction === 'previous' && selectedIndex > 0 ) { + setSelectedRecord( chartData[ selectedIndex - 1 ] ); + } else if ( direction === 'next' && selectedIndex < chartData.length - 1 ) { + setSelectedRecord( chartData[ selectedIndex + 1 ] ); } }; - const selectedIndex = selected - ? chartData.findIndex( ( record ) => record.period === selected.period ) - : -1; - let disablePreviousArrow = false; - let disableNextArrow = false; - if ( period === 'day' && allRecords.length ) { - const maxPages = Math.ceil( allRecords.length / MAX_RECORDS_PER_DAY ); - disablePreviousArrow = page >= maxPages && selectedIndex === 0; - disableNextArrow = page === 1 && selectedIndex === chartData.length - 1; - } else { - disablePreviousArrow = selectedIndex <= 0; - disableNextArrow = selectedIndex === chartData.length - 1; - } - const tabLabels: Record< VideoStatType, string > = { views: translate( 'Views', { textOnly: true } ), impressions: translate( 'Impressions', { textOnly: true } ), @@ -321,7 +286,7 @@ export default function VideoSummary( { retention_rate: translate( 'Retention rate', { textOnly: true } ), }; - const periods: Array< { id: Period; label: string } > = [ + const periods: Array< { id: UiPeriod; label: string } > = [ { id: 'day', label: translate( 'Days', { textOnly: true } ) }, { id: 'week', label: translate( 'Weeks', { textOnly: true } ) }, { id: 'month', label: translate( 'Months', { textOnly: true } ) }, @@ -331,14 +296,14 @@ export default function VideoSummary( { return (
0 && chartData.length < 3, } ) } > { siteId && - STAT_TYPES.map( ( type ) => ( + FETCHED_STAT_TYPES.map( ( type ) => ( - + { periods.map( ( { id, label } ) => ( { label } diff --git a/client/state/stats/lists/utils.js b/client/state/stats/lists/utils.js index f39f4bb05efa..6655faa91803 100644 --- a/client/state/stats/lists/utils.js +++ b/client/state/stats/lists/utils.js @@ -834,10 +834,14 @@ export const normalizers = { } let data = []; - if ( payload.data ) { - data = payload.data.map( ( item ) => { - return { period: item[ 0 ], value: item[ 1 ] }; - } ); + // When the requested window has no rows at all, the endpoint returns a single + // `{ date, p }` object instead of the usual `[ date, value ]` tuples. + if ( Array.isArray( payload.data ) ) { + data = payload.data + .filter( ( item ) => Array.isArray( item ) ) + .map( ( item ) => { + return { period: item[ 0 ], value: item[ 1 ] }; + } ); } let pages = []; From 47187c61b17aa4a9bb54b1a04192808227b01f13 Mon Sep 17 00:00:00 2001 From: Dognose Date: Fri, 10 Jul 2026 14:24:35 +0800 Subject: [PATCH 07/30] Stats: trim the video details daily window to the trailing 30 days The stats/video endpoint's month window spans 31 days inclusive, one more than the 30-day window the Videos module and All videos page show, so the Views total was off by the oldest day's plays. Part of STATS-299. Co-Authored-By: Claude Fable 5 --- .../my-sites/stats/stats-video-detail/video-summary.tsx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/client/my-sites/stats/stats-video-detail/video-summary.tsx b/client/my-sites/stats/stats-video-detail/video-summary.tsx index f42ff7246080..f1cf489c515b 100644 --- a/client/my-sites/stats/stats-video-detail/video-summary.tsx +++ b/client/my-sites/stats/stats-video-detail/video-summary.tsx @@ -155,9 +155,14 @@ export default function VideoSummary( { // period wants, summing values. Bucket keys are normalized ISO dates. const buckets: BucketRecord[] = useMemo( () => { const unit = uiPeriod; + // The endpoint's `month` window spans 31 days inclusive; trim to the + // trailing 30 so totals line up with the 30-day window the Videos + // module and the All videos page show by default. + const trimToWindow = ( data?: Array< { period: string; value: number } > ) => + apiPeriod === 'month' && data && data.length > 30 ? data.slice( -30 ) : data; const toBucketMap = ( data?: Array< { period: string; value: number } > ) => { const map = new Map< string, number >(); - for ( const { period: date, value } of data ?? [] ) { + for ( const { period: date, value } of trimToWindow( data ) ?? [] ) { const parsed = moment( date ); if ( ! parsed.isValid() ) { continue; @@ -187,7 +192,7 @@ export default function VideoSummary( { retention: computeRetention( watchTime, plays, videoDuration ), }; } ); - }, [ playsData, impressionsData, watchTimeData, uiPeriod, videoDuration, moment ] ); + }, [ playsData, impressionsData, watchTimeData, uiPeriod, apiPeriod, videoDuration, moment ] ); const chartData: ChartRecord[] = useMemo( () => From 8f81611c23a496bdb9366fb0da3e223b96fb0861 Mon Sep 17 00:00:00 2001 From: Dognose Date: Fri, 10 Jul 2026 15:10:21 +0800 Subject: [PATCH 08/30] Stats: keep video chart month labels on one line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The chart's x-axis labels have a fixed 42px width, so 'MMM YYYY' wraps and collides with the metric cards below. Show 'MMM' like the legacy year view did — the full month and year remain in the bar tooltip and the date heading — and key bar selection by startDate since bare month labels can repeat within a 13-month window. Part of STATS-299. Co-Authored-By: Claude Fable 5 --- .../my-sites/stats/stats-video-detail/video-summary.tsx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/client/my-sites/stats/stats-video-detail/video-summary.tsx b/client/my-sites/stats/stats-video-detail/video-summary.tsx index f1cf489c515b..8460a2b0b74c 100644 --- a/client/my-sites/stats/stats-video-detail/video-summary.tsx +++ b/client/my-sites/stats/stats-video-detail/video-summary.tsx @@ -210,8 +210,11 @@ export default function VideoSummary( { value, }; case 'month': + // The chart's x-axis labels are 42px wide; 'MMM YYYY' wraps + // onto a second line. The year is visible in the tooltip and + // the date heading. return { - period: start.format( 'MMM YYYY' ), + period: start.format( 'MMM' ), periodLabel: start.format( 'MMMM YYYY' ), startDate: bucket.key, value, @@ -269,8 +272,10 @@ export default function VideoSummary( { setSelectedRecord( null ); }; + // Bucket labels can repeat across the window (e.g. two "Jul" months), so + // selection identity uses the unique startDate. const selectedIndex = selected - ? chartData.findIndex( ( record ) => record.period === selected.period ) + ? chartData.findIndex( ( record ) => record.startDate === selected.startDate ) : -1; const handleArrows = ( { direction }: { direction: string } ) => { From ed7f7f5f5095a8e40fb436d9d9dd10b913859a35 Mon Sep 17 00:00:00 2001 From: Dognose Date: Fri, 10 Jul 2026 15:16:34 +0800 Subject: [PATCH 09/30] Stats: bucket video weekly stats Monday-based to match the date heading The date heading (stats-date-label) renders weeks Monday-Sunday, while the weekly aggregation grouped by the locale's week start (Sunday in en), so a selected bar's tooltip range was one day off from the heading. Group by isoWeek, which matches the heading's convention on every Monday key regardless of locale. Also center capped chart bars in their slots (space-around) so bars line up with the mathematically positioned x-axis labels when there are only a few buckets. Part of STATS-299. Co-Authored-By: Claude Fable 5 --- client/my-sites/stats/stats-video-detail/style.scss | 8 ++++++++ .../my-sites/stats/stats-video-detail/video-summary.tsx | 4 +++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/client/my-sites/stats/stats-video-detail/style.scss b/client/my-sites/stats/stats-video-detail/style.scss index 9e0ae1646d34..caf1151447d5 100644 --- a/client/my-sites/stats/stats-video-detail/style.scss +++ b/client/my-sites/stats/stats-video-detail/style.scss @@ -18,6 +18,14 @@ $border-radius: 5px; // stylelint-disable-line scales/radii gap: 24px; } +// The x-axis places labels at equal-slot centers (chart width / bar count), +// but `.is-chart-tabs` caps bars at 156px and lays them out space-between, so +// with few bars (e.g. ~5 weekly buckets) bars drift away from their labels. +// space-around keeps every capped bar centered in its slot. +.stats-video-summary.is-chart-tabs .chart__bars { + justify-content: space-around; +} + .stats-video-details-card { border-color: var(--studio-gray-5); border-radius: $border-radius; diff --git a/client/my-sites/stats/stats-video-detail/video-summary.tsx b/client/my-sites/stats/stats-video-detail/video-summary.tsx index 8460a2b0b74c..676df83bffa6 100644 --- a/client/my-sites/stats/stats-video-detail/video-summary.tsx +++ b/client/my-sites/stats/stats-video-detail/video-summary.tsx @@ -167,7 +167,9 @@ export default function VideoSummary( { if ( ! parsed.isValid() ) { continue; } - const key = parsed.startOf( unit ).format( 'YYYY-MM-DD' ); + // Stats weeks run Monday-Sunday (see stats-date-label); isoWeek + // matches that regardless of the user's locale. + const key = parsed.startOf( unit === 'week' ? 'isoWeek' : unit ).format( 'YYYY-MM-DD' ); map.set( key, ( map.get( key ) ?? 0 ) + value ); } return map; From a71f21c21b3916702e206e218a0b8f85f533ac90 Mon Sep 17 00:00:00 2001 From: Dognose Date: Fri, 10 Jul 2026 15:29:23 +0800 Subject: [PATCH 10/30] Stats: show full month-year video chart labels like post details MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restore the 'MMM YYYY' month labels — the post details chart uses the same format and its second wrapped line is absorbed by the spacing below the chart, so match that spacing above the metric cards instead of shortening the label. Part of STATS-299. Co-Authored-By: Claude Fable 5 --- client/my-sites/stats/stats-video-detail/style.scss | 5 ++++- client/my-sites/stats/stats-video-detail/video-summary.tsx | 5 +---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/client/my-sites/stats/stats-video-detail/style.scss b/client/my-sites/stats/stats-video-detail/style.scss index caf1151447d5..7d5c7dc74081 100644 --- a/client/my-sites/stats/stats-video-detail/style.scss +++ b/client/my-sites/stats/stats-video-detail/style.scss @@ -155,7 +155,10 @@ $border-radius: 5px; // stylelint-disable-line scales/radii grid-template-columns: repeat(4, 1fr); gap: 16px; list-style: none; - margin: 16px 0 0; + // The chart's 42px-wide x-axis labels wrap onto a second line for + // 'MMM YYYY' month labels (as on the post details page); leave room for + // the overflowing line, matching the spacing below the post details chart. + margin: 40px 0 0; padding: 0; } diff --git a/client/my-sites/stats/stats-video-detail/video-summary.tsx b/client/my-sites/stats/stats-video-detail/video-summary.tsx index 676df83bffa6..d325ac3ecb97 100644 --- a/client/my-sites/stats/stats-video-detail/video-summary.tsx +++ b/client/my-sites/stats/stats-video-detail/video-summary.tsx @@ -212,11 +212,8 @@ export default function VideoSummary( { value, }; case 'month': - // The chart's x-axis labels are 42px wide; 'MMM YYYY' wraps - // onto a second line. The year is visible in the tooltip and - // the date heading. return { - period: start.format( 'MMM' ), + period: start.format( 'MMM YYYY' ), periodLabel: start.format( 'MMMM YYYY' ), startDate: bucket.key, value, From b6cca065b3f2b76340dfdd862c9944c41210fffb Mon Sep 17 00:00:00 2001 From: Dognose Date: Fri, 10 Jul 2026 16:28:54 +0800 Subject: [PATCH 11/30] Stats: stretch the video details header and embeds cards to full width The Card base carries margin: 0 auto; inside the page's flex column the auto inline margins shrink-to-fit and center the card instead of letting align-items: stretch apply, so the header and embeds cards rendered narrower than the chart section between them. Part of STATS-299. Co-Authored-By: Claude Fable 5 --- client/my-sites/stats/stats-video-detail/style.scss | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/client/my-sites/stats/stats-video-detail/style.scss b/client/my-sites/stats/stats-video-detail/style.scss index 7d5c7dc74081..3549bb2d4678 100644 --- a/client/my-sites/stats/stats-video-detail/style.scss +++ b/client/my-sites/stats/stats-video-detail/style.scss @@ -16,6 +16,13 @@ $border-radius: 5px; // stylelint-disable-line scales/radii display: flex; flex-direction: column; gap: 24px; + + // The Card base has `margin: 0 auto`; inside a flex column the auto + // inline margins absorb the free space and shrink the card to its content + // width instead of stretching, so zero them out. + > .card { + margin-inline: 0; + } } // The x-axis places labels at equal-slot centers (chart width / bar count), From 6872b8dbc1c514e782b20d3d29023d31cef333c9 Mon Sep 17 00:00:00 2001 From: Dognose Date: Fri, 10 Jul 2026 17:09:48 +0800 Subject: [PATCH 12/30] Stats: track the video details screen in the stats navigation history MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit recordCurrentScreen silently ignores screens missing from possibleBackLinks, and videodetails was never registered (a gap dating back to the summary-page implementation), so the breadcrumb trail treated the previous screen as current and dropped the Videos crumb — or showed only the Stats root on direct entry. Register videodetails like postDetails (tracked, never rendered as a crumb) and record it in a layout effect so it lands in the history before the breadcrumb-trail hook's passive effect reads it. Part of STATS-299. Co-Authored-By: Claude Fable 5 --- .../my-sites/stats/hooks/use-stats-navigation-history.ts | 3 +++ client/my-sites/stats/stats-video-detail/index.tsx | 7 +++++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/client/my-sites/stats/hooks/use-stats-navigation-history.ts b/client/my-sites/stats/hooks/use-stats-navigation-history.ts index 64778b5561ea..d376bece6eb0 100644 --- a/client/my-sites/stats/hooks/use-stats-navigation-history.ts +++ b/client/my-sites/stats/hooks/use-stats-navigation-history.ts @@ -30,6 +30,7 @@ const possibleBackLinks: { [ key: string ]: string | null } = { postList: '{adminUrl}edit.php', emailsummary: '/stats/{period}/emails/', postDetails: null, // Last item in the history, the text is not displayed anywhere but this is used to track the item in history stack. + videodetails: null, // Same as postDetails: tracked in the history stack, never rendered as a crumb. }; const SUPPORTED_QUERY_PARAMS: string[] = [ @@ -98,6 +99,7 @@ export const useStatsNavigationHistory = (): { text: string; url: string | null postList: translate( 'Post List' ), emailsummary: translate( 'Emails' ), postDetails: null, // Last item in the history, the text is not displayed anywhere but this is used to track the item in history stack. + videodetails: null, } ), [] ); @@ -213,6 +215,7 @@ export const useStatsBreadcrumbTrail = (): Array< { label: string; url: string | postList: translate( 'Post List' ), emailsummary: translate( 'Emails' ), postDetails: null, + videodetails: null, } ), [] ); diff --git a/client/my-sites/stats/stats-video-detail/index.tsx b/client/my-sites/stats/stats-video-detail/index.tsx index da495fda1164..b93751c76bc8 100644 --- a/client/my-sites/stats/stats-video-detail/index.tsx +++ b/client/my-sites/stats/stats-video-detail/index.tsx @@ -1,5 +1,5 @@ import { useTranslate } from 'i18n-calypso'; -import { useEffect } from 'react'; +import { useEffect, useLayoutEffect } from 'react'; import titlecase from 'to-title-case'; import QueryMedia from 'calypso/components/data/query-media'; import Main from 'calypso/my-sites/stats/components/stats-main'; @@ -38,7 +38,10 @@ export default function StatsVideoDetail( { postId, period, context }: StatsVide window.scrollTo( 0, 0 ); }, [] ); - useEffect( () => { + // Must run before useStatsBreadcrumbTrail's passive effect reads the + // navigation history, so the trail treats this screen (not the previous + // one) as the current entry to exclude. + useLayoutEffect( () => { recordCurrentScreen( 'videodetails', { queryParams: context.query, period: period.period, From 2a6dfa6b4960edbe63d4e56fd5a5447d6e656a8e Mon Sep 17 00:00:00 2001 From: Dognose Date: Fri, 10 Jul 2026 17:16:26 +0800 Subject: [PATCH 13/30] Stats: address review feedback on the video details page Include watch-time bucket keys in the chart bucket union, treat missing embeds data as loading so the empty state doesn't flash before the deferred request starts, and cover the statsVideo normalizer's empty-window response shapes with unit tests. Part of STATS-299. Co-Authored-By: Claude Fable 5 --- .../stats-video-detail/video-embeds-card.tsx | 12 ++++------ .../stats-video-detail/video-summary.tsx | 6 ++++- client/state/stats/lists/test/utils.js | 23 +++++++++++++++++++ 3 files changed, 33 insertions(+), 8 deletions(-) diff --git a/client/my-sites/stats/stats-video-detail/video-embeds-card.tsx b/client/my-sites/stats/stats-video-detail/video-embeds-card.tsx index 4b16701d112d..32f4d299ef04 100644 --- a/client/my-sites/stats/stats-video-detail/video-embeds-card.tsx +++ b/client/my-sites/stats/stats-video-detail/video-embeds-card.tsx @@ -3,10 +3,7 @@ import { useTranslate } from 'i18n-calypso'; import { useMemo } from 'react'; import QuerySiteStats from 'calypso/components/data/query-site-stats'; import { useSelector } from 'calypso/state'; -import { - getSiteStatsNormalizedData, - isRequestingSiteStatsForQuery, -} from 'calypso/state/stats/lists/selectors'; +import { getSiteStatsNormalizedData } from 'calypso/state/stats/lists/selectors'; import { getSelectedSiteId } from 'calypso/state/ui/selectors'; import StatsModulePlaceholder from '../stats-module/placeholder'; @@ -23,9 +20,10 @@ export default function VideoEmbedsCard( { postId }: { postId: number } ) { ( state ) => getSiteStatsNormalizedData( state, siteId, 'statsVideo', query ) as VideoEmbedsData | null ); - const isLoading = useSelector( ( state ) => - siteId ? isRequestingSiteStatsForQuery( state, siteId, 'statsVideo', query ) && ! data : ! data - ); + // QuerySiteStats defers its initial request, so the requesting flag can be + // false on first render; treat missing data as loading to avoid flashing + // the empty state. + const isLoading = ! data; const pages = data?.pages ?? []; diff --git a/client/my-sites/stats/stats-video-detail/video-summary.tsx b/client/my-sites/stats/stats-video-detail/video-summary.tsx index d325ac3ecb97..e20ad78c6f7c 100644 --- a/client/my-sites/stats/stats-video-detail/video-summary.tsx +++ b/client/my-sites/stats/stats-video-detail/video-summary.tsx @@ -180,7 +180,11 @@ export default function VideoSummary( { const watchTimeByBucket = toBucketMap( watchTimeData?.data ); const keys = Array.from( - new Set( [ ...playsByBucket.keys(), ...impressionsByBucket.keys() ] ) + new Set( [ + ...playsByBucket.keys(), + ...impressionsByBucket.keys(), + ...watchTimeByBucket.keys(), + ] ) ).sort(); return keys.map( ( key ) => { diff --git a/client/state/stats/lists/test/utils.js b/client/state/stats/lists/test/utils.js index 5f5e5eab0ff1..e7015a940a5d 100644 --- a/client/state/stats/lists/test/utils.js +++ b/client/state/stats/lists/test/utils.js @@ -1636,6 +1636,29 @@ describe( 'utils', () => { expect( normalizers.statsVideo() ).toBeNull(); } ); + test( 'should return empty data when the endpoint reports an empty window', () => { + // With no rows in the requested window, the endpoint returns a + // single object instead of the usual [ date, value ] tuples. + expect( + normalizers.statsVideo( { + data: { date: '7-10', p: '0' }, + pages: [], + } ) + ).toEqual( { pages: [], data: [] } ); + } ); + + test( 'should skip non-tuple entries in the data array', () => { + expect( + normalizers.statsVideo( { + data: [ [ '2016-11-12', 1 ], { date: '7-10', p: '0' } ], + pages: [], + } ) + ).toEqual( { + pages: [], + data: [ { period: '2016-11-12', value: 1 } ], + } ); + } ); + test( 'should return a properly parsed data array', () => { expect( normalizers.statsVideo( { From a70a3cfa557fe7df6ccff070023d2493284b30b0 Mon Sep 17 00:00:00 2001 From: Dognose Date: Fri, 10 Jul 2026 22:11:23 +0800 Subject: [PATCH 14/30] Stats: degrade the video details page gracefully in Odyssey Stats The Odyssey stats-app proxy (Jetpack stats-admin REST controller) has no media or settings routes, so those requests 404 in wp-admin. Gate the shared stats shell's QuerySiteSettings the same way as QuerySiteFeatures (the request 404s on every Odyssey stats page today), and hide the video details header card in Odyssey while its media item is unavailable instead of showing a loading state forever. The media request is still made, so the card and the derived retention rate light up automatically once the proxy learns the media route. Part of STATS-299. Co-Authored-By: Claude Fable 5 --- client/my-sites/stats/components/stats-main/index.tsx | 3 ++- .../stats/stats-video-detail/video-details-card.tsx | 8 ++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/client/my-sites/stats/components/stats-main/index.tsx b/client/my-sites/stats/components/stats-main/index.tsx index 6d608ea7a796..4fb24ebb37b3 100644 --- a/client/my-sites/stats/components/stats-main/index.tsx +++ b/client/my-sites/stats/components/stats-main/index.tsx @@ -138,7 +138,8 @@ export default function StatsMain( { return (
{ ! isWPAdminAndNotSimpleSite && } - + { /* The Odyssey stats-app proxy has no settings route, so the request would 404. */ } + { ! isWPAdminAndNotSimpleSite && } Date: Fri, 10 Jul 2026 23:37:21 +0800 Subject: [PATCH 15/30] Stats: drop the video details page's hard dependency on the media API The header card and the retention rate metric both required the media item (title, date, thumbnail, and duration), but the Odyssey stats-app proxy has no media route, leaving the page degraded in wp-admin until a Jetpack release ships one. Remove the header card and the retention card/series so the page works identically everywhere, and keep the media request only as a best-effort enhancement for the breadcrumb title (matching the legacy page), falling back to a static label. Revert this commit to restore both once Automattic/jetpack#50412 (or the retention series from STATS-312) is available. Co-Authored-By: Claude Fable 5 --- .../stats/stats-video-detail/index.tsx | 17 ++- .../stats/stats-video-detail/style.scss | 112 +----------------- .../stats-video-detail/video-details-card.tsx | 89 -------------- .../stats-video-detail/video-metric-tabs.tsx | 11 +- .../stats-video-detail/video-summary.tsx | 59 ++------- 5 files changed, 24 insertions(+), 264 deletions(-) delete mode 100644 client/my-sites/stats/stats-video-detail/video-details-card.tsx diff --git a/client/my-sites/stats/stats-video-detail/index.tsx b/client/my-sites/stats/stats-video-detail/index.tsx index b93751c76bc8..284aba7a7432 100644 --- a/client/my-sites/stats/stats-video-detail/index.tsx +++ b/client/my-sites/stats/stats-video-detail/index.tsx @@ -11,10 +11,11 @@ import { useSelector } from 'calypso/state'; import getMediaItem from 'calypso/state/selectors/get-media-item'; import { getSelectedSiteId } from 'calypso/state/ui/selectors'; import PageViewTracker from '../stats-page-view-tracker'; -import VideoDetailsCard, { VideoMediaItem } from './video-details-card'; import VideoEmbedsCard from './video-embeds-card'; import VideoSummary from './video-summary'; +import './style.scss'; + interface StatsVideoDetailProps { postId: number; period: { @@ -28,8 +29,11 @@ interface StatsVideoDetailProps { export default function StatsVideoDetail( { postId, period, context }: StatsVideoDetailProps ) { const translate = useTranslate(); const siteId = useSelector( getSelectedSiteId ); + // The media item is a best-effort enhancement for the breadcrumb title: + // the request 404s in Odyssey Stats (the stats-app proxy has no media + // route), in which case the static label below is used instead. const media = useSelector( - ( state ) => getMediaItem( state, siteId, postId ) as VideoMediaItem | null + ( state ) => getMediaItem( state, siteId, postId ) as { title?: string } | null ); const breadcrumbTrail = useStatsBreadcrumbTrail(); const statType = context.query.statType ?? null; @@ -48,7 +52,7 @@ export default function StatsVideoDetail( { postId, period, context }: StatsVide } ); }, [ context.query, period.period ] ); - const title = media?.title || translate( 'Video', { textOnly: true } ); + const title = media?.title || translate( 'Video details', { textOnly: true } ); return (
- - +
diff --git a/client/my-sites/stats/stats-video-detail/style.scss b/client/my-sites/stats/stats-video-detail/style.scss index 3549bb2d4678..700caaf32800 100644 --- a/client/my-sites/stats/stats-video-detail/style.scss +++ b/client/my-sites/stats/stats-video-detail/style.scss @@ -3,15 +3,6 @@ @import "@automattic/components/src/styles/typography"; @import "@automattic/components/src/styles/mixins"; -$card-padding: 24px; -$border-radius: 5px; // stylelint-disable-line scales/radii - -@keyframes stats-video-details-card-shine { - to { - background-position-x: -200%; - } -} - .stats-video-detail { display: flex; flex-direction: column; @@ -33,88 +24,6 @@ $border-radius: 5px; // stylelint-disable-line scales/radii justify-content: space-around; } -.stats-video-details-card { - border-color: var(--studio-gray-5); - border-radius: $border-radius; - display: grid; - font-family: $font-sf-pro-text; - font-size: $font-body-small; - grid-template-columns: minmax(10px, 1.5fr) minmax(0, auto); - grid-template-rows: 26px auto; - grid-template-areas: - "heading thumbnail" - "info thumbnail"; - margin-bottom: 0; - max-width: 100%; - max-height: 255px; - padding: $card-padding; - gap: $card-padding; - - &.card::after { - display: none; - } - - &.is-loading { - .stats-video-details-card__heading, - .stats-video-details-card__info { - position: relative; - - &::after { - content: ""; - position: absolute; - inset: 0; - background: linear-gradient(94deg, var(--studio-gray-5) 8%, var(--studio-gray-0) 18%, var(--studio-gray-5) 33%); - border-radius: 4px; - background-size: 200% 100%; - animation: 1.5s stats-video-details-card-shine linear infinite; - } - } - } -} - -.stats-video-details-card__heading { - color: var(--studio-gray-100); - font-family: $font-sf-pro-display; - font-size: $font-size-header-small; - font-weight: 500; - grid-area: heading; - line-height: 1.3; -} - -.stats-video-details-card__info { - grid-area: info; - - .stats-video-details-card__title { - @include stats-section-header; - display: block; - margin-bottom: 4px; - min-height: 40px; - } - - .stats-video-details-card__date { - color: var(--studio-gray-60); - font-weight: 400; - line-height: 1.5; - } -} - -.stats-video-details-card__thumbnail-link { - grid-area: thumbnail; - display: block; - height: calc(100% + #{$card-padding * 2}); - margin: -1 * $card-padding -1 * $card-padding -1 * $card-padding 0; - max-width: 300px; - width: calc(100% + #{$card-padding}); -} - -.stats-video-details-card__thumbnail { - border-radius: 0 $border-radius $border-radius 0; - display: block; - height: 100%; - object-fit: cover; - width: 100%; -} - .stats-video-embeds-card { border-color: var(--studio-gray-5); border-radius: 5px; /* stylelint-disable-line scales/radii */ @@ -159,7 +68,7 @@ $border-radius: 5px; // stylelint-disable-line scales/radii .stats-video-metric-tabs { display: grid; - grid-template-columns: repeat(4, 1fr); + grid-template-columns: repeat(3, 1fr); gap: 16px; list-style: none; // The chart's 42px-wide x-axis labels wrap onto a second line for @@ -225,23 +134,4 @@ $border-radius: 5px; // stylelint-disable-line scales/radii .stats-video-metric-tabs { grid-template-columns: 1fr; } - - .stats-video-details-card { - gap: 12px 0; - grid-template-areas: - "heading heading" - "info thumbnail"; - max-height: initial; - } - - .stats-video-details-card__thumbnail-link { - height: 84px; - margin: 0; - width: 84px; - max-width: initial; - } - - .stats-video-details-card__thumbnail { - border-radius: $border-radius; - } } diff --git a/client/my-sites/stats/stats-video-detail/video-details-card.tsx b/client/my-sites/stats/stats-video-detail/video-details-card.tsx deleted file mode 100644 index b841cddf45f1..000000000000 --- a/client/my-sites/stats/stats-video-detail/video-details-card.tsx +++ /dev/null @@ -1,89 +0,0 @@ -import config from '@automattic/calypso-config'; -import { Card } from '@automattic/components'; -import { useTranslate } from 'i18n-calypso'; -import { useLocalizedMoment } from 'calypso/components/localized-moment'; -import { useSelector } from 'calypso/state'; -import { getSiteSlug } from 'calypso/state/sites/selectors'; -import getSiteAdminUrl from 'calypso/state/sites/selectors/get-site-admin-url'; -import { getSelectedSiteId } from 'calypso/state/ui/selectors'; - -import './style.scss'; - -export interface VideoMediaItem { - ID: number; - title?: string; - date?: string; - /** Video duration in seconds. */ - length?: number; - thumbnails?: Record< string, string >; -} - -const THUMBNAIL_SIZES = [ 'fmt_hd', 'fmt_dvd', 'fmt_std' ]; - -export default function VideoDetailsCard( { - media, - mediaId, -}: { - media: VideoMediaItem | null; - mediaId: number; -} ) { - const translate = useTranslate(); - const moment = useLocalizedMoment(); - const siteId = useSelector( getSelectedSiteId ); - const siteSlug = useSelector( ( state ) => getSiteSlug( state, siteId ) ); - const adminBaseUrl = useSelector( ( state ) => getSiteAdminUrl( state, siteId ) ); - - const isLoading = ! media; - const isOdyssey = config.isEnabled( 'is_odyssey' ); - - // The Odyssey stats-app proxy has no media route yet, so the media request - // 404s and the card would shimmer forever. Hide it until the data arrives — - // once the proxy learns the route, the card simply starts appearing. - if ( isOdyssey && ! media ) { - return null; - } - - const mediaLibraryUrl = isOdyssey - ? adminBaseUrl && `${ adminBaseUrl }upload.php?item=${ mediaId }` - : siteSlug && `/media/${ siteSlug }/${ mediaId }`; - - const thumbnailSize = THUMBNAIL_SIZES.find( ( size ) => media?.thumbnails?.[ size ] ); - const thumbnailUrl = thumbnailSize && media?.thumbnails?.[ thumbnailSize ]; - - const classes = isLoading ? 'stats-video-details-card is-loading' : 'stats-video-details-card'; - - return ( - -

{ translate( 'Video details' ) }

-
-
{ media?.title }
- { ( isLoading || media?.date ) && ( -
- { media?.date && - translate( 'Published %(date)s', { - args: { date: moment( media.date ).format( 'll' ) }, - comment: 'Date when the video was uploaded.', - } ) } -
- ) } -
- { thumbnailUrl && ( - - { - - ) } -
- ); -} diff --git a/client/my-sites/stats/stats-video-detail/video-metric-tabs.tsx b/client/my-sites/stats/stats-video-detail/video-metric-tabs.tsx index 53a7908ddfbb..6c889746bc03 100644 --- a/client/my-sites/stats/stats-video-detail/video-metric-tabs.tsx +++ b/client/my-sites/stats/stats-video-detail/video-metric-tabs.tsx @@ -1,10 +1,10 @@ import { Gridicon } from '@automattic/components'; import { formatNumber, formatNumberCompact } from '@automattic/number-formatters'; -import { Icon, seen, video, dashboard } from '@wordpress/icons'; +import { Icon, seen, video } from '@wordpress/icons'; import clsx from 'clsx'; import { useTranslate } from 'i18n-calypso'; -export type VideoStatType = 'views' | 'impressions' | 'watch_time' | 'retention_rate'; +export type VideoStatType = 'views' | 'impressions' | 'watch_time'; export type VideoMetricValues = Record< VideoStatType, number | null >; @@ -18,8 +18,6 @@ function formatValue( statType: VideoStatType, value: number | null ) { return value > 1 ? formatNumber( value, { decimals: 1 } ) : `< ${ formatNumber( 1, { decimals: 1 } ) }`; - case 'retention_rate': - return `${ formatNumber( value, { decimals: 1 } ) }%`; default: return formatNumberCompact( value ); } @@ -52,11 +50,6 @@ export default function VideoMetricTabs( { label: translate( 'Hours watched', { textOnly: true } ), icon: , }, - { - key: 'retention_rate', - label: translate( 'Retention rate', { textOnly: true } ), - icon: , - }, ]; return ( diff --git a/client/my-sites/stats/stats-video-detail/video-summary.tsx b/client/my-sites/stats/stats-video-detail/video-summary.tsx index e20ad78c6f7c..c23418810eb1 100644 --- a/client/my-sites/stats/stats-video-detail/video-summary.tsx +++ b/client/my-sites/stats/stats-video-detail/video-summary.tsx @@ -28,9 +28,7 @@ type ApiPeriod = 'month' | 'year'; // The endpoint only recognizes statType=watch_time|impressions; `views` falls // back to the plays column, which is the same metric the Videos module and the -// All videos page label "Views". There is no retention series — retention is -// derived below with the same formula the video-plays complete_stats endpoint -// uses: (watch time / plays) / video duration. +// All videos page label "Views". const FETCHED_STAT_TYPES = [ 'views', 'impressions', 'watch_time' ] as const; interface ChartRecord { @@ -45,14 +43,13 @@ interface BucketRecord { plays: number; impressions: number; watchTime: number; - retention: number; } interface VideoSummaryData { data?: Array< { period: string; value: number } >; } -const STAT_TYPES: VideoStatType[] = [ 'views', 'impressions', 'watch_time', 'retention_rate' ]; +const STAT_TYPES: VideoStatType[] = [ 'views', 'impressions', 'watch_time' ]; function isVideoStatType( value: string | null ): value is VideoStatType { return !! value && ( STAT_TYPES as string[] ).includes( value ); @@ -64,32 +61,17 @@ function metricOfBucket( bucket: BucketRecord, type: VideoStatType ): number { return bucket.impressions; case 'watch_time': return bucket.watchTime; - case 'retention_rate': - return bucket.retention; default: return bucket.plays; } } -function computeRetention( - watchTimeHours: number, - plays: number, - videoDuration: number | null -): number { - if ( ! videoDuration || plays <= 0 ) { - return 0; - } - return ( ( watchTimeHours * 3600 ) / plays / videoDuration ) * 100; -} - export default function VideoSummary( { postId, initialStatType, - videoDuration, }: { postId: number; initialStatType: string | null; - videoDuration: number | null; } ) { const translate = useTranslate(); const moment = useLocalizedMoment(); @@ -187,18 +169,13 @@ export default function VideoSummary( { ] ) ).sort(); - return keys.map( ( key ) => { - const plays = playsByBucket.get( key ) ?? 0; - const watchTime = watchTimeByBucket.get( key ) ?? 0; - return { - key, - plays, - impressions: impressionsByBucket.get( key ) ?? 0, - watchTime, - retention: computeRetention( watchTime, plays, videoDuration ), - }; - } ); - }, [ playsData, impressionsData, watchTimeData, uiPeriod, apiPeriod, videoDuration, moment ] ); + return keys.map( ( key ) => ( { + key, + plays: playsByBucket.get( key ) ?? 0, + impressions: impressionsByBucket.get( key ) ?? 0, + watchTime: watchTimeByBucket.get( key ) ?? 0, + } ) ); + }, [ playsData, impressionsData, watchTimeData, uiPeriod, apiPeriod, moment ] ); const chartData: ChartRecord[] = useMemo( () => @@ -244,26 +221,17 @@ export default function VideoSummary( { const selected = selectedRecord ?? ( chartData.length ? chartData[ chartData.length - 1 ] : null ); - // Card totals cover the whole window shown in the chart. Retention is not - // summable, so the total uses the same canonical formula over the sums. + // Card totals cover the whole window shown in the chart. const metricValues: VideoMetricValues = useMemo( () => { const sum = ( data: VideoSummaryData | null, pick: ( bucket: BucketRecord ) => number ) => data ? buckets.reduce( ( total, bucket ) => total + pick( bucket ), 0 ) : null; - const playsTotal = sum( playsData, ( bucket ) => bucket.plays ); - const watchTimeTotal = sum( watchTimeData, ( bucket ) => bucket.watchTime ); - const retentionTotal = - playsTotal !== null && watchTimeTotal !== null && videoDuration && playsTotal > 0 - ? computeRetention( watchTimeTotal, playsTotal, videoDuration ) - : null; - return { - views: playsTotal, + views: sum( playsData, ( bucket ) => bucket.plays ), impressions: sum( impressionsData, ( bucket ) => bucket.impressions ), - watch_time: watchTimeTotal, - retention_rate: retentionTotal, + watch_time: sum( watchTimeData, ( bucket ) => bucket.watchTime ), }; - }, [ buckets, playsData, impressionsData, watchTimeData, videoDuration ] ); + }, [ buckets, playsData, impressionsData, watchTimeData ] ); const selectPeriod = ( newPeriod: UiPeriod ) => () => { setUiPeriod( newPeriod ); @@ -296,7 +264,6 @@ export default function VideoSummary( { views: translate( 'Views', { textOnly: true } ), impressions: translate( 'Impressions', { textOnly: true } ), watch_time: translate( 'Hours watched', { textOnly: true } ), - retention_rate: translate( 'Retention rate', { textOnly: true } ), }; const periods: Array< { id: UiPeriod; label: string } > = [ From 6bc09e42d6c51707692443fce4044c1da47c8d93 Mon Sep 17 00:00:00 2001 From: Dognose Date: Fri, 10 Jul 2026 23:43:43 +0800 Subject: [PATCH 16/30] Stats: render video details media-derived UI only when the data exists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restore the header card and the Retention rate card, but field-adaptive instead of hard-required: the header card renders once the media item loads (never in Odyssey until its proxy has a media route), the date and thumbnail only when present, and the Retention rate card and series only when the video duration is available — the metric row divides evenly whether three or four cards render. The breadcrumb keeps the media title with a static fallback. Part of STATS-299. Co-Authored-By: Claude Fable 5 --- .../stats/stats-video-detail/index.tsx | 15 +- .../stats/stats-video-detail/style.scss | 128 +++++++++++++++++- .../stats-video-detail/video-details-card.tsx | 89 ++++++++++++ .../stats-video-detail/video-metric-tabs.tsx | 26 +++- .../stats-video-detail/video-summary.tsx | 79 ++++++++--- 5 files changed, 303 insertions(+), 34 deletions(-) create mode 100644 client/my-sites/stats/stats-video-detail/video-details-card.tsx diff --git a/client/my-sites/stats/stats-video-detail/index.tsx b/client/my-sites/stats/stats-video-detail/index.tsx index 284aba7a7432..90cef9299673 100644 --- a/client/my-sites/stats/stats-video-detail/index.tsx +++ b/client/my-sites/stats/stats-video-detail/index.tsx @@ -11,11 +11,10 @@ import { useSelector } from 'calypso/state'; import getMediaItem from 'calypso/state/selectors/get-media-item'; import { getSelectedSiteId } from 'calypso/state/ui/selectors'; import PageViewTracker from '../stats-page-view-tracker'; +import VideoDetailsCard, { VideoMediaItem } from './video-details-card'; import VideoEmbedsCard from './video-embeds-card'; import VideoSummary from './video-summary'; -import './style.scss'; - interface StatsVideoDetailProps { postId: number; period: { @@ -29,11 +28,8 @@ interface StatsVideoDetailProps { export default function StatsVideoDetail( { postId, period, context }: StatsVideoDetailProps ) { const translate = useTranslate(); const siteId = useSelector( getSelectedSiteId ); - // The media item is a best-effort enhancement for the breadcrumb title: - // the request 404s in Odyssey Stats (the stats-app proxy has no media - // route), in which case the static label below is used instead. const media = useSelector( - ( state ) => getMediaItem( state, siteId, postId ) as { title?: string } | null + ( state ) => getMediaItem( state, siteId, postId ) as VideoMediaItem | null ); const breadcrumbTrail = useStatsBreadcrumbTrail(); const statType = context.query.statType ?? null; @@ -75,7 +71,12 @@ export default function StatsVideoDetail( { postId, period, context }: StatsVide id="my-stats-content" className="stats-summary-view stats-summary__positioned stats-video-detail" > - + + diff --git a/client/my-sites/stats/stats-video-detail/style.scss b/client/my-sites/stats/stats-video-detail/style.scss index 700caaf32800..289f2c6d0ab0 100644 --- a/client/my-sites/stats/stats-video-detail/style.scss +++ b/client/my-sites/stats/stats-video-detail/style.scss @@ -3,6 +3,15 @@ @import "@automattic/components/src/styles/typography"; @import "@automattic/components/src/styles/mixins"; +$card-padding: 24px; +$border-radius: 5px; // stylelint-disable-line scales/radii + +@keyframes stats-video-details-card-shine { + to { + background-position-x: -200%; + } +} + .stats-video-detail { display: flex; flex-direction: column; @@ -24,6 +33,88 @@ justify-content: space-around; } +.stats-video-details-card { + border-color: var(--studio-gray-5); + border-radius: $border-radius; + display: grid; + font-family: $font-sf-pro-text; + font-size: $font-body-small; + grid-template-columns: minmax(10px, 1.5fr) minmax(0, auto); + grid-template-rows: 26px auto; + grid-template-areas: + "heading thumbnail" + "info thumbnail"; + margin-bottom: 0; + max-width: 100%; + max-height: 255px; + padding: $card-padding; + gap: $card-padding; + + &.card::after { + display: none; + } + + &.is-loading { + .stats-video-details-card__heading, + .stats-video-details-card__info { + position: relative; + + &::after { + content: ""; + position: absolute; + inset: 0; + background: linear-gradient(94deg, var(--studio-gray-5) 8%, var(--studio-gray-0) 18%, var(--studio-gray-5) 33%); + border-radius: 4px; + background-size: 200% 100%; + animation: 1.5s stats-video-details-card-shine linear infinite; + } + } + } +} + +.stats-video-details-card__heading { + color: var(--studio-gray-100); + font-family: $font-sf-pro-display; + font-size: $font-size-header-small; + font-weight: 500; + grid-area: heading; + line-height: 1.3; +} + +.stats-video-details-card__info { + grid-area: info; + + .stats-video-details-card__title { + @include stats-section-header; + display: block; + margin-bottom: 4px; + min-height: 40px; + } + + .stats-video-details-card__date { + color: var(--studio-gray-60); + font-weight: 400; + line-height: 1.5; + } +} + +.stats-video-details-card__thumbnail-link { + grid-area: thumbnail; + display: block; + height: calc(100% + #{$card-padding * 2}); + margin: -1 * $card-padding -1 * $card-padding -1 * $card-padding 0; + max-width: 300px; + width: calc(100% + #{$card-padding}); +} + +.stats-video-details-card__thumbnail { + border-radius: 0 $border-radius $border-radius 0; + display: block; + height: 100%; + object-fit: cover; + width: 100%; +} + .stats-video-embeds-card { border-color: var(--studio-gray-5); border-radius: 5px; /* stylelint-disable-line scales/radii */ @@ -67,8 +158,10 @@ } .stats-video-metric-tabs { - display: grid; - grid-template-columns: repeat(3, 1fr); + // Flex (not a fixed-column grid) so the row divides evenly whether three + // or four cards render — the retention card is omitted when the video + // duration is unavailable. + display: flex; gap: 16px; list-style: none; // The chart's 42px-wide x-axis labels wrap onto a second line for @@ -79,7 +172,9 @@ } .stats-video-metric-tabs__item { + flex: 1 1 0; margin: 0; + min-width: 0; } .stats-video-metric-tabs__tab { @@ -126,12 +221,35 @@ @media (max-width: $break-medium) { .stats-video-metric-tabs { - grid-template-columns: repeat(2, 1fr); + flex-wrap: wrap; + } + + .stats-video-metric-tabs__item { + flex-basis: calc(50% - 8px); } } @media (max-width: $break-small) { - .stats-video-metric-tabs { - grid-template-columns: 1fr; + .stats-video-metric-tabs__item { + flex-basis: 100%; + } + + .stats-video-details-card { + gap: 12px 0; + grid-template-areas: + "heading heading" + "info thumbnail"; + max-height: initial; + } + + .stats-video-details-card__thumbnail-link { + height: 84px; + margin: 0; + width: 84px; + max-width: initial; + } + + .stats-video-details-card__thumbnail { + border-radius: $border-radius; } } diff --git a/client/my-sites/stats/stats-video-detail/video-details-card.tsx b/client/my-sites/stats/stats-video-detail/video-details-card.tsx new file mode 100644 index 000000000000..b841cddf45f1 --- /dev/null +++ b/client/my-sites/stats/stats-video-detail/video-details-card.tsx @@ -0,0 +1,89 @@ +import config from '@automattic/calypso-config'; +import { Card } from '@automattic/components'; +import { useTranslate } from 'i18n-calypso'; +import { useLocalizedMoment } from 'calypso/components/localized-moment'; +import { useSelector } from 'calypso/state'; +import { getSiteSlug } from 'calypso/state/sites/selectors'; +import getSiteAdminUrl from 'calypso/state/sites/selectors/get-site-admin-url'; +import { getSelectedSiteId } from 'calypso/state/ui/selectors'; + +import './style.scss'; + +export interface VideoMediaItem { + ID: number; + title?: string; + date?: string; + /** Video duration in seconds. */ + length?: number; + thumbnails?: Record< string, string >; +} + +const THUMBNAIL_SIZES = [ 'fmt_hd', 'fmt_dvd', 'fmt_std' ]; + +export default function VideoDetailsCard( { + media, + mediaId, +}: { + media: VideoMediaItem | null; + mediaId: number; +} ) { + const translate = useTranslate(); + const moment = useLocalizedMoment(); + const siteId = useSelector( getSelectedSiteId ); + const siteSlug = useSelector( ( state ) => getSiteSlug( state, siteId ) ); + const adminBaseUrl = useSelector( ( state ) => getSiteAdminUrl( state, siteId ) ); + + const isLoading = ! media; + const isOdyssey = config.isEnabled( 'is_odyssey' ); + + // The Odyssey stats-app proxy has no media route yet, so the media request + // 404s and the card would shimmer forever. Hide it until the data arrives — + // once the proxy learns the route, the card simply starts appearing. + if ( isOdyssey && ! media ) { + return null; + } + + const mediaLibraryUrl = isOdyssey + ? adminBaseUrl && `${ adminBaseUrl }upload.php?item=${ mediaId }` + : siteSlug && `/media/${ siteSlug }/${ mediaId }`; + + const thumbnailSize = THUMBNAIL_SIZES.find( ( size ) => media?.thumbnails?.[ size ] ); + const thumbnailUrl = thumbnailSize && media?.thumbnails?.[ thumbnailSize ]; + + const classes = isLoading ? 'stats-video-details-card is-loading' : 'stats-video-details-card'; + + return ( + +

{ translate( 'Video details' ) }

+
+
{ media?.title }
+ { ( isLoading || media?.date ) && ( +
+ { media?.date && + translate( 'Published %(date)s', { + args: { date: moment( media.date ).format( 'll' ) }, + comment: 'Date when the video was uploaded.', + } ) } +
+ ) } +
+ { thumbnailUrl && ( + + { + + ) } +
+ ); +} diff --git a/client/my-sites/stats/stats-video-detail/video-metric-tabs.tsx b/client/my-sites/stats/stats-video-detail/video-metric-tabs.tsx index 6c889746bc03..8cb346f5dd56 100644 --- a/client/my-sites/stats/stats-video-detail/video-metric-tabs.tsx +++ b/client/my-sites/stats/stats-video-detail/video-metric-tabs.tsx @@ -1,12 +1,19 @@ import { Gridicon } from '@automattic/components'; import { formatNumber, formatNumberCompact } from '@automattic/number-formatters'; -import { Icon, seen, video } from '@wordpress/icons'; +import { Icon, seen, video, dashboard } from '@wordpress/icons'; import clsx from 'clsx'; import { useTranslate } from 'i18n-calypso'; -export type VideoStatType = 'views' | 'impressions' | 'watch_time'; +export type VideoStatType = 'views' | 'impressions' | 'watch_time' | 'retention_rate'; -export type VideoMetricValues = Record< VideoStatType, number | null >; +// `null` renders a loading placeholder; `undefined` omits the card entirely +// (used for retention when the video duration is unavailable). +export interface VideoMetricValues { + views: number | null; + impressions: number | null; + watch_time: number | null; + retention_rate?: number | null; +} function formatValue( statType: VideoStatType, value: number | null ) { if ( value === null ) { @@ -18,6 +25,8 @@ function formatValue( statType: VideoStatType, value: number | null ) { return value > 1 ? formatNumber( value, { decimals: 1 } ) : `< ${ formatNumber( 1, { decimals: 1 } ) }`; + case 'retention_rate': + return `${ formatNumber( value, { decimals: 1 } ) }%`; default: return formatNumberCompact( value ); } @@ -50,11 +59,18 @@ export default function VideoMetricTabs( { label: translate( 'Hours watched', { textOnly: true } ), icon: , }, + { + key: 'retention_rate', + label: translate( 'Retention rate', { textOnly: true } ), + icon: , + }, ]; + const availableTabs = tabs.filter( ( tab ) => values[ tab.key ] !== undefined ); + return (