Reading History (#1699)
* Added new stat graph for pages read over time for all users. * Switched to reading events rather than pages read to get a better scale * Changed query to use Created date as LastModified wont work since I just did a migration on all rows. * Small cleanup on graph * Read by day completed and ready for user stats page. * Changed the initial stat report to be in 1 day, to avoid people trying and ditching the software from muddying up the stats. * Cleaned up stats page such that stats around series show their image and tweaked some layout and wordings * Fixed recently read order * Put read history on user profile * Final cleanup, Robbie needs to do a CSS pass before release.
This commit is contained in:
parent
e43ead44da
commit
1c1e48d28c
24 changed files with 426 additions and 86 deletions
|
@ -47,5 +47,9 @@ export class MemberService {
|
|||
removeSeriesToWantToRead(seriesIds: Array<number>) {
|
||||
return this.httpClient.post<Array<Member>>(this.baseUrl + 'want-to-read/remove-series', {seriesIds});
|
||||
}
|
||||
|
||||
getMember() {
|
||||
return this.httpClient.get<Member>(this.baseUrl + 'users/myself');
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -81,4 +81,8 @@ export class StatisticsService {
|
|||
getFileBreakdown() {
|
||||
return this.httpClient.get<FileExtensionBreakdown>(this.baseUrl + 'stats/server/file-breakdown');
|
||||
}
|
||||
|
||||
getReadCountByDay(userId: number = 0) {
|
||||
return this.httpClient.get<Array<any>>(this.baseUrl + 'stats/reading-count-by-day?userId=' + userId);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -17,11 +17,11 @@ export class ManagaReaderService {
|
|||
this.renderer = rendererFactory.createRenderer(null, null);
|
||||
}
|
||||
|
||||
|
||||
loadPageDimensions(dims: Array<FileDimension>) {
|
||||
this.pageDimensions = {};
|
||||
let counter = 0;
|
||||
let i = 0;
|
||||
|
||||
dims.forEach(d => {
|
||||
const isWide = (d.width > d.height);
|
||||
this.pageDimensions[d.pageNumber] = {
|
||||
|
@ -30,16 +30,22 @@ export class ManagaReaderService {
|
|||
isWide: isWide
|
||||
};
|
||||
|
||||
//console.log('Page Number: ', d.pageNumber);
|
||||
|
||||
if (isWide) {
|
||||
console.log('\tPage is wide, counter: ', counter, 'i: ', i);
|
||||
this.pairs[d.pageNumber] = d.pageNumber;
|
||||
//this.pairs[d.pageNumber] = this.pairs[d.pageNumber - 1] + 1;
|
||||
} else {
|
||||
//console.log('\tPage is single, counter: ', counter, 'i: ', i);
|
||||
this.pairs[d.pageNumber] = counter % 2 === 0 ? Math.max(i - 1, 0) : counter;
|
||||
counter++;
|
||||
}
|
||||
//console.log('\t\tMapped to ', this.pairs[d.pageNumber]);
|
||||
|
||||
i++;
|
||||
});
|
||||
console.log('pairs: ', this.pairs);
|
||||
//console.log('pairs: ', this.pairs);
|
||||
}
|
||||
|
||||
adjustForDoubleReader(page: number) {
|
||||
|
|
|
@ -72,8 +72,6 @@ export class FileBreakdownStatsComponent implements OnInit {
|
|||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.onDestroy.next();
|
||||
this.onDestroy.complete();
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
|
|
|
@ -0,0 +1,44 @@
|
|||
<ng-container>
|
||||
<div class="row g-0 mb-2 align-items-center">
|
||||
<div class="col-4">
|
||||
<h4>Top Readers</h4>
|
||||
</div>
|
||||
<div class="col-8">
|
||||
<form [formGroup]="formGroup" class="d-inline-flex float-end" *ngIf="isAdmin">
|
||||
<div class="d-flex">
|
||||
<label for="time-select-read-by-day" class="form-check-label"></label>
|
||||
<select id="time-select-read-by-day" class="form-select" formControlName="users"
|
||||
[class.is-invalid]="formGroup.get('users')?.invalid && formGroup.get('users')?.touched">
|
||||
<option [value]="0">All Users</option>
|
||||
<option *ngFor="let item of users$ | async" [value]="item.id">{{item.username}}</option>
|
||||
</select>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row g-0">
|
||||
<ng-container *ngIf="data$ | async as data">
|
||||
<ngx-charts-line-chart
|
||||
*ngIf="data.length > 0; else noData"
|
||||
class="dark"
|
||||
[legend]="true"
|
||||
legendTitle="Formats"
|
||||
[showXAxisLabel]="true"
|
||||
[showYAxisLabel]="true"
|
||||
[xAxis]="true"
|
||||
[yAxis]="true"
|
||||
[showGridLines]="false"
|
||||
[showRefLines]="true"
|
||||
[roundDomains]="true"
|
||||
xAxisLabel="Time"
|
||||
yAxisLabel="Reading Events"
|
||||
[timeline]="false"
|
||||
[results]="data"
|
||||
>
|
||||
</ngx-charts-line-chart>
|
||||
</ng-container>
|
||||
</div>
|
||||
<ng-template #noData>
|
||||
No Reading progress
|
||||
</ng-template>
|
||||
</ng-container>
|
|
@ -0,0 +1,3 @@
|
|||
::ng-deep .dark .ngx-charts text {
|
||||
fill: #a0aabe;
|
||||
}
|
|
@ -0,0 +1,77 @@
|
|||
import { ChangeDetectionStrategy, Component, Input, OnDestroy, OnInit } from '@angular/core';
|
||||
import { FormControl, FormGroup } from '@angular/forms';
|
||||
import { filter, map, Observable, of, shareReplay, Subject, switchMap, takeUntil } from 'rxjs';
|
||||
import { MangaFormatPipe } from 'src/app/pipe/manga-format.pipe';
|
||||
import { Member } from 'src/app/_models/auth/member';
|
||||
import { MemberService } from 'src/app/_services/member.service';
|
||||
import { StatisticsService } from 'src/app/_services/statistics.service';
|
||||
import { PieDataItem } from '../../_models/pie-data-item';
|
||||
|
||||
const options: Intl.DateTimeFormatOptions = { month: "short", day: "numeric" };
|
||||
const mangaFormatPipe = new MangaFormatPipe();
|
||||
|
||||
@Component({
|
||||
selector: 'app-read-by-day-and',
|
||||
templateUrl: './read-by-day-and.component.html',
|
||||
styleUrls: ['./read-by-day-and.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class ReadByDayAndComponent implements OnInit, OnDestroy {
|
||||
/**
|
||||
* Only show for one user
|
||||
*/
|
||||
@Input() userId: number = 0;
|
||||
@Input() isAdmin: boolean = true;
|
||||
|
||||
view: [number, number] = [0, 400];
|
||||
formGroup: FormGroup = new FormGroup({
|
||||
'users': new FormControl(-1, []),
|
||||
});
|
||||
users$: Observable<Member[]> | undefined;
|
||||
data$: Observable<Array<PieDataItem>>;
|
||||
private readonly onDestroy = new Subject<void>();
|
||||
|
||||
constructor(private statService: StatisticsService, private memberService: MemberService) {
|
||||
this.data$ = this.formGroup.get('users')!.valueChanges.pipe(
|
||||
switchMap(uId => this.statService.getReadCountByDay(uId)),
|
||||
map(data => {
|
||||
const gList = data.reduce((formats, entry) => {
|
||||
const formatTranslated = mangaFormatPipe.transform(entry.format);
|
||||
if (!formats[formatTranslated]) {
|
||||
formats[formatTranslated] = {
|
||||
name: formatTranslated,
|
||||
value: 0,
|
||||
series: []
|
||||
};
|
||||
}
|
||||
formats[formatTranslated].series.push({name: new Date(entry.value).toLocaleDateString("en-US", options), value: entry.count});
|
||||
|
||||
return formats;
|
||||
}, {});
|
||||
return Object.keys(gList).map(format => {
|
||||
return {name: format, value: 0, series: gList[format].series}
|
||||
});
|
||||
}),
|
||||
takeUntil(this.onDestroy),
|
||||
shareReplay(),
|
||||
);
|
||||
|
||||
this.data$.subscribe(_ => console.log('hi'));
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.users$ = (this.isAdmin ? this.memberService.getMembers() : of([])).pipe(filter(_ => this.isAdmin), takeUntil(this.onDestroy), shareReplay());
|
||||
this.formGroup.get('users')?.setValue(this.userId, {emitValue: true});
|
||||
|
||||
if (!this.isAdmin) {
|
||||
this.formGroup.get('users')?.disable();
|
||||
}
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.onDestroy.next();
|
||||
this.onDestroy.complete();
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -72,21 +72,22 @@
|
|||
</ng-container>
|
||||
</div>
|
||||
|
||||
<div class="grid row g-0 pt-2 pb-2">
|
||||
<div class="grid row g-0 pt-2 pb-2 d-flex justify-content-around">
|
||||
<div class="col-auto">
|
||||
<app-stat-list [data$]="releaseYears$" title="Release Years"></app-stat-list>
|
||||
<app-stat-list [data$]="releaseYears$" title="Release Years" lable="series"></app-stat-list>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<app-stat-list [data$]="mostActiveUsers$" title="Most Active Users" label="events"></app-stat-list>
|
||||
<app-stat-list [data$]="mostActiveUsers$" title="Most Active Users" label="reads"></app-stat-list>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<app-stat-list [data$]="mostActiveLibrary$" title="Popular Libraries" label="events"></app-stat-list>
|
||||
<app-stat-list [data$]="mostActiveLibrary$" title="Popular Libraries" label="reads"></app-stat-list>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<app-stat-list [data$]="mostActiveSeries$" title="Popular Series"></app-stat-list>
|
||||
<app-stat-list [data$]="mostActiveSeries$" title="Popular Series" [image]="seriesImage" [handleClick]="openSeries">
|
||||
</app-stat-list>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<app-stat-list [data$]="recentlyRead$" title="Recently Read" [handleClick]="handleRecentlyReadClick"></app-stat-list>
|
||||
<app-stat-list [data$]="recentlyRead$" title="Recently Read" [image]="seriesImage" [handleClick]="openSeries"></app-stat-list>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
@ -94,7 +95,7 @@
|
|||
<app-top-readers></app-top-readers>
|
||||
</div>
|
||||
|
||||
<div class="row g-0 pt-2 pb-2 " style="height: 242px">
|
||||
<div class="row g-0 pt-4 pb-2" style="height: 242px">
|
||||
<div class="col-md-6 col-sm-12">
|
||||
<app-file-breakdown-stats></app-file-breakdown-stats>
|
||||
</div>
|
||||
|
@ -102,5 +103,10 @@
|
|||
<app-publication-status-stats></app-publication-status-stats>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row g-0 pt-4 pb-2 " style="height: 242px">
|
||||
<div class="col-md-12 col-sm-12 mt-4 pt-2">
|
||||
<app-read-by-day-and [isAdmin]="true"></app-read-by-day-and>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
|
|
@ -1,14 +1,13 @@
|
|||
import { ChangeDetectionStrategy, Component, OnDestroy, OnInit } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { map, Observable, shareReplay, Subject, takeUntil } from 'rxjs';
|
||||
import { map, Observable, shareReplay, Subject, takeUntil, tap } from 'rxjs';
|
||||
import { DownloadService } from 'src/app/shared/_services/download.service';
|
||||
import { Series } from 'src/app/_models/series';
|
||||
import { User } from 'src/app/_models/user';
|
||||
import { ImageService } from 'src/app/_services/image.service';
|
||||
import { StatisticsService } from 'src/app/_services/statistics.service';
|
||||
import { FileExtensionBreakdown } from '../../_models/file-breakdown';
|
||||
import { PieDataItem } from '../../_models/pie-data-item';
|
||||
import { ServerStatistics } from '../../_models/server-statistics';
|
||||
import { StatCount } from '../../_models/stat-count';
|
||||
|
||||
@Component({
|
||||
selector: 'app-server-stats',
|
||||
|
@ -24,9 +23,15 @@ export class ServerStatsComponent implements OnInit, OnDestroy {
|
|||
mostActiveSeries$!: Observable<Array<PieDataItem>>;
|
||||
recentlyRead$!: Observable<Array<PieDataItem>>;
|
||||
stats$!: Observable<ServerStatistics>;
|
||||
seriesImage: (data: PieDataItem) => string;
|
||||
private readonly onDestroy = new Subject<void>();
|
||||
|
||||
constructor(private statService: StatisticsService, private router: Router) {
|
||||
constructor(private statService: StatisticsService, private router: Router, private imageService: ImageService) {
|
||||
this.seriesImage = (data: PieDataItem) => {
|
||||
if (data.extra) return this.imageService.getSeriesCoverImage(data.extra.id);
|
||||
return '';
|
||||
}
|
||||
|
||||
this.stats$ = this.statService.getServerStatistics().pipe(takeUntil(this.onDestroy), shareReplay());
|
||||
this.releaseYears$ = this.statService.getTopYears().pipe(takeUntil(this.onDestroy));
|
||||
this.mostActiveUsers$ = this.stats$.pipe(
|
||||
|
@ -46,9 +51,9 @@ export class ServerStatsComponent implements OnInit, OnDestroy {
|
|||
);
|
||||
|
||||
this.mostActiveSeries$ = this.stats$.pipe(
|
||||
map(d => d.mostActiveLibraries),
|
||||
map(d => d.mostReadSeries),
|
||||
map(counts => counts.map(count => {
|
||||
return {name: count.value.name, value: count.count};
|
||||
return {name: count.value.name, value: count.count, extra: count.value};
|
||||
})),
|
||||
takeUntil(this.onDestroy)
|
||||
);
|
||||
|
@ -60,8 +65,6 @@ export class ServerStatsComponent implements OnInit, OnDestroy {
|
|||
})),
|
||||
takeUntil(this.onDestroy)
|
||||
);
|
||||
|
||||
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
|
@ -72,7 +75,7 @@ export class ServerStatsComponent implements OnInit, OnDestroy {
|
|||
this.onDestroy.complete();
|
||||
}
|
||||
|
||||
handleRecentlyReadClick = (data: PieDataItem) => {
|
||||
openSeries = (data: PieDataItem) => {
|
||||
const series = data.extra as Series;
|
||||
this.router.navigate(['library', series.libraryId, 'series', series.id]);
|
||||
}
|
||||
|
|
|
@ -6,6 +6,9 @@
|
|||
</div>
|
||||
<ul class="list-group list-group-flush">
|
||||
<li class="list-group-item" [ngClass]="{'underline': handleClick != undefined}" *ngFor="let item of data" (click)="doClick(item)">
|
||||
<ng-container *ngIf="image && image(item) as url">
|
||||
<app-image *ngIf="url && url.length > 0" width="32px" maxHeight="32px" class="img-top me-1" [imageUrl]="url"></app-image>
|
||||
</ng-container>
|
||||
{{item.name}} <span class="float-end" *ngIf="item.value >= 0">{{item.value}} {{label}}</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
|
|
@ -24,6 +24,7 @@ export class StatListComponent {
|
|||
*/
|
||||
@Input() description: string = '';
|
||||
@Input() data$!: Observable<PieDataItem[]>;
|
||||
@Input() image: ((data: PieDataItem) => string) | undefined = undefined;
|
||||
/**
|
||||
* Optional callback handler when an item is clicked
|
||||
*/
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
<div class="row g-0 mt-4 mb-3">
|
||||
<div class="row g-0 mt-4 mb-3 d-flex justify-content-around">
|
||||
<ng-container>
|
||||
<div class="col-auto mb-2">
|
||||
<app-icon-and-title label="Total Pages Read" [clickable]="false" fontClasses="fa-regular fa-file-lines" title="Total Pages Read">
|
||||
{{totalPagesRead | number}}
|
||||
{{totalPagesRead | compactNumber}}
|
||||
</app-icon-and-title>
|
||||
</div>
|
||||
<div class="vr d-none d-lg-block m-2"></div>
|
||||
|
|
|
@ -1,13 +1,19 @@
|
|||
<div class="container-fluid">
|
||||
<div class="container-fluid" *ngIf="userId">
|
||||
|
||||
<!-- High level stats (use same design as series metadata info cards)-->
|
||||
<div class="row g-0">
|
||||
<div class="row g-0 d-flex justify-content-around">
|
||||
<ng-container *ngIf="userStats$ | async as userStats">
|
||||
<app-user-stats-info-cards [totalPagesRead]="userStats.totalPagesRead" [timeSpentReading]="userStats.timeSpentReading"
|
||||
[chaptersRead]="userStats.chaptersRead" [lastActive]="userStats.lastActive"></app-user-stats-info-cards>
|
||||
</ng-container>
|
||||
</div>
|
||||
|
||||
<div class="row g-0 pt-4 pb-2 " style="height: 242px">
|
||||
<div class="col-md-12 col-sm-12 mt-4 pt-2">
|
||||
<app-read-by-day-and [userId]="userId" [isAdmin]="(isAdmin$ | async) || false"></app-read-by-day-and>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- <div class="row g-0">
|
||||
Books Read (this can be chapters read fully)
|
||||
Number of bookmarks
|
||||
|
|
|
@ -7,6 +7,8 @@ import { SeriesService } from 'src/app/_services/series.service';
|
|||
import { StatisticsService } from 'src/app/_services/statistics.service';
|
||||
import { SortableHeader, SortEvent } from 'src/app/_single-module/table/_directives/sortable-header.directive';
|
||||
import { ReadHistoryEvent } from '../../_models/read-history-event';
|
||||
import { MemberService } from 'src/app/_services/member.service';
|
||||
import { AccountService } from 'src/app/_services/account.service';
|
||||
|
||||
type SeriesWithProgress = Series & {progress: number};
|
||||
|
||||
|
@ -18,25 +20,35 @@ type SeriesWithProgress = Series & {progress: number};
|
|||
})
|
||||
export class UserStatsComponent implements OnInit, OnDestroy {
|
||||
|
||||
@Input() userId!: number;
|
||||
|
||||
@ViewChildren(SortableHeader) headers!: QueryList<SortableHeader<SeriesWithProgress>>;
|
||||
|
||||
userId: number | undefined = undefined;
|
||||
userStats$!: Observable<UserReadStatistics>;
|
||||
readSeries$!: Observable<ReadHistoryEvent[]>;
|
||||
isAdmin$: Observable<boolean>;
|
||||
|
||||
private readonly onDestroy = new Subject<void>();
|
||||
|
||||
constructor(private readonly cdRef: ChangeDetectorRef, private statService: StatisticsService, private seriesService: SeriesService,
|
||||
private filterService: FilterUtilitiesService) { }
|
||||
constructor(private readonly cdRef: ChangeDetectorRef, private statService: StatisticsService,
|
||||
private filterService: FilterUtilitiesService, private accountService: AccountService, private memberService: MemberService) {
|
||||
this.isAdmin$ = this.accountService.currentUser$.pipe(takeUntil(this.onDestroy), map(u => {
|
||||
if (!u) return false;
|
||||
return this.accountService.hasAdminRole(u);
|
||||
}));
|
||||
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
const filter = this.filterService.createSeriesFilter();
|
||||
filter.readStatus = {read: true, notRead: false, inProgress: true};
|
||||
this.userStats$ = this.statService.getUserStatistics(this.userId).pipe(takeUntil(this.onDestroy));
|
||||
this.readSeries$ = this.statService.getReadingHistory(this.userId).pipe(
|
||||
takeUntil(this.onDestroy),
|
||||
);
|
||||
this.memberService.getMember().subscribe(me => {
|
||||
this.userId = me.id;
|
||||
this.cdRef.markForCheck();
|
||||
|
||||
this.userStats$ = this.statService.getUserStatistics(this.userId).pipe(takeUntil(this.onDestroy));
|
||||
this.readSeries$ = this.statService.getReadingHistory(this.userId).pipe(
|
||||
takeUntil(this.onDestroy),
|
||||
);
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
|
@ -44,23 +56,4 @@ export class UserStatsComponent implements OnInit, OnDestroy {
|
|||
this.onDestroy.complete();
|
||||
}
|
||||
|
||||
onSort({ column, direction }: SortEvent<SeriesWithProgress>) {
|
||||
// resetting other headers
|
||||
this.headers.forEach((header) => {
|
||||
if (header.sortable !== column) {
|
||||
header.direction = '';
|
||||
}
|
||||
});
|
||||
|
||||
// sorting countries
|
||||
// if (direction === '' || column === '') {
|
||||
// this.countries = COUNTRIES;
|
||||
// } else {
|
||||
// this.countries = [...COUNTRIES].sort((a, b) => {
|
||||
// const res = compare(a[column], b[column]);
|
||||
// return direction === 'asc' ? res : -res;
|
||||
// });
|
||||
// }
|
||||
}
|
||||
|
||||
}
|
||||
|
|
5
UI/Web/src/app/statistics/_models/line-data-item.ts
Normal file
5
UI/Web/src/app/statistics/_models/line-data-item.ts
Normal file
|
@ -0,0 +1,5 @@
|
|||
export interface LineDataItem {
|
||||
name: string;
|
||||
value: number;
|
||||
extra?: any;
|
||||
}
|
|
@ -14,6 +14,6 @@ export interface ServerStatistics {
|
|||
totalPeople: number;
|
||||
mostActiveUsers: Array<StatCount<User>>;
|
||||
mostActiveLibraries: Array<StatCount<Library>>;
|
||||
mostActiveSeries: Array<StatCount<Series>>;
|
||||
mostReadSeries: Array<StatCount<Series>>;
|
||||
recentlyRead: Array<Series>;
|
||||
}
|
|
@ -14,6 +14,7 @@ import { MangaFormatStatsComponent } from './_components/manga-format-stats/mang
|
|||
import { FileBreakdownStatsComponent } from './_components/file-breakdown-stats/file-breakdown-stats.component';
|
||||
import { PipeModule } from '../pipe/pipe.module';
|
||||
import { TopReadersComponent } from './_components/top-readers/top-readers.component';
|
||||
import { ReadByDayAndComponent } from './_components/read-by-day-and/read-by-day-and.component';
|
||||
|
||||
|
||||
|
||||
|
@ -26,7 +27,8 @@ import { TopReadersComponent } from './_components/top-readers/top-readers.compo
|
|||
PublicationStatusStatsComponent,
|
||||
MangaFormatStatsComponent,
|
||||
FileBreakdownStatsComponent,
|
||||
TopReadersComponent
|
||||
TopReadersComponent,
|
||||
ReadByDayAndComponent
|
||||
],
|
||||
imports: [
|
||||
CommonModule,
|
||||
|
|
|
@ -321,7 +321,7 @@
|
|||
<app-manage-devices></app-manage-devices>
|
||||
</ng-container>
|
||||
<ng-container *ngIf="tab.fragment === FragmentID.Stats">
|
||||
<app-user-stats [userId]="1"></app-user-stats>
|
||||
<app-user-stats></app-user-stats>
|
||||
</ng-container>
|
||||
</ng-template>
|
||||
</li>
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue