aboutsummaryrefslogtreecommitdiffhomepage
path: root/client/src/app/+stats
diff options
context:
space:
mode:
Diffstat (limited to 'client/src/app/+stats')
-rw-r--r--client/src/app/+stats/index.ts1
-rw-r--r--client/src/app/+stats/stats-routing.module.ts25
-rw-r--r--client/src/app/+stats/stats.module.ts27
-rw-r--r--client/src/app/+stats/video/index.ts2
-rw-r--r--client/src/app/+stats/video/video-stats.component.html38
-rw-r--r--client/src/app/+stats/video/video-stats.component.scss54
-rw-r--r--client/src/app/+stats/video/video-stats.component.ts295
-rw-r--r--client/src/app/+stats/video/video-stats.service.ts34
8 files changed, 476 insertions, 0 deletions
diff --git a/client/src/app/+stats/index.ts b/client/src/app/+stats/index.ts
new file mode 100644
index 000000000..d880024a5
--- /dev/null
+++ b/client/src/app/+stats/index.ts
@@ -0,0 +1 @@
export * from './stats.module'
diff --git a/client/src/app/+stats/stats-routing.module.ts b/client/src/app/+stats/stats-routing.module.ts
new file mode 100644
index 000000000..59519a703
--- /dev/null
+++ b/client/src/app/+stats/stats-routing.module.ts
@@ -0,0 +1,25 @@
1import { NgModule } from '@angular/core'
2import { RouterModule, Routes } from '@angular/router'
3import { VideoResolver } from '@app/shared/shared-main'
4import { VideoStatsComponent } from './video'
5
6const statsRoutes: Routes = [
7 {
8 path: 'videos/:videoId',
9 component: VideoStatsComponent,
10 data: {
11 meta: {
12 title: $localize`Video stats`
13 }
14 },
15 resolve: {
16 video: VideoResolver
17 }
18 }
19]
20
21@NgModule({
22 imports: [ RouterModule.forChild(statsRoutes) ],
23 exports: [ RouterModule ]
24})
25export class StatsRoutingModule {}
diff --git a/client/src/app/+stats/stats.module.ts b/client/src/app/+stats/stats.module.ts
new file mode 100644
index 000000000..0497576e7
--- /dev/null
+++ b/client/src/app/+stats/stats.module.ts
@@ -0,0 +1,27 @@
1import { ChartModule } from 'primeng/chart'
2import { NgModule } from '@angular/core'
3import { SharedGlobalIconModule } from '@app/shared/shared-icons'
4import { SharedMainModule } from '@app/shared/shared-main'
5import { StatsRoutingModule } from './stats-routing.module'
6import { VideoStatsComponent, VideoStatsService } from './video'
7
8@NgModule({
9 imports: [
10 StatsRoutingModule,
11
12 SharedMainModule,
13 SharedGlobalIconModule,
14
15 ChartModule
16 ],
17
18 declarations: [
19 VideoStatsComponent
20 ],
21
22 exports: [],
23 providers: [
24 VideoStatsService
25 ]
26})
27export class StatsModule { }
diff --git a/client/src/app/+stats/video/index.ts b/client/src/app/+stats/video/index.ts
new file mode 100644
index 000000000..e948d4f4e
--- /dev/null
+++ b/client/src/app/+stats/video/index.ts
@@ -0,0 +1,2 @@
1export * from './video-stats.component'
2export * from './video-stats.service'
diff --git a/client/src/app/+stats/video/video-stats.component.html b/client/src/app/+stats/video/video-stats.component.html
new file mode 100644
index 000000000..ef43c9fba
--- /dev/null
+++ b/client/src/app/+stats/video/video-stats.component.html
@@ -0,0 +1,38 @@
1<div class="margin-content">
2 <h1 class="title-page title-page-single" i18n>Stats for {{ video.name }}</h1>
3
4 <div class="overall-stats-embed">
5 <div class="overall-stats">
6 <div *ngFor="let card of overallStatCards" class="card overall-stats-card">
7 <div class="label">{{ card.label }}</div>
8 <div class="value">{{ card.value }}</div>
9 <div *ngIf="card.moreInfo" class="more-info">{{ card.moreInfo }}</div>
10 </div>
11 </div>
12
13 <my-embed [video]="video"></my-embed>
14 </div>
15
16 <div class="timeserie">
17 <div ngbNav #nav="ngbNav" [activeId]="activeGraphId" (activeIdChange)="onChartChange($event)" class="nav-tabs">
18
19 <ng-container *ngFor="let availableChart of availableCharts" [ngbNavItem]="availableChart.id">
20 <a ngbNavLink i18n>
21 <span>{{ availableChart.label }}</span>
22 </a>
23
24 <ng-template ngbNavContent>
25 <div [ngStyle]="{ 'min-height': chartHeight }">
26 <p-chart
27 *ngIf="chartOptions[availableChart.id]"
28 [height]="chartHeight" [width]="chartWidth"
29 [type]="chartOptions[availableChart.id].type" [options]="chartOptions[availableChart.id].options" [data]="chartOptions[availableChart.id].data"
30 ></p-chart>
31 </div>
32 </ng-template>
33 </ng-container>
34 </div>
35
36 <div [ngbNavOutlet]="nav"></div>
37 </div>
38</div>
diff --git a/client/src/app/+stats/video/video-stats.component.scss b/client/src/app/+stats/video/video-stats.component.scss
new file mode 100644
index 000000000..190499b5c
--- /dev/null
+++ b/client/src/app/+stats/video/video-stats.component.scss
@@ -0,0 +1,54 @@
1@use '_variables' as *;
2@use '_mixins' as *;
3@use '_nav' as *;
4
5.overall-stats-embed {
6 display: flex;
7 justify-content: space-between;
8}
9
10.overall-stats {
11 display: flex;
12 flex-wrap: wrap;
13}
14
15.overall-stats-card {
16 display: flex;
17 justify-content: center;
18 align-items: center;
19 height: fit-content;
20 min-height: 100px;
21 min-width: 200px;
22 margin-right: 15px;
23 background-color: pvar(--submenuBackgroundColor);
24
25 .label,
26 .more-info {
27 font-size: 14px;
28 }
29
30 .label {
31 color: pvar(--greyForegroundColor);
32 font-weight: $font-semibold;
33 opacity: 0.8;
34 }
35
36 .value {
37 font-size: 24px;
38 font-weight: $font-semibold;
39 }
40}
41
42my-embed {
43 display: block;
44 max-width: 500px;
45 width: 100%;
46}
47
48.tab-content {
49 margin-top: 15px;
50}
51
52.nav-tabs {
53 @include peertube-nav-tabs($border-width: 2px);
54}
diff --git a/client/src/app/+stats/video/video-stats.component.ts b/client/src/app/+stats/video/video-stats.component.ts
new file mode 100644
index 000000000..05319539b
--- /dev/null
+++ b/client/src/app/+stats/video/video-stats.component.ts
@@ -0,0 +1,295 @@
1import { ChartConfiguration, ChartData } from 'chart.js'
2import { Observable, of } from 'rxjs'
3import { Component, OnInit } from '@angular/core'
4import { ActivatedRoute } from '@angular/router'
5import { Notifier } from '@app/core'
6import { NumberFormatterPipe, VideoDetails } from '@app/shared/shared-main'
7import { secondsToTime } from '@shared/core-utils'
8import { VideoStatsOverall, VideoStatsRetention, VideoStatsTimeserie, VideoStatsTimeserieMetric } from '@shared/models/videos'
9import { VideoStatsService } from './video-stats.service'
10
11type ActiveGraphId = VideoStatsTimeserieMetric | 'retention' | 'countries'
12
13type CountryData = { name: string, viewers: number }[]
14
15type ChartIngestData = VideoStatsTimeserie | VideoStatsRetention | CountryData
16type ChartBuilderResult = {
17 type: 'line' | 'bar'
18 data: ChartData<'line' | 'bar'>
19 displayLegend: boolean
20}
21
22@Component({
23 templateUrl: './video-stats.component.html',
24 styleUrls: [ './video-stats.component.scss' ],
25 providers: [ NumberFormatterPipe ]
26})
27export class VideoStatsComponent implements OnInit {
28 overallStatCards: { label: string, value: string | number, moreInfo?: string }[] = []
29
30 chartOptions: { [ id in ActiveGraphId ]?: ChartConfiguration<'line' | 'bar'> } = {}
31 chartHeight = '300px'
32 chartWidth: string = null
33
34 availableCharts = [
35 {
36 id: 'viewers',
37 label: $localize`Viewers`
38 },
39 {
40 id: 'aggregateWatchTime',
41 label: $localize`Watch time`
42 },
43 {
44 id: 'retention',
45 label: $localize`Retention`
46 },
47 {
48 id: 'countries',
49 label: $localize`Countries`
50 }
51 ]
52
53 activeGraphId: ActiveGraphId = 'viewers'
54
55 video: VideoDetails
56
57 countries: CountryData = []
58
59 constructor (
60 private route: ActivatedRoute,
61 private notifier: Notifier,
62 private statsService: VideoStatsService,
63 private numberFormatter: NumberFormatterPipe
64 ) {}
65
66 ngOnInit () {
67 this.video = this.route.snapshot.data.video
68
69 this.loadOverallStats()
70 this.loadChart()
71 }
72
73 hasCountries () {
74 return this.countries.length !== 0
75 }
76
77 onChartChange (newActive: ActiveGraphId) {
78 this.activeGraphId = newActive
79
80 this.loadChart()
81 }
82
83 private loadOverallStats () {
84 this.statsService.getOverallStats(this.video.uuid)
85 .subscribe({
86 next: res => {
87 this.countries = res.countries.slice(0, 10).map(c => ({
88 name: this.countryCodeToName(c.isoCode),
89 viewers: c.viewers
90 }))
91
92 this.buildOverallStatCard(res)
93 },
94
95 error: err => this.notifier.error(err.message)
96 })
97 }
98
99 private buildOverallStatCard (overallStats: VideoStatsOverall) {
100 this.overallStatCards = [
101 {
102 label: $localize`Views`,
103 value: this.numberFormatter.transform(overallStats.views)
104 },
105 {
106 label: $localize`Comments`,
107 value: this.numberFormatter.transform(overallStats.comments)
108 },
109 {
110 label: $localize`Likes`,
111 value: this.numberFormatter.transform(overallStats.likes)
112 },
113 {
114 label: $localize`Average watch time`,
115 value: secondsToTime(overallStats.averageWatchTime)
116 },
117 {
118 label: $localize`Peak viewers`,
119 value: this.numberFormatter.transform(overallStats.viewersPeak),
120 moreInfo: $localize`at ${new Date(overallStats.viewersPeakDate).toLocaleString()}`
121 }
122 ]
123 }
124
125 private loadChart () {
126 const obsBuilders: { [ id in ActiveGraphId ]: Observable<ChartIngestData> } = {
127 retention: this.statsService.getRetentionStats(this.video.uuid),
128 aggregateWatchTime: this.statsService.getTimeserieStats(this.video.uuid, 'aggregateWatchTime'),
129 viewers: this.statsService.getTimeserieStats(this.video.uuid, 'viewers'),
130 countries: of(this.countries)
131 }
132
133 obsBuilders[this.activeGraphId].subscribe({
134 next: res => {
135 this.chartOptions[this.activeGraphId] = this.buildChartOptions(this.activeGraphId, res)
136 },
137
138 error: err => this.notifier.error(err.message)
139 })
140 }
141
142 private buildChartOptions (
143 graphId: ActiveGraphId,
144 rawData: ChartIngestData
145 ): ChartConfiguration<'line' | 'bar'> {
146 const dataBuilders: {
147 [ id in ActiveGraphId ]: (rawData: ChartIngestData) => ChartBuilderResult
148 } = {
149 retention: (rawData: VideoStatsRetention) => this.buildRetentionChartOptions(rawData),
150 aggregateWatchTime: (rawData: VideoStatsTimeserie) => this.buildTimeserieChartOptions(rawData),
151 viewers: (rawData: VideoStatsTimeserie) => this.buildTimeserieChartOptions(rawData),
152 countries: (rawData: CountryData) => this.buildCountryChartOptions(rawData)
153 }
154
155 const { type, data, displayLegend } = dataBuilders[graphId](rawData)
156
157 return {
158 type,
159 data,
160
161 options: {
162 responsive: true,
163
164 scales: {
165 y: {
166 beginAtZero: true,
167
168 max: this.activeGraphId === 'retention'
169 ? 100
170 : undefined,
171
172 ticks: {
173 callback: value => this.formatTick(graphId, value)
174 }
175 }
176 },
177
178 plugins: {
179 legend: {
180 display: displayLegend
181 },
182 tooltip: {
183 callbacks: {
184 label: value => this.formatTick(graphId, value.raw as number | string)
185 }
186 }
187 }
188 }
189 }
190 }
191
192 private buildRetentionChartOptions (rawData: VideoStatsRetention) {
193 const labels: string[] = []
194 const data: number[] = []
195
196 for (const d of rawData.data) {
197 labels.push(secondsToTime(d.second))
198 data.push(d.retentionPercent)
199 }
200
201 return {
202 type: 'line' as 'line',
203
204 displayLegend: false,
205
206 data: {
207 labels,
208 datasets: [
209 {
210 data,
211 borderColor: this.buildChartColor()
212 }
213 ]
214 }
215 }
216 }
217
218 private buildTimeserieChartOptions (rawData: VideoStatsTimeserie) {
219 const labels: string[] = []
220 const data: number[] = []
221
222 for (const d of rawData.data) {
223 labels.push(new Date(d.date).toLocaleDateString())
224 data.push(d.value)
225 }
226
227 return {
228 type: 'line' as 'line',
229
230 displayLegend: false,
231
232 data: {
233 labels,
234 datasets: [
235 {
236 data,
237 borderColor: this.buildChartColor()
238 }
239 ]
240 }
241 }
242 }
243
244 private buildCountryChartOptions (rawData: CountryData) {
245 const labels: string[] = []
246 const data: number[] = []
247
248 for (const d of rawData) {
249 labels.push(d.name)
250 data.push(d.viewers)
251 }
252
253 return {
254 type: 'bar' as 'bar',
255
256 displayLegend: true,
257
258 options: {
259 indexAxis: 'y'
260 },
261
262 data: {
263 labels,
264 datasets: [
265 {
266 label: $localize`Viewers`,
267 backgroundColor: this.buildChartColor(),
268 maxBarThickness: 20,
269 data
270 }
271 ]
272 }
273 }
274 }
275
276 private buildChartColor () {
277 return getComputedStyle(document.body).getPropertyValue('--mainColorLighter')
278 }
279
280 private formatTick (graphId: ActiveGraphId, value: number | string) {
281 if (graphId === 'retention') return value + ' %'
282 if (graphId === 'aggregateWatchTime') return secondsToTime(+value)
283
284 return value.toLocaleString()
285 }
286
287 private countryCodeToName (code: string) {
288 const intl: any = Intl
289 if (!intl.DisplayNames) return code
290
291 const regionNames = new intl.DisplayNames([], { type: 'region' })
292
293 return regionNames.of(code)
294 }
295}
diff --git a/client/src/app/+stats/video/video-stats.service.ts b/client/src/app/+stats/video/video-stats.service.ts
new file mode 100644
index 000000000..8f9d48f60
--- /dev/null
+++ b/client/src/app/+stats/video/video-stats.service.ts
@@ -0,0 +1,34 @@
1import { catchError } from 'rxjs'
2import { environment } from 'src/environments/environment'
3import { HttpClient } from '@angular/common/http'
4import { Injectable } from '@angular/core'
5import { RestExtractor } from '@app/core'
6import { VideoService } from '@app/shared/shared-main'
7import { VideoStatsOverall, VideoStatsRetention, VideoStatsTimeserie, VideoStatsTimeserieMetric } from '@shared/models/videos'
8
9@Injectable({
10 providedIn: 'root'
11})
12export class VideoStatsService {
13 static BASE_VIDEO_STATS_URL = environment.apiUrl + '/api/v1/videos/'
14
15 constructor (
16 private authHttp: HttpClient,
17 private restExtractor: RestExtractor
18 ) { }
19
20 getOverallStats (videoId: string) {
21 return this.authHttp.get<VideoStatsOverall>(VideoService.BASE_VIDEO_URL + '/' + videoId + '/stats/overall')
22 .pipe(catchError(err => this.restExtractor.handleError(err)))
23 }
24
25 getTimeserieStats (videoId: string, metric: VideoStatsTimeserieMetric) {
26 return this.authHttp.get<VideoStatsTimeserie>(VideoService.BASE_VIDEO_URL + '/' + videoId + '/stats/timeseries/' + metric)
27 .pipe(catchError(err => this.restExtractor.handleError(err)))
28 }
29
30 getRetentionStats (videoId: string) {
31 return this.authHttp.get<VideoStatsRetention>(VideoService.BASE_VIDEO_URL + '/' + videoId + '/stats/retention')
32 .pipe(catchError(err => this.restExtractor.handleError(err)))
33 }
34}