]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/blobdiff - client/src/app/+stats/video/video-stats.component.ts
Cleanup title-page CSS
[github/Chocobozzz/PeerTube.git] / client / src / app / +stats / video / video-stats.component.ts
index 05319539bcf066c7ab50e504b54d8893facea63f..6e03da727aebd9d0ed27f3dc112f38d8afd01931 100644 (file)
@@ -1,11 +1,21 @@
-import { ChartConfiguration, ChartData } from 'chart.js'
+import { ChartConfiguration, ChartData, PluginOptionsByType, Scale, TooltipItem } from 'chart.js'
+import zoomPlugin from 'chartjs-plugin-zoom'
 import { Observable, of } from 'rxjs'
-import { Component, OnInit } from '@angular/core'
+import { SelectOptionsItem } from 'src/types'
+import { Component, Inject, LOCALE_ID, OnInit } from '@angular/core'
 import { ActivatedRoute } from '@angular/router'
-import { Notifier } from '@app/core'
+import { Notifier, PeerTubeRouterService } from '@app/core'
 import { NumberFormatterPipe, VideoDetails } from '@app/shared/shared-main'
+import { LiveVideoService } from '@app/shared/shared-video-live'
 import { secondsToTime } from '@shared/core-utils'
-import { VideoStatsOverall, VideoStatsRetention, VideoStatsTimeserie, VideoStatsTimeserieMetric } from '@shared/models/videos'
+import { HttpStatusCode } from '@shared/models/http'
+import {
+  LiveVideoSession,
+  VideoStatsOverall,
+  VideoStatsRetention,
+  VideoStatsTimeserie,
+  VideoStatsTimeserieMetric
+} from '@shared/models/videos'
 import { VideoStatsService } from './video-stats.service'
 
 type ActiveGraphId = VideoStatsTimeserieMetric | 'retention' | 'countries'
@@ -15,59 +25,108 @@ type CountryData = { name: string, viewers: number }[]
 type ChartIngestData = VideoStatsTimeserie | VideoStatsRetention | CountryData
 type ChartBuilderResult = {
   type: 'line' | 'bar'
+  plugins: Partial<PluginOptionsByType<'line' | 'bar'>>
   data: ChartData<'line' | 'bar'>
   displayLegend: boolean
 }
 
