Verified Commit f40712ab authored by Chocobozzz's avatar Chocobozzz
Browse files

Add ability to filter overall video stats by date

parent 49f0468d
import { NgModule } from '@angular/core'
import { RouterModule, Routes } from '@angular/router'
import { LoginGuard } from '@app/core'
import { VideoResolver } from '@app/shared/shared-main'
import { VideoStatsComponent } from './video'
const statsRoutes: Routes = [
{
path: 'videos/:videoId',
canActivate: [ LoginGuard ],
component: VideoStatsComponent,
data: {
meta: {
......
import { ChartModule } from 'primeng/chart'
import { NgModule } from '@angular/core'
import { SharedFormModule } from '@app/shared/shared-forms'
import { SharedGlobalIconModule } from '@app/shared/shared-icons'
import { SharedMainModule } from '@app/shared/shared-main'
import { SharedVideoLiveModule } from '@app/shared/shared-video-live'
import { StatsRoutingModule } from './stats-routing.module'
import { VideoStatsComponent, VideoStatsService } from './video'
......@@ -10,7 +12,9 @@ import { VideoStatsComponent, VideoStatsService } from './video'
StatsRoutingModule,
SharedMainModule,
SharedFormModule,
SharedGlobalIconModule,
SharedVideoLiveModule,
ChartModule
],
......
<div class="margin-content">
<h1 class="title-page title-page-single" i18n>Stats for {{ video.name }}</h1>
<h1 class="title-page title-page-single" i18n>{{ video.name }}</h1>
<div class="overall-stats-embed">
<div class="overall-stats">
<div *ngFor="let card of overallStatCards" class="card overall-stats-card">
<div class="stats-embed">
<div class="global-stats">
<div *ngFor="let card of globalStatsCards" class="card stats-card">
<div class="label">{{ card.label }}</div>
<div class="value">{{ card.value }}</div>
<div *ngIf="card.moreInfo" class="more-info">{{ card.moreInfo }}</div>
......@@ -13,33 +13,51 @@
<my-embed [video]="video"></my-embed>
</div>
<div class="timeserie">
<div ngbNav #nav="ngbNav" [activeId]="activeGraphId" (activeIdChange)="onChartChange($event)" class="nav-tabs">
<ng-container *ngFor="let availableChart of availableCharts" [ngbNavItem]="availableChart.id">
<a ngbNavLink i18n>
<span>{{ availableChart.label }}</span>
</a>
<ng-template ngbNavContent>
<div class="chart-container" [ngStyle]="{ 'min-height': chartHeight }">
<p-chart
*ngIf="chartOptions[availableChart.id]"
[height]="chartHeight" [width]="chartWidth"
[type]="chartOptions[availableChart.id].type" [options]="chartOptions[availableChart.id].options" [data]="chartOptions[availableChart.id].data"
[plugins]="chartPlugins"
></p-chart>
</div>
<div class="zoom-container">
<span *ngIf="!hasZoom() && availableChart.zoomEnabled" i18n class="description">You can select a part of the graph to zoom in</span>
<my-button i18n *ngIf="hasZoom()" (click)="resetZoom()">Reset zoom</my-button>
</div>
</ng-template>
</ng-container>
<div class="stats-with-date">
<div class="overall-stats">
<div class="date-filter-wrapper">
<h2>{{ getViewersStatsTitle() }}</h2>
<my-select-options [(ngModel)]="currentDateFilter" (ngModelChange)="onDateFilterChange()" [items]="dateFilters"></my-select-options>
</div>
<div class="cards">
<div *ngFor="let card of overallStatCards" class="card stats-card">
<div class="label">{{ card.label }}</div>
<div class="value">{{ card.value }}</div>
<div *ngIf="card.moreInfo" class="more-info">{{ card.moreInfo }}</div>
</div>
</div>
</div>
<div [ngbNavOutlet]="nav"></div>
<div class="timeserie">
<div ngbNav #nav="ngbNav" [activeId]="activeGraphId" (activeIdChange)="onChartChange($event)" class="nav-tabs">
<ng-container *ngFor="let availableChart of availableCharts" [ngbNavItem]="availableChart.id">
<a ngbNavLink i18n>
<span>{{ availableChart.label }}</span>
</a>
<ng-template ngbNavContent>
<div class="chart-container" [ngStyle]="{ 'min-height': chartHeight }">
<p-chart
*ngIf="chartOptions[availableChart.id]"
[height]="chartHeight" [width]="chartWidth"
[type]="chartOptions[availableChart.id].type" [options]="chartOptions[availableChart.id].options" [data]="chartOptions[availableChart.id].data"
[plugins]="chartPlugins"
></p-chart>
</div>
<div class="zoom-container">
<span *ngIf="!hasZoom() && availableChart.zoomEnabled" i18n class="description">You can select a part of the graph to zoom in</span>
<my-button i18n *ngIf="hasZoom()" (click)="resetZoom()">Reset zoom</my-button>
</div>
</ng-template>
</ng-container>
</div>
<div [ngbNavOutlet]="nav"></div>
</div>
</div>
</div>
......@@ -2,17 +2,31 @@
@use '_mixins' as *;
@use '_nav' as *;
.overall-stats-embed {
.stats-embed {
display: flex;
justify-content: space-between;
}
.overall-stats {
.overall-stats,
.global-stats {
display: flex;
flex-wrap: wrap;
h2 {
font-size: 16px;
width: 100%;
}
}
.overall-stats {
justify-content: space-between;
.cards {
display: flex;
}
}
.overall-stats-card {
.stats-card {
display: flex;
justify-content: center;
align-items: center;
......@@ -28,12 +42,6 @@
font-size: 14px;
}
.label {
color: pvar(--greyForegroundColor);
font-weight: $font-semibold;
opacity: 0.8;
}
.value {
font-size: 24px;
font-weight: $font-semibold;
......@@ -52,6 +60,12 @@ my-embed {
width: 100%;
}
.stats-with-date {
margin-top: 30px;
padding-top: 30px;
border-top: 1px solid $separator-border-color;
}
@include on-small-main-col {
my-embed {
display: none;
......@@ -59,7 +73,7 @@ my-embed {
}
.tab-content {
margin-top: 15px;
margin-top: 5px;
}
.nav-tabs {
......
import { ChartConfiguration, ChartData, PluginOptionsByType, Scale, TooltipItem } from 'chart.js'
import zoomPlugin from 'chartjs-plugin-zoom'
import { Observable, of } from 'rxjs'
import { SelectOptionsItem } from 'src/types'
import { Component, OnInit } from '@angular/core'
import { ActivatedRoute } from '@angular/router'
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'
......@@ -21,41 +30,24 @@ type ChartBuilderResult = {
displayLegend: boolean
}
type Card = { label: string, value: string | number, moreInfo?: 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`,
zoomEnabled: true
},
{
id: 'aggregateWatchTime',
label: $localize`Watch time`,
zoomEnabled: true
},
{
id: 'retention',
label: $localize`Retention`,
zoomEnabled: false
},
{
id: 'countries',
label: $localize`Countries`,
zoomEnabled: false
}
]
availableCharts: { id: string, label: string, zoomEnabled: boolean }[] = []
activeGraphId: ActiveGraphId = 'viewers'
video: VideoDetails
......@@ -64,8 +56,16 @@ export class VideoStatsComponent implements OnInit {
chartPlugins = [ zoomPlugin ]
private timeseriesStartDate: Date
private timeseriesEndDate: Date
currentDateFilter = 'all'
dateFilters: SelectOptionsItem[] = [
{
id: 'all',
label: $localize`Since the video publication`
}
]
private statsStartDate: Date
private statsEndDate: Date
private chartIngestData: { [ id in ActiveGraphId ]?: ChartIngestData } = {}
......@@ -74,25 +74,58 @@ export class VideoStatsComponent implements OnInit {
private notifier: Notifier,
private statsService: VideoStatsService,
private peertubeRouter: PeerTubeRouterService,
private numberFormatter: NumberFormatterPipe
private numberFormatter: NumberFormatterPipe,
private liveService: LiveVideoService
) {}
ngOnInit () {
this.video = this.route.snapshot.data.video
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.timeseriesStartDate = params.startDate
this.statsStartDate = params.startDate
? new Date(params.startDate)
: undefined
this.timeseriesEndDate = params.endDate
this.statsEndDate = params.endDate
? new Date(params.endDate)
: undefined
this.loadChart()
this.loadOverallStats()
})
this.loadOverallStats()
this.loadDateFilters()
}
hasCountries () {
......@@ -107,10 +140,30 @@ export class VideoStatsComponent implements OnInit {
resetZoom () {
this.peertubeRouter.silentNavigate([], {})
this.removeAndResetCustomDateFilter()
}
hasZoom () {
return !!this.timeseriesStartDate && this.isTimeserieGraph(this.activeGraphId)
return !!this.statsStartDate && this.isTimeserieGraph(this.activeGraphId)
}
getViewersStatsTitle () {
if (this.statsStartDate && this.statsEndDate) {
return $localize`Viewers stats between ${this.statsStartDate.toLocaleString()} and ${this.statsEndDate.toLocaleString()}`
}
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) {
......@@ -118,7 +171,7 @@ export class VideoStatsComponent implements OnInit {
}
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 => ({
......@@ -133,8 +186,70 @@ 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`Of live of ${new Date(session.startDate).toLocaleString()}`
}
}
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(this.video.views)
......@@ -142,11 +257,18 @@ export class VideoStatsComponent implements OnInit {
{
label: $localize`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),
......@@ -155,6 +277,13 @@ export class VideoStatsComponent implements OnInit {
: undefined
}
]
if (overallStats.countries.length !== 0) {
this.overallStatCards.push({
label: $localize`Countries`,
value: this.numberFormatter.transform(overallStats.countries.length)
})
}
}
private loadChart () {
......@@ -163,14 +292,14 @@ export class VideoStatsComponent implements OnInit {
aggregateWatchTime: this.statsService.getTimeserieStats({
videoId: this.video.uuid,
startDate: this.timeseriesStartDate,
endDate: this.timeseriesEndDate,
startDate: this.statsStartDate,
endDate: this.statsEndDate,
metric: 'aggregateWatchTime'
}),
viewers: this.statsService.getTimeserieStats({
videoId: this.video.uuid,
startDate: this.timeseriesStartDate,
endDate: this.timeseriesEndDate,
startDate: this.statsStartDate,
endDate: this.statsEndDate,
metric: 'viewers'
}),
......@@ -317,6 +446,7 @@ export class VideoStatsComponent implements OnInit {
const endDate = this.buildZoomEndDate(rawData.groupInterval, rawData.data[max].date)
this.peertubeRouter.silentNavigate([], { startDate, endDate })
this.addAndSelectCustomDateFilter()
}
}
}
......@@ -386,6 +516,10 @@ export class VideoStatsComponent implements OnInit {
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' })
}
......
......@@ -17,8 +17,18 @@ export class VideoStatsService {
private restExtractor: RestExtractor
) { }
getOverallStats (videoId: string) {
return this.authHttp.get<VideoStatsOverall>(VideoService.BASE_VIDEO_URL + '/' + videoId + '/stats/overall')
getOverallStats (options: {
videoId: string
startDate?: Date
endDate?: Date
}) {
const { videoId, startDate, endDate } = options
let params = new HttpParams()
if (startDate) params = params.append('startDate', startDate.toISOString())
if (endDate) params = params.append('endDate', endDate.toISOString())
return this.authHttp.get<VideoStatsOverall>(VideoService.BASE_VIDEO_URL + '/' + videoId + '/stats/overall', { params })
.pipe(catchError(err => this.restExtractor.handleError(err)))
}
......
import { NgModule } from '@angular/core'
import { RouterModule, Routes } from '@angular/router'
import { LoginGuard } from '@app/core'
import { VideoResolver } from '@app/shared/shared-main'
import { VideoStudioEditComponent } from './edit'
const videoStudioRoutes: Routes = [
{
path: '',
canActivateChild: [ LoginGuard ],
children: [
{
path: 'edit/:videoId',
......
......@@ -4,6 +4,7 @@ import { Injectable } from '@angular/core'
import { RestExtractor } from '@app/core'
import { LiveVideo, LiveVideoCreate, LiveVideoSession, LiveVideoUpdate, ResultList, VideoCreateResult } from '@shared/models'
import { environment } from '../../../environments/environment'
import { VideoService } from '../shared-main'
@Injectable()
export class LiveVideoService {
......@@ -32,6 +33,12 @@ export class LiveVideoService {
.pipe(catchError(err => this.restExtractor.handleError(err)))
}
findLiveSessionFromVOD (videoId: number | string) {
return this.authHttp
.get<LiveVideoSession>(VideoService.BASE_VIDEO_URL + '/' + videoId + '/live-session')
.pipe(catchError(err => this.restExtractor.handleError(err)))
}
updateLive (videoId: number | string, liveUpdate: LiveVideoUpdate) {
return this.authHttp
.put(LiveVideoService.BASE_VIDEO_LIVE_URL + videoId, liveUpdate)
......
......@@ -67,18 +67,9 @@ async function getTimeserieStats (req: express.Request, res: express.Response) {
const stats = await LocalVideoViewerModel.getTimeserieStats({
video,
metric,
startDate: query.startDate ?? buildOneMonthAgo().toISOString(),
startDate: query.startDate ?? video.createdAt.toISOString(),
endDate: query.endDate ?? new Date().toISOString()
})