diff --git a/client/my-sites/stats/components/stats-main/index.tsx b/client/my-sites/stats/components/stats-main/index.tsx index 6d608ea7a796..f331d4970eb9 100644 --- a/client/my-sites/stats/components/stats-main/index.tsx +++ b/client/my-sites/stats/components/stats-main/index.tsx @@ -76,7 +76,7 @@ function StatsBreadcrumbs( { items }: { items: BreadcrumbItem[] } ) { { STATS_HEADER_TITLE } ) } { restItems.map( ( item, index ) => ( - + / { item.to ? ( { ! isWPAdminAndNotSimpleSite && } - + { /* The Odyssey stats-app proxy has no settings route, so the request would 404. */ } + { ! isWPAdminAndNotSimpleSite && } 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' @@ -335,8 +339,21 @@ export function summary( context, next ) { ); const period = rangeOfPeriod( activeFilter.period, date ); - 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' ] ); @@ -356,7 +373,6 @@ export function summary( context, next ) { dateRange={ dateRange } context={ context } period={ period } - { ...extraProps } /> ); 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 d2d4eea762fa..f7a6a54af2e0 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[] = [ @@ -158,6 +159,7 @@ export const useStatsNavigationHistory = ( 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, } ), [] ); @@ -288,6 +290,7 @@ export const useStatsBreadcrumbTrail = ( postList: translate( 'Post List' ), emailsummary: translate( 'Emails' ), postDetails: null, + videodetails: null, } ), [] ); 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 new file mode 100644 index 000000000000..95b4832eb6a6 --- /dev/null +++ b/client/my-sites/stats/stats-video-detail/index.tsx @@ -0,0 +1,120 @@ +import { useTranslate } from 'i18n-calypso'; +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'; +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 { getSiteStatsNormalizedData } from 'calypso/state/stats/lists/selectors'; +import { getSelectedSiteId } from 'calypso/state/ui/selectors'; +import PageViewTracker from '../stats-page-view-tracker'; +import VideoDetailsCard from './video-details-card'; +import VideoEmbedsCard from './video-embeds-card'; +import VideoSummary from './video-summary'; + +import './style.scss'; + +interface StatsVideoDetailProps { + postId: number; + period: { + period: string; + }; + context: { + query: Record< string, string >; + }; +} + +interface VideoMediaItem { + title?: string; + date?: string; + /** Video duration in seconds. */ + length?: number; +} + +interface VideoStatsPost { + post_title?: string; + post_date?: string; +} + +export default function StatsVideoDetail( { postId, period, context }: StatsVideoDetailProps ) { + const translate = useTranslate(); + const siteId = useSelector( getSelectedSiteId ); + // The video title and upload date come from the attachment post included in + // the statsVideo response — available in both Calypso and Odyssey (the + // stats-app proxy forwards stats routes). Mirrors VideoSummary's default + // Days/Weeks query, which is always fetched first. + const videoStatsData = useSelector( + ( state ) => + getSiteStatsNormalizedData( state, siteId, 'statsVideo', { + postId, + statType: 'views', + period: 'month', + } ) as { post?: VideoStatsPost | null } | null + ); + const videoStatsPost = videoStatsData?.post ?? null; + // The media item is only needed for the video duration (retention rate); + // the request 404s harmlessly in Odyssey, where the stats-app proxy has no + // media route, and the retention card is simply omitted. + 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 ); + }, [] ); + + // 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, + } ); + }, [ context.query, period.period ] ); + + const videoTitle = videoStatsPost?.post_title || media?.title || null; + const videoDate = videoStatsPost?.post_date || media?.date || null; + // Loading = neither source has responded yet; once statsVideo answers, a + // missing post means there is genuinely no title and the card hides. + const isVideoInfoLoading = ! videoStatsData && ! media; + + return ( +
( { + label: item.label, + to: item.url ?? undefined, + } ) ), + { label: videoTitle || translate( 'Video details', { textOnly: true } ) }, + ] } + > + ${ 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..362d782885b6 --- /dev/null +++ b/client/my-sites/stats/stats-video-detail/style.scss @@ -0,0 +1,253 @@ +@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; + + // 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), +// 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-summary { + .stats__period-header { + justify-content: space-between; + + // Keep the period switcher pinned to the right edge when the header + // wraps on narrow widths (the base style centers wrapped content). + .segmented-control { + margin-inline-start: auto; + } + } + + .is-summary-chart { + margin-top: 24px; + // Both the loading placeholder (228px) and the chart (200px bars + + // 28px x-axis) render inside; pin the height and hide the chart while + // loading (as .is-chart-tabs.is-loading does for the traffic chart) + // so the two never stack and resize the page. + min-height: 228px; + + &.is-loading .chart { + display: none; + } + } +} + +.stats-video-details-card { + border-color: var(--studio-gray-5); + border-radius: $border-radius; + display: flex; + flex-direction: column; + font-family: $font-sf-pro-text; + font-size: $font-body-small; + margin-bottom: 0; + max-width: 100%; + padding: $card-padding; + gap: $card-padding; + + &.card::after { + display: none; + } +} + +.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; + line-height: 1.3; + // wp-admin core styles give h4 a `margin: 1.33em 0` in Odyssey Stats. + margin: 0; +} + +.stats-video-details-card.is-loading { + .stats-video-details-card__title, + .stats-video-details-card__date { + position: relative; + max-width: 480px; + + &::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__title { + min-height: 32px; + } + + .stats-video-details-card__date { + min-height: 20px; + margin-top: 4px; + } +} + +.stats-video-details-card__info { + .stats-video-details-card__title { + @include stats-section-header; + display: block; + margin-bottom: 4px; + } + + .stats-video-details-card__date { + color: var(--studio-gray-60); + font-weight: 400; + line-height: 1.5; + } +} + +.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; + // wp-admin core styles give h4 a `margin: 1.33em 0` in Odyssey Stats. + margin: 0 0 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(3, 1fr); + gap: 16px; + list-style: none; + // 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; +} + +.stats-video-metric-tabs__item { + // Stretch the button to the row height so cards stay uniform when a + // label or value wraps inside one of them. + display: flex; + margin: 0; + min-width: 0; +} + +.stats-video-metric-tabs__tab { + flex: 1; + 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); + // The page container (.stats-summary__positioned) only adds side + // padding above this breakpoint, so pad the cards off the viewport + // edges ourselves. + padding: 0 16px; + } +} + +@media (max-width: $break-small) { + .stats-video-metric-tabs { + grid-template-columns: 1fr; + } + + .stats-video-details-card { + gap: 12px; + } +} 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..3d2de62d891a --- /dev/null +++ b/client/my-sites/stats/stats-video-detail/video-details-card.tsx @@ -0,0 +1,41 @@ +import { Card } from '@automattic/components'; +import clsx from 'clsx'; +import { useTranslate } from 'i18n-calypso'; +import { useLocalizedMoment } from 'calypso/components/localized-moment'; + +import './style.scss'; + +export default function VideoDetailsCard( { + title, + date, + isLoading = false, +}: { + title: string | null; + date: string | null; + isLoading?: boolean; +} ) { + const translate = useTranslate(); + const moment = useLocalizedMoment(); + + if ( ! title && ! isLoading ) { + return null; + } + + return ( + +

{ translate( 'Video details' ) }

+
+
{ title }
+ { ( isLoading || date ) && ( +
+ { date && + translate( 'Published %(date)s', { + args: { date: moment( date ).format( 'll' ) }, + comment: 'Date when the video was uploaded.', + } ) } +
+ ) } +
+
+ ); +} 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..32f4d299ef04 --- /dev/null +++ b/client/my-sites/stats/stats-video-detail/video-embeds-card.tsx @@ -0,0 +1,53 @@ +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 } 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 + ); + // 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 ?? []; + + 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-detail/video-metric-tabs.tsx b/client/my-sites/stats/stats-video-detail/video-metric-tabs.tsx new file mode 100644 index 000000000000..165636ef75c4 --- /dev/null +++ b/client/my-sites/stats/stats-video-detail/video-metric-tabs.tsx @@ -0,0 +1,81 @@ +import { Gridicon } from '@automattic/components'; +import { formatNumber, formatNumberCompact } from '@automattic/number-formatters'; +import { Icon, seen, video } from '@wordpress/icons'; +import clsx from 'clsx'; +import { useTranslate } from 'i18n-calypso'; + +export type VideoStatType = 'views' | 'impressions' | 'watch_time'; + +// `null` renders a loading placeholder. +export type VideoMetricValues = Record< VideoStatType, number | null >; + +function formatValue( statType: VideoStatType, value: number | null ) { + if ( value === null ) { + return '-'; + } + + switch ( statType ) { + case 'watch_time': + if ( value === 0 || value >= 1 ) { + return formatNumber( value, { decimals: 1 } ); + } + return `< ${ formatNumber( 1, { 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: , + }, + ]; + + 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 new file mode 100644 index 000000000000..cfa2c8c675bb --- /dev/null +++ b/client/my-sites/stats/stats-video-detail/video-summary.tsx @@ -0,0 +1,342 @@ +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'; +import VideoMetricTabs, { VideoStatType, VideoMetricValues } from './video-metric-tabs'; + +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". +const FETCHED_STAT_TYPES = [ 'views', 'impressions', 'watch_time' ] as const; + +interface ChartRecord { + period: string; + periodLabel: string; + startDate: string; + value: number; +} + +interface BucketRecord { + key: string; + plays: number; + impressions: number; + watchTime: number; +} + +interface VideoSummaryData { + data?: Array< { period: string; value: number } >; +} + +const STAT_TYPES: VideoStatType[] = [ 'views', 'impressions', 'watch_time' ]; + +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; + default: + return bucket.plays; + } +} + +export default function VideoSummary( { + postId, + initialStatType, +}: { + postId: number; + initialStatType: string | null; +} ) { + const translate = useTranslate(); + const moment = useLocalizedMoment(); + const siteId = useSelector( getSelectedSiteId ); + const [ uiPeriod, setUiPeriod ] = useState< UiPeriod >( 'day' ); + const [ statType, setStatType ] = useState< VideoStatType >( + isVideoStatType( initialStatType ) ? initialStatType : 'views' + ); + const [ selectedRecord, setSelectedRecord ] = useState< ChartRecord | null >( null ); + + const apiPeriod: ApiPeriod = uiPeriod === 'day' || uiPeriod === 'week' ? 'month' : 'year'; + + const queries = useMemo( + () => + Object.fromEntries( + 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 playsData = useSelector( + ( state ) => + getSiteStatsNormalizedData( + state, + siteId, + 'statsVideo', + queries.views + ) as VideoSummaryData | null + ); + const impressionsData = useSelector( + ( state ) => + getSiteStatsNormalizedData( + state, + siteId, + 'statsVideo', + queries.impressions + ) as VideoSummaryData | null + ); + const watchTimeData = useSelector( + ( state ) => + getSiteStatsNormalizedData( + state, + siteId, + 'statsVideo', + queries.watch_time + ) as VideoSummaryData | null + ); + const isRequesting = useSelector( ( state ) => + siteId + ? FETCHED_STAT_TYPES.some( ( type ) => + isRequestingSiteStatsForQuery( state, siteId, 'statsVideo', queries[ type ] ) + ) + : false + ); + + // QuerySiteStats defers its initial request, so the requesting flag is + // still false on the first render after switching windows; treat missing + // data as loading too, or the empty state flashes before the fetch starts. + const isSelectedSeriesLoaded = !! { + views: playsData, + impressions: impressionsData, + watch_time: watchTimeData, + }[ statType ]; + + // 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; + // 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 trimToWindow( data ) ?? [] ) { + const parsed = moment( date ); + if ( ! parsed.isValid() ) { + continue; + } + // 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; + }; + + 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(), + ...watchTimeByBucket.keys(), + ] ) + ).sort(); + + 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( + () => + 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( bucket.key ) + .add( 6, 'days' ) + .format( 'L' ) }`, + startDate: bucket.key, + value, + }; + case 'month': + return { + period: start.format( 'MMM YYYY' ), + periodLabel: start.format( 'MMMM YYYY' ), + startDate: bucket.key, + value, + }; + case 'year': + return { + period: start.format( 'YYYY' ), + periodLabel: start.format( 'YYYY' ), + startDate: bucket.key, + value, + }; + default: + return { + period: start.format( 'MMM D' ), + periodLabel: start.format( 'LL' ), + startDate: bucket.key, + value, + }; + } + } ), + [ buckets, statType, uiPeriod, moment ] + ); + + const selected = + selectedRecord ?? ( chartData.length ? chartData[ chartData.length - 1 ] : null ); + + // 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; + + return { + views: sum( playsData, ( bucket ) => bucket.plays ), + impressions: sum( impressionsData, ( bucket ) => bucket.impressions ), + watch_time: sum( watchTimeData, ( bucket ) => bucket.watchTime ), + }; + }, [ buckets, playsData, impressionsData, watchTimeData ] ); + + const selectPeriod = ( newPeriod: UiPeriod ) => () => { + setUiPeriod( newPeriod ); + setSelectedRecord( null ); + }; + + const selectStatType = ( newStatType: VideoStatType ) => { + setStatType( newStatType ); + 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.startDate === selected.startDate ) + : -1; + + const handleArrows = ( { direction }: { direction: string } ) => { + if ( selectedIndex === -1 ) { + return; + } + if ( direction === 'previous' && selectedIndex > 0 ) { + setSelectedRecord( chartData[ selectedIndex - 1 ] ); + } else if ( direction === 'next' && selectedIndex < chartData.length - 1 ) { + setSelectedRecord( chartData[ selectedIndex + 1 ] ); + } + }; + + const tabLabels: Record< VideoStatType, string > = { + views: translate( 'Views', { textOnly: true } ), + impressions: translate( 'Impressions', { textOnly: true } ), + watch_time: translate( 'Hours watched', { textOnly: true } ), + }; + + 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 } ) }, + { id: 'year', label: translate( 'Years', { textOnly: true } ) }, + ]; + + return ( +
0 && chartData.length < 3, + } ) } + > + { siteId && + FETCHED_STAT_TYPES.map( ( type ) => ( + + ) ) } + + + + + + + { periods.map( ( { id, label } ) => ( + + { label } + + ) ) } + + + + + + +
+ ); +} 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 ) ); 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 ); diff --git a/client/my-sites/stats/style.scss b/client/my-sites/stats/style.scss index 24c40f3fc6dc..b2d0d8cf9341 100644 --- a/client/my-sites/stats/style.scss +++ b/client/my-sites/stats/style.scss @@ -119,6 +119,13 @@ $font-sf-pro-display: "SF Pro Display", $sans; *:has(> h1) { min-width: 0; } + + // The Jetpack logo (JetpackTitle's HStack) is a flex item with the + // default flex-shrink: 1, so it compresses along with a truncating + // title unless pinned. + .jetpack-logo { + flex-shrink: 0; + } } // Breadcrumb trail rendered inside the unified header on detail pages. @@ -127,10 +134,16 @@ $font-sf-pro-display: "SF Pro Display", $sans; align-items: center; gap: 8px; min-width: 0; + } - .jetpack-logo { - flex-shrink: 0; - } + // Each crumb (separator + label) wraps in a span that is the actual + // flex item; without min-width: 0 on it, the label inside can never + // shrink and the ellipsis rules below have no effect. + .stats-breadcrumbs__item { + display: inline-flex; + align-items: center; + gap: 8px; + min-width: 0; } .stats-breadcrumbs__link { @@ -146,6 +159,7 @@ $font-sf-pro-display: "SF Pro Display", $sans; .stats-breadcrumbs__current { overflow: hidden; text-overflow: ellipsis; + white-space: nowrap; } > .stats-navigation { diff --git a/client/my-sites/stats/summary/index.jsx b/client/my-sites/stats/summary/index.jsx index 605ed58f500c..0eb92c867de4 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; @@ -336,47 +330,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'; @@ -510,7 +463,7 @@ const StatsSummaryWrapper = ( props ) => { ); }; -export default connect( ( state, { context, postId } ) => { +export default connect( ( state ) => { const siteId = getSelectedSiteId( state ); const { supportsUTMStats, supportsArchiveStats } = getEnvStatsFeatureSupportChecks( @@ -521,7 +474,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 ), diff --git a/client/state/stats/lists/test/utils.js b/client/state/stats/lists/test/utils.js index 5f5e5eab0ff1..1dde5bacf65c 100644 --- a/client/state/stats/lists/test/utils.js +++ b/client/state/stats/lists/test/utils.js @@ -1636,6 +1636,30 @@ 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: [], post: null } ); + } ); + + 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 } ], + post: null, + } ); + } ); + test( 'should return a properly parsed data array', () => { expect( normalizers.statsVideo( { @@ -1675,6 +1699,20 @@ describe( 'utils', () => { link: 'http://www.themepremium.com/blog-with-the-speed-of-your-thought-with-the-p2-theme/', }, ], + post: null, + } ); + } ); + + test( 'should pass through the attachment post', () => { + const post = { + ID: 43948, + post_title: 'blank-canvas-split-screen', + post_date: '2021-02-08 13:53:37', + }; + expect( normalizers.statsVideo( { data: [], pages: [], post } ) ).toEqual( { + pages: [], + data: [], + post, } ); } ); } ); diff --git a/client/state/stats/lists/utils.js b/client/state/stats/lists/utils.js index f39f4bb05efa..9a78cc7640ec 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 = []; @@ -850,7 +854,9 @@ export const normalizers = { } ); } - return { pages, data }; + // The endpoint also returns the video's attachment post, which carries + // the title and upload date. + return { pages, data, post: payload.post ?? null }; }, /**