+type Card = { label: string, value: string | number, moreInfo?: string, help?: string }
+
 @Component({
   templateUrl: './video-stats.component.html',
   styleUrls: [ './video-stats.component.scss' ],
   providers: [ NumberFormatterPipe ]
 })
 export class VideoStatsComponent implements OnInit {
-  overallStatCards: { label: string, value: string | number, moreInfo?: string }[] = []
+  // Cannot handle date filters
+  globalStatsCards: Card[] = []
+  // Can handle date filters
+  overallStatCards: Card[] = []
 
   chartOptions: { [ id in ActiveGraphId ]?: ChartConfiguration<'line' | 'bar'> } = {}
   chartHeight = '300px'
   chartWidth: string = null
 
-  availableCharts = [
-    {
-      id: 'viewers',
-      label: $localize`Viewers`
-    },
-    {
-      id: 'aggregateWatchTime',
-      label: $localize`Watch time`
-    },
-    {
-      id: 'retention',
-      label: $localize`Retention`
-    },
-    {
-      id: 'countries',
-      label: $localize`Countries`
-    }
-  ]
-
+  availableCharts: { id: string, label: string, zoomEnabled: boolean }[] = []
   activeGraphId: ActiveGraphId = 'viewers'
 
   video: VideoDetails
 
   countries: CountryData = []
 
+  chartPlugins = [ zoomPlugin ]
+
+  currentDateFilter = 'all'
+  dateFilters: SelectOptionsItem[] = [
+    {
+      id: 'all',
+      label: $localize`Since the video publication`
+    }
+  ]
+
+  private statsStartDate: Date
+  private statsEndDate: Date
+
+  private chartIngestData: { [ id in ActiveGraphId ]?: ChartIngestData } = {}
+
   constructor (
+    @Inject(LOCALE_ID) private localeId: string,
     private route: ActivatedRoute,
     private notifier: Notifier,
     private statsService: VideoStatsService,
-    private numberFormatter: NumberFormatterPipe
+    private peertubeRouter: PeerTubeRouterService,
+    private numberFormatter: NumberFormatterPipe,
+    private liveService: LiveVideoService
   ) {}
 
   ngOnInit () {
     this.video = this.route.snapshot.data.video
 
-    this.loadOverallStats()
-    this.loadChart()
+    this.availableCharts = [
+      {
+        id: 'viewers',
+        label: $localize`Viewers`,
+        zoomEnabled: true
+      },
+      {
+        id: 'aggregateWatchTime',
+        label: $localize`Watch time`,
+        zoomEnabled: true
+      },
+      {
+        id: 'countries',
+        label: $localize`Countries`,
+        zoomEnabled: false
+      }
+    ]
+
+    if (!this.video.isLive) {
+      this.availableCharts.push({
+        id: 'retention',
+        label: $localize`Retention`,
+        zoomEnabled: false
+      })
+    }
+
+    const snapshotQuery = this.route.snapshot.queryParams
+    if (snapshotQuery.startDate || snapshotQuery.endDate) {
+      this.addAndSelectCustomDateFilter()
+    }
+
+    this.route.queryParams.subscribe(params => {
+      this.statsStartDate = params.startDate
+        ? new Date(params.startDate)
+        : undefined
+
+      this.statsEndDate = params.endDate
+        ? new Date(params.endDate)
+        : undefined
+
+      this.loadChart()
+      this.loadOverallStats()
+    })
+
+    this.loadDateFilters()
   }
 
   hasCountries () {
@@ -80,8 +139,40 @@ export class VideoStatsComponent implements OnInit {
     this.loadChart()
   }
 
+  resetZoom () {
+    this.peertubeRouter.silentNavigate([], {})
+    this.removeAndResetCustomDateFilter()
+  }
+
+  hasZoom () {
+    return !!this.statsStartDate && this.isTimeserieGraph(this.activeGraphId)
+  }
+
+  getViewersStatsTitle () {
+    if (this.statsStartDate && this.statsEndDate) {
+      return $localize`Viewers stats between ${this.toMediumDate(this.statsStartDate)} and ${this.toMediumDate(this.statsEndDate)}`
+    }
+
+    return $localize`Viewers stats`
+  }
+
+  onDateFilterChange () {
+    if (this.currentDateFilter === 'all') {
+      return this.resetZoom()
+    }
+
+    const idParts = this.currentDateFilter.split('|')
+    if (idParts.length === 2) {
+      return this.peertubeRouter.silentNavigate([], { startDate: idParts[0], endDate: idParts[1] })
+    }
+  }
+
+  private isTimeserieGraph (graphId: ActiveGraphId) {
+    return graphId === 'aggregateWatchTime' || graphId === 'viewers'
+  }
+
   private loadOverallStats () {
-    this.statsService.getOverallStats(this.video.uuid)
+    this.statsService.getOverallStats({ videoId: this.video.uuid, startDate: this.statsStartDate, endDate: this.statsEndDate })
       .subscribe({
         next: res => {
           this.countries = res.countries.slice(0, 10).map(c => ({
@@ -96,53 +187,143 @@ export class VideoStatsComponent implements OnInit {
       })
   }
 
+  private loadDateFilters () {
+    if (this.video.isLive) return this.loadLiveDateFilters()
+
+    return this.loadVODDateFilters()
+  }
+
+  private loadLiveDateFilters () {
+    this.liveService.listSessions(this.video.id)
+      .subscribe({
+        next: ({ data }) => {
+          const newFilters = data.map(session => this.buildLiveFilter(session))
+
+          this.dateFilters = this.dateFilters.concat(newFilters)
+        },
+
+        error: err => this.notifier.error(err.message)
+      })
+  }
+
+  private loadVODDateFilters () {
+    this.liveService.findLiveSessionFromVOD(this.video.id)
+      .subscribe({
+        next: session => {
+          this.dateFilters = this.dateFilters.concat([ this.buildLiveFilter(session) ])
+        },
+
+        error: err => {
+          if (err.status === HttpStatusCode.NOT_FOUND_404) return
+
+          this.notifier.error(err.message)
+        }
+      })
+  }
+
+  private buildLiveFilter (session: LiveVideoSession) {
+    return {
+      id: session.startDate + '|' + session.endDate,
+      label: $localize`Live as of ${this.toMediumDate(new Date(session.startDate))}`
+    }
+  }
+
+  private addAndSelectCustomDateFilter () {
+    const exists = this.dateFilters.some(d => d.id === 'custom')
+
+    if (!exists) {
+      this.dateFilters = this.dateFilters.concat([
+        {
+          id: 'custom',
+          label: $localize`Custom dates`
+        }
+      ])
+    }
+
+    this.currentDateFilter = 'custom'
+  }
+
+  private removeAndResetCustomDateFilter () {
+    this.dateFilters = this.dateFilters.filter(d => d.id !== 'custom')
+
+    this.currentDateFilter = 'all'
+  }
+
   private buildOverallStatCard (overallStats: VideoStatsOverall) {
-    this.overallStatCards = [
+    this.globalStatsCards = [
       {
         label: $localize`Views`,
-        value: this.numberFormatter.transform(overallStats.views)
-      },
-      {
-        label: $localize`Comments`,
-        value: this.numberFormatter.transform(overallStats.comments)
+        value: this.numberFormatter.transform(this.video.views),
+        help: $localize`A view means that someone watched the video for at least 30 seconds`
       },
       {
         label: $localize`Likes`,
-        value: this.numberFormatter.transform(overallStats.likes)
-      },
+        value: this.numberFormatter.transform(this.video.likes)
+      }
+    ]
+
+    this.overallStatCards = [
       {
         label: $localize`Average watch time`,
         value: secondsToTime(overallStats.averageWatchTime)
       },
+      {
+        label: $localize`Total watch time`,
+        value: secondsToTime(overallStats.totalWatchTime)
+      },
       {
         label: $localize`Peak viewers`,
         value: this.numberFormatter.transform(overallStats.viewersPeak),
-        moreInfo: $localize`at ${new Date(overallStats.viewersPeakDate).toLocaleString()}`
+        moreInfo: overallStats.viewersPeak !== 0
+          ? $localize`at ${this.toMediumDate(new Date(overallStats.viewersPeakDate))}`
+          : undefined
+      },
+      {
+        label: $localize`Unique viewers`,
+        value: this.numberFormatter.transform(overallStats.totalViewers)
       }
     ]
+
+    if (overallStats.countries.length !== 0) {
+      this.overallStatCards.push({
+        label: $localize`Countries`,
+        value: this.numberFormatter.transform(overallStats.countries.length)
+      })
+    }
   }
 
   private loadChart () {
     const obsBuilders: { [ id in ActiveGraphId ]: Observable<ChartIngestData> } = {
       retention: this.statsService.getRetentionStats(this.video.uuid),
-      aggregateWatchTime: this.statsService.getTimeserieStats(this.video.uuid, 'aggregateWatchTime'),
-      viewers: this.statsService.getTimeserieStats(this.video.uuid, 'viewers'),
+
+      aggregateWatchTime: this.statsService.getTimeserieStats({
+        videoId: this.video.uuid,
+        startDate: this.statsStartDate,
+        endDate: this.statsEndDate,
+        metric: 'aggregateWatchTime'
+      }),
+      viewers: this.statsService.getTimeserieStats({
+        videoId: this.video.uuid,
+        startDate: this.statsStartDate,
+        endDate: this.statsEndDate,
+        metric: 'viewers'
+      }),
+
       countries: of(this.countries)
     }
 
     obsBuilders[this.activeGraphId].subscribe({
       next: res => {
-        this.chartOptions[this.activeGraphId] = this.buildChartOptions(this.activeGraphId, res)
+        this.chartIngestData[this.activeGraphId] = res
+
+        this.chartOptions[this.activeGraphId] = this.buildChartOptions(this.activeGraphId)
       },
 
       error: err => this.notifier.error(err.message)
     })
   }
 
-  private buildChartOptions (
-    graphId: ActiveGraphId,
-    rawData: ChartIngestData
-  ): ChartConfiguration<'line' | 'bar'> {
+  private buildChartOptions (graphId: ActiveGraphId): ChartConfiguration<'line' | 'bar'> {
     const dataBuilders: {
       [ id in ActiveGraphId ]: (rawData: ChartIngestData) => ChartBuilderResult
     } = {
@@ -152,7 +333,9 @@ export class VideoStatsComponent implements OnInit {
       countries: (rawData: CountryData) => this.buildCountryChartOptions(rawData)
     }
 
-    const { type, data, displayLegend } = dataBuilders[graphId](rawData)
+    const { type, data, displayLegend, plugins } = dataBuilders[graphId](this.chartIngestData[graphId])
+
+    const self = this
 
     return {
       type,
@@ -162,6 +345,19 @@ export class VideoStatsComponent implements OnInit {
         responsive: true,
 
         scales: {
+          x: {
+            ticks: {
+              callback: function (value) {
+                return self.formatXTick({
+                  graphId,
+                  value,
+                  data: self.chartIngestData[graphId] as VideoStatsTimeserie,
+                  scale: this
+                })
+              }
+            }
+          },
+
           y: {
             beginAtZero: true,
 
@@ -170,7 +366,7 @@ export class VideoStatsComponent implements OnInit {
               : undefined,
 
             ticks: {
-              callback: value => this.formatTick(graphId, value)
+              callback: value => this.formatYTick({ graphId, value })
             }
           }
         },
@@ -181,15 +377,18 @@ export class VideoStatsComponent implements OnInit {
           },
           tooltip: {
             callbacks: {
-              label: value => this.formatTick(graphId, value.raw as number | string)
+              title: items => this.formatTooltipTitle({ graphId, items }),
+              label: value => this.formatYTick({ graphId, value: value.raw as number | string })
             }
-          }
+          },
+
+          ...plugins
         }
       }
     }
   }
 
-  private buildRetentionChartOptions (rawData: VideoStatsRetention) {
+  private buildRetentionChartOptions (rawData: VideoStatsRetention): ChartBuilderResult {
     const labels: string[] = []
     const data: number[] = []
 
@@ -203,6 +402,10 @@ export class VideoStatsComponent implements OnInit {
 
       displayLegend: false,
 
+      plugins: {
+        ...this.buildDisabledZoomPlugin()
+      },
+
       data: {
         labels,
         datasets: [
@@ -215,12 +418,12 @@ export class VideoStatsComponent implements OnInit {
     }
   }
 
-  private buildTimeserieChartOptions (rawData: VideoStatsTimeserie) {
+  private buildTimeserieChartOptions (rawData: VideoStatsTimeserie): ChartBuilderResult {
     const labels: string[] = []
     const data: number[] = []
 
     for (const d of rawData.data) {
-      labels.push(new Date(d.date).toLocaleDateString())
+      labels.push(d.date)
       data.push(d.value)
     }
 
@@ -229,6 +432,37 @@ export class VideoStatsComponent implements OnInit {
 
       displayLegend: false,
 
+      plugins: {
+        zoom: {
+          zoom: {
+            wheel: {
+              enabled: false
+            },
+            drag: {
+              enabled: true
+            },
+            pinch: {
+              enabled: true
+            },
+            mode: 'x',
+            onZoomComplete: ({ chart }) => {
+              const { min, max } = chart.scales.x
+
+              const startDate = rawData.data[min].date
+              const endDate = this.buildZoomEndDate(rawData.groupInterval, rawData.data[max].date)
+
+              this.peertubeRouter.silentNavigate([], { startDate, endDate })
+              this.addAndSelectCustomDateFilter()
+            }
+          },
+          limits: {
+            x: {
+              minRange: 2
+            }
+          }
+        }
+      },
+
       data: {
         labels,
         datasets: [
@@ -241,7 +475,7 @@ export class VideoStatsComponent implements OnInit {
     }
   }
 
-  private buildCountryChartOptions (rawData: CountryData) {
+  private buildCountryChartOptions (rawData: CountryData): ChartBuilderResult {
     const labels: string[] = []
     const data: number[] = []
 
@@ -255,8 +489,8 @@ export class VideoStatsComponent implements OnInit {
 
       displayLegend: true,
 
-      options: {
-        indexAxis: 'y'
+      plugins: {
+        ...this.buildDisabledZoomPlugin()
       },
 
       data: {
@@ -277,11 +511,61 @@ export class VideoStatsComponent implements OnInit {
     return getComputedStyle(document.body).getPropertyValue('--mainColorLighter')
   }
 
-  private formatTick (graphId: ActiveGraphId, value: number | string) {
+  private formatXTick (options: {
+    graphId: ActiveGraphId
+    value: number | string
+    data: VideoStatsTimeserie
+    scale: Scale
+  }) {
+    const { graphId, value, data, scale } = options
+
+    const label = scale.getLabelForValue(value as number)
+
+    if (!this.isTimeserieGraph(graphId)) {
+      return label
+    }
+
+    const date = new Date(label)
+
+    if (data.groupInterval.match(/ month?$/)) {
+      return date.toLocaleDateString([], { month: 'numeric' })
+    }
+
+    if (data.groupInterval.match(/ days?$/)) {
+      return date.toLocaleDateString([], { month: 'numeric', day: 'numeric' })
+    }
+
+    if (data.groupInterval.match(/ hours?$/)) {
+      return date.toLocaleTimeString([], { month: 'numeric', day: 'numeric', hour: 'numeric', minute: 'numeric' })
+    }
+
+    return date.toLocaleTimeString([], { hour: 'numeric', minute: 'numeric' })
+  }
+
+  private formatYTick (options: {
+    graphId: ActiveGraphId
+    value: number | string
+  }) {
+    const { graphId, value } = options
+
     if (graphId === 'retention') return value + ' %'
     if (graphId === 'aggregateWatchTime') return secondsToTime(+value)
 
-    return value.toLocaleString()
+    return value.toLocaleString(this.localeId)
+  }
+
+  private formatTooltipTitle (options: {
+    graphId: ActiveGraphId
+    items: TooltipItem<any>[]
+  }) {
+    const { graphId, items } = options
+    const item = items[0]
+
+    if (this.isTimeserieGraph(graphId)) {
+      return this.toMediumDate(new Date(item.label))
+    }
+
+    return item.label
   }
 
   private countryCodeToName (code: string) {
@@ -292,4 +576,48 @@ export class VideoStatsComponent implements OnInit {
 
     return regionNames.of(code)
   }
+
+  private buildDisabledZoomPlugin () {
+    return {
+      zoom: {
+        zoom: {
+          wheel: {
+            enabled: false
+          },
+          drag: {
+            enabled: false
+          },
+          pinch: {
+            enabled: false
+          }
+        }
+      }
+    }
+  }
+
+  private toMediumDate (d: Date) {
+    return new Date(d).toLocaleString(this.localeId, {
+      day: 'numeric',
+      month: 'short',
+      year: 'numeric',
+      hour: 'numeric',
+      minute: 'numeric',
+      second: 'numeric'
+    })
+  }
+
+  private buildZoomEndDate (groupInterval: string, last: string) {
+    const date = new Date(last)
+
+    // Remove parts of the date we don't need
+    if (groupInterval.endsWith(' day') || groupInterval.endsWith(' days')) {
+      date.setHours(23, 59, 59)
+    } else if (groupInterval.endsWith(' hour') || groupInterval.endsWith(' hours')) {
+      date.setMinutes(59, 59)
+    } else {
+      date.setSeconds(59)
+    }
+
+    return date.toISOString()
+  }
 }