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 };
},
/**