Feature/stats finishoff (#1720)

* Added ability to click on genres, tags, and people to view all items in a modal.

* Made it so we can click and open a filtered search from generic list

* Fixed broken epub pagination area due to a typo in a query selector

* Added day breakdown, wrapping up stats
This commit is contained in:
Joe Milazzo 2023-01-03 19:41:10 -06:00 committed by GitHub
parent dfbc8da427
commit 02daa5ed56
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
20 changed files with 378 additions and 66 deletions

View file

@ -1,4 +1,4 @@
import { HttpClient, HttpParams } from '@angular/common/http';
import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { environment } from 'src/environments/environment';
import { UserReadStatistics } from '../statistics/_models/user-read-statistics';
@ -13,6 +13,16 @@ import { StatCount } from '../statistics/_models/stat-count';
import { PublicationStatus } from '../_models/metadata/publication-status';
import { MangaFormat } from '../_models/manga-format';
export enum DayOfWeek
{
Sunday = 0,
Monday = 1,
Tuesday = 2,
Wednesday = 3,
Thursday = 4,
Friday = 5,
Saturday = 6,
}
const publicationStatusPipe = new PublicationStatusPipe();
const mangaFormatPipe = new MangaFormatPipe();
@ -85,4 +95,8 @@ export class StatisticsService {
getReadCountByDay(userId: number = 0, days: number = 0) {
return this.httpClient.get<Array<any>>(this.baseUrl + 'stats/reading-count-by-day?userId=' + userId + '&days=' + days);
}
getDayBreakdown() {
return this.httpClient.get<Array<StatCount<DayOfWeek>>>(this.baseUrl + 'stats/day-breakdown');
}
}

View file

@ -1,5 +1,4 @@
import { Component, Input, OnInit } from '@angular/core';
import { FormBuilder } from '@angular/forms';
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
import { Library } from 'src/app/_models/library';
import { Member } from 'src/app/_models/auth/member';
@ -24,7 +23,7 @@ export class LibraryAccessModalComponent implements OnInit {
return this.selections != null && this.selections.hasSomeSelected();
}
constructor(public modal: NgbActiveModal, private libraryService: LibraryService, private fb: FormBuilder) { }
constructor(public modal: NgbActiveModal, private libraryService: LibraryService) { }
ngOnInit(): void {
this.libraryService.getLibraries().subscribe(libs => {

View file

@ -256,7 +256,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
/**
* book-content class
*/
@ViewChild('bookContentElemRef', {static: false}) bookContentElemRef!: ElementRef<HTMLDivElement>;
@ViewChild('readingHtml', {static: false}) bookContentElemRef!: ElementRef<HTMLDivElement>;
@ViewChild('readingSection', {static: false}) readingSectionElemRef!: ElementRef<HTMLDivElement>;
@ViewChild('stickyTop', {static: false}) stickyTopElemRef!: ElementRef<HTMLDivElement>;
@ViewChild('reader', {static: true}) reader!: ElementRef;
@ -382,7 +382,6 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
get PageHeightForPagination() {
if (this.layoutMode === BookPageLayoutMode.Default) {
// if the book content is less than the height of the container, override and return height of container for pagination area
if (this.bookContainerElemRef?.nativeElement?.clientHeight > this.bookContentElemRef?.nativeElement?.clientHeight) {
return (this.bookContainerElemRef?.nativeElement?.clientHeight || 0) + 'px';

View file

@ -1,3 +1,4 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { DashboardRoutingModule } from './dashboard-routing.module';

View file

@ -60,6 +60,10 @@ export class AddToListModalComponent implements OnInit, AfterViewInit {
@ViewChild('title') inputElem!: ElementRef<HTMLInputElement>;
filterList = (listItem: ReadingList) => {
return listItem.title.toLowerCase().indexOf((this.listForm.value.filterQuery || '').toLowerCase()) >= 0;
}
constructor(private modal: NgbActiveModal, private readingListService: ReadingListService, private toastr: ToastrService) { }
@ -128,9 +132,4 @@ export class AddToListModalComponent implements OnInit, AfterViewInit {
}
}
filterList = (listItem: ReadingList) => {
return listItem.title.toLowerCase().indexOf((this.listForm.value.filterQuery || '').toLowerCase()) >= 0;
}
}

View file

@ -0,0 +1,28 @@
<div class="modal-header">
<h4 class="modal-title" id="modal-basic-title">{{title}}</h4>
<button type="button" class="btn-close" aria-label="Close" (click)="close()"></button>
</div>
<div class="modal-body">
<form style="width: 100%" [formGroup]="listForm">
<div class="mb-3" *ngIf="items.length >= 5">
<label for="filter" class="form-label">Filter</label>
<div class="input-group">
<input id="filter" autocomplete="off" class="form-control" formControlName="filterQuery" type="text" aria-describedby="reset-input">
<button class="btn btn-outline-secondary" type="button" id="reset-input" (click)="listForm.get('filterQuery')?.setValue('');">Clear</button>
</div>
</div>
<div class="list-group">
<li class="list-group-item d-flex justify-content-between align-items-center" *ngFor="let item of items | filter: filterList; let i = index">
{{item}}
<button class="btn btn-primary" [disabled]="clicked === undefined" (click)="handleClick(item)">
<i class="fa-solid fa-arrow-up-right-from-square" aria-hidden="true"></i>
<span class="visually-hidden">Open a filtered search for {{item}}</span>
</button>
</li>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-primary" (click)="close()">Close</button>
</div>

View file

@ -0,0 +1,7 @@
.list-group-item.no-click {
cursor: not-allowed;
}
.list-group-item {
cursor: pointer;
}

View file

@ -0,0 +1,34 @@
import { Component, EventEmitter, Input } from '@angular/core';
import { FormControl, FormGroup } from '@angular/forms';
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
@Component({
selector: 'app-generic-list-modal',
templateUrl: './generic-list-modal.component.html',
styleUrls: ['./generic-list-modal.component.scss']
})
export class GenericListModalComponent {
@Input() items: Array<string> = [];
@Input() title: string = '';
@Input() clicked: ((item: string) => void) | undefined = undefined;
listForm: FormGroup = new FormGroup({
'filterQuery': new FormControl('', [])
});
filterList = (listItem: string) => {
return listItem.toLowerCase().indexOf((this.listForm.value.filterQuery || '').toLowerCase()) >= 0;
}
constructor(private modal: NgbActiveModal) {}
close() {
this.modal.close();
}
handleClick(item: string) {
if (this.clicked) {
this.clicked(item);
}
}
}

View file

@ -0,0 +1,16 @@
<div class="row g-0 mb-2">
<h4>Day Breakdown</h4>
</div>
<ngx-charts-bar-vertical
class="dark"
[view]="view"
[results]="dayBreakdown$ | async"
[xAxis]="true"
[yAxis]="true"
[legend]="showLegend"
[showXAxisLabel]="true"
[showYAxisLabel]="true"
xAxisLabel="Day of Week"
yAxisLabel="Reading Events">
</ngx-charts-bar-vertical>

View file

@ -0,0 +1,3 @@
::ng-deep .dark .ngx-charts text {
fill: #a0aabe;
}

View file

@ -0,0 +1,55 @@
import { Component, OnInit } from '@angular/core';
import { FormControl } from '@angular/forms';
import { LegendPosition } from '@swimlane/ngx-charts';
import { Subject, combineLatest, map, takeUntil, Observable } from 'rxjs';
import { DayOfWeek, StatisticsService } from 'src/app/_services/statistics.service';
import { compare } from 'src/app/_single-module/table/_directives/sortable-header.directive';
import { PieDataItem } from '../../_models/pie-data-item';
import { StatCount } from '../../_models/stat-count';
import { DayOfWeekPipe } from '../../_pipes/day-of-week.pipe';
@Component({
selector: 'app-day-breakdown',
templateUrl: './day-breakdown.component.html',
styleUrls: ['./day-breakdown.component.scss']
})
export class DayBreakdownComponent implements OnInit {
private readonly onDestroy = new Subject<void>();
view: [number, number] = [700, 400];
gradient: boolean = true;
showLegend: boolean = true;
showLabels: boolean = true;
isDoughnut: boolean = false;
legendPosition: LegendPosition = LegendPosition.Right;
colorScheme = {
domain: ['#5AA454', '#A10A28', '#C7B42C', '#AAAAAA']
};
formControl: FormControl = new FormControl(true, []);
dayBreakdown$!: Observable<Array<PieDataItem>>;
constructor(private statService: StatisticsService) {
const dayOfWeekPipe = new DayOfWeekPipe();
this.dayBreakdown$ = this.statService.getDayBreakdown().pipe(
map((data: Array<StatCount<DayOfWeek>>) => {
return data.map(d => {
return {name: dayOfWeekPipe.transform(d.value), value: d.count};
})
}),
takeUntil(this.onDestroy)
);
}
ngOnInit(): void {
this.onDestroy.next();
this.onDestroy.complete();
}
ngOnDestroy(): void {
this.onDestroy.next();
this.onDestroy.complete();
}
}

View file

@ -59,7 +59,7 @@ export class ReadByDayAndComponent implements OnInit, OnDestroy {
shareReplay(),
);
this.data$.subscribe(_ => console.log('hi'));
this.data$.subscribe();
}
ngOnInit(): void {

View file

@ -2,7 +2,7 @@
<div class="row g-0 mt-4 mb-3 d-flex justify-content-around" *ngIf="stats$ | async as stats">
<ng-container>
<div class="col-auto mb-2">
<app-icon-and-title label="Total Series" [clickable]="false" fontClasses="fa-regular fa-calendar" title="Total Series">
<app-icon-and-title label="Total Series" [clickable]="false" fontClasses="fa-solid fa-book-open" title="Total Series">
{{stats.seriesCount | compactNumber}} Series
</app-icon-and-title>
</div>
@ -11,7 +11,7 @@
<ng-container >
<div class="col-auto mb-2">
<app-icon-and-title label="Total Volumes" [clickable]="false" fontClasses="fas fa-eye" title="Total Volumes">
<app-icon-and-title label="Total Volumes" [clickable]="false" fontClasses="fas fa-book" title="Total Volumes">
{{stats.volumeCount | compactNumber}} Volumes
</app-icon-and-title>
</div>
@ -38,7 +38,7 @@
<ng-container>
<div class="col-auto mb-2">
<app-icon-and-title label="Total Size" [clickable]="false" fontClasses="fa-solid fa-weight-scale" title="Total Size">
<app-icon-and-title label="Total Size" [clickable]="false" fontClasses="fa-solid fa-scale-unbalanced" title="Total Size">
{{stats.totalSize | bytes}}
</app-icon-and-title>
</div>
@ -47,7 +47,7 @@
<ng-container>
<div class="col-auto mb-2">
<app-icon-and-title label="Total Genres" [clickable]="false" fontClasses="fa-solid fa-tags" title="Total Genres">
<app-icon-and-title label="Total Genres" [clickable]="true" fontClasses="fa-solid fa-tags" title="Total Genres" (click)="openGenreList();$event.stopPropagation();">
{{stats.totalGenres | compactNumber}} Genres
</app-icon-and-title>
</div>
@ -56,7 +56,7 @@
<ng-container>
<div class="col-auto mb-2">
<app-icon-and-title label="Total Tags" [clickable]="false" fontClasses="fa-solid fa-tags" title="Total Tags">
<app-icon-and-title label="Total Tags" [clickable]="true" fontClasses="fa-solid fa-tags" title="Total Tags" (click)="openTagList();$event.stopPropagation();">
{{stats.totalTags | compactNumber}} Tags
</app-icon-and-title>
</div>
@ -65,7 +65,7 @@
<ng-container>
<div class="col-auto mb-2">
<app-icon-and-title label="Total People" [clickable]="false" fontClasses="fa-solid fa-user-tag" title="Total People">
<app-icon-and-title label="Total People" [clickable]="true" fontClasses="fa-solid fa-user-tag" title="Total People" (click)="openPeopleList();$event.stopPropagation();">
{{stats.totalPeople | compactNumber}} People
</app-icon-and-title>
</div>
@ -74,7 +74,7 @@
<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" lable="series"></app-stat-list>
<app-stat-list [data$]="releaseYears$" title="Release Years" label="series"></app-stat-list>
</div>
<div class="col-auto">
<app-stat-list [data$]="mostActiveUsers$" title="Most Active Users" label="reads"></app-stat-list>
@ -109,4 +109,10 @@
</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-day-breakdown></app-day-breakdown>
</div>
</div>
</div>

View file

@ -1,13 +1,15 @@
import { ChangeDetectionStrategy, Component, OnDestroy, OnInit } from '@angular/core';
import { Router } from '@angular/router';
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
import { map, Observable, shareReplay, Subject, takeUntil, tap } from 'rxjs';
import { DownloadService } from 'src/app/shared/_services/download.service';
import { FilterQueryParam } from 'src/app/shared/_services/filter-utilities.service';
import { Series } from 'src/app/_models/series';
import { User } from 'src/app/_models/user';
import { ImageService } from 'src/app/_services/image.service';
import { MetadataService } from 'src/app/_services/metadata.service';
import { StatisticsService } from 'src/app/_services/statistics.service';
import { PieDataItem } from '../../_models/pie-data-item';
import { ServerStatistics } from '../../_models/server-statistics';
import { GenericListModalComponent } from '../_modals/generic-list-modal/generic-list-modal.component';
@Component({
selector: 'app-server-stats',
@ -25,8 +27,13 @@ export class ServerStatsComponent implements OnInit, OnDestroy {
stats$!: Observable<ServerStatistics>;
seriesImage: (data: PieDataItem) => string;
private readonly onDestroy = new Subject<void>();
openSeries = (data: PieDataItem) => {
const series = data.extra as Series;
this.router.navigate(['library', series.libraryId, 'series', series.id]);
}
constructor(private statService: StatisticsService, private router: Router, private imageService: ImageService) {
constructor(private statService: StatisticsService, private router: Router, private imageService: ImageService,
private metadataService: MetadataService, private modalService: NgbModal) {
this.seriesImage = (data: PieDataItem) => {
if (data.extra) return this.imageService.getSeriesCoverImage(data.extra.id);
return '';
@ -75,9 +82,40 @@ export class ServerStatsComponent implements OnInit, OnDestroy {
this.onDestroy.complete();
}
openSeries = (data: PieDataItem) => {
const series = data.extra as Series;
this.router.navigate(['library', series.libraryId, 'series', series.id]);
openGenreList() {
this.metadataService.getAllGenres().subscribe(genres => {
const ref = this.modalService.open(GenericListModalComponent, { scrollable: true });
ref.componentInstance.items = genres.map(t => t.title);
ref.componentInstance.title = 'Genres';
ref.componentInstance.clicked = (item: string) => {
const params: any = {};
params[FilterQueryParam.Genres] = item;
params[FilterQueryParam.Page] = 1;
this.router.navigate(['all-series'], {queryParams: params});
};
});
}
openTagList() {
this.metadataService.getAllTags().subscribe(tags => {
const ref = this.modalService.open(GenericListModalComponent, { scrollable: true });
ref.componentInstance.items = tags.map(t => t.title);
ref.componentInstance.title = 'Tags';
ref.componentInstance.clicked = (item: string) => {
const params: any = {};
params[FilterQueryParam.Tags] = item;
params[FilterQueryParam.Page] = 1;
this.router.navigate(['all-series'], {queryParams: params});
};
});
}
openPeopleList() {
this.metadataService.getAllPeople().subscribe(people => {
const ref = this.modalService.open(GenericListModalComponent, { scrollable: true });
ref.componentInstance.items = [...new Set(people.map(person => person.name))];
ref.componentInstance.title = 'People';
});
}

View file

@ -0,0 +1,22 @@
import { Pipe, PipeTransform } from '@angular/core';
import { DayOfWeek } from 'src/app/_services/statistics.service';
@Pipe({
name: 'dayOfWeek'
})
export class DayOfWeekPipe implements PipeTransform {
transform(value: DayOfWeek): string {
switch(value) {
case DayOfWeek.Monday: return 'Monday';
case DayOfWeek.Tuesday: return 'Tuesday';
case DayOfWeek.Wednesday: return 'Wednesday';
case DayOfWeek.Thursday: return 'Thursday';
case DayOfWeek.Friday: return 'Friday';
case DayOfWeek.Saturday: return 'Saturday';
case DayOfWeek.Sunday: return 'Sunday';
}
}
}

View file

@ -7,7 +7,7 @@ import { SharedModule } from '../shared/shared.module';
import { ServerStatsComponent } from './_components/server-stats/server-stats.component';
import { NgxChartsModule } from '@swimlane/ngx-charts';
import { StatListComponent } from './_components/stat-list/stat-list.component';
import { NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap';
import { NgbModalModule, NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap';
import { PublicationStatusStatsComponent } from './_components/publication-status-stats/publication-status-stats.component';
import { ReactiveFormsModule } from '@angular/forms';
import { MangaFormatStatsComponent } from './_components/manga-format-stats/manga-format-stats.component';
@ -15,6 +15,9 @@ import { FileBreakdownStatsComponent } from './_components/file-breakdown-stats/
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';
import { GenericListModalComponent } from './_components/_modals/generic-list-modal/generic-list-modal.component';
import { DayBreakdownComponent } from './_components/day-breakdown/day-breakdown.component';
import { DayOfWeekPipe } from './_pipes/day-of-week.pipe';
@ -28,13 +31,17 @@ import { ReadByDayAndComponent } from './_components/read-by-day-and/read-by-day
MangaFormatStatsComponent,
FileBreakdownStatsComponent,
TopReadersComponent,
ReadByDayAndComponent
ReadByDayAndComponent,
GenericListModalComponent,
DayBreakdownComponent,
DayOfWeekPipe
],
imports: [
CommonModule,
TableModule,
SharedModule,
NgbTooltipModule,
NgbModalModule,
ReactiveFormsModule,
PipeModule,