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:
Joe Milazzo 2022-12-15 17:28:01 -06:00 committed by GitHub
parent e43ead44da
commit 1c1e48d28c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
24 changed files with 426 additions and 86 deletions

View File

@ -79,7 +79,7 @@ public class StatsController : BaseApiController
} }
/// <summary> /// <summary>
/// Returns /// Returns users with the top reads in the server
/// </summary> /// </summary>
/// <param name="days"></param> /// <param name="days"></param>
/// <returns></returns> /// <returns></returns>
@ -91,6 +91,10 @@ public class StatsController : BaseApiController
return Ok(await _statService.GetTopUsers(days)); return Ok(await _statService.GetTopUsers(days));
} }
/// <summary>
/// A breakdown of different files, their size, and format
/// </summary>
/// <returns></returns>
[Authorize("RequireAdminRole")] [Authorize("RequireAdminRole")]
[HttpGet("server/file-breakdown")] [HttpGet("server/file-breakdown")]
[ResponseCache(CacheProfileName = "Statistics")] [ResponseCache(CacheProfileName = "Statistics")]
@ -100,11 +104,30 @@ public class StatsController : BaseApiController
} }
/// <summary>
/// Returns reading history events for a give or all users, broken up by day, and format
/// </summary>
/// <param name="userId">If 0, defaults to all users, else just userId</param>
/// <returns></returns>
[HttpGet("reading-count-by-day")]
[ResponseCache(CacheProfileName = "Statistics")]
public async Task<ActionResult<IEnumerable<PagesReadOnADayCount<DateTime>>>> ReadCountByDay(int userId = 0)
{
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
var isAdmin = User.IsInRole(PolicyConstants.AdminRole);
if (!isAdmin && userId != user.Id) return BadRequest();
return Ok(await _statService.ReadCountByDay(userId));
}
[HttpGet("user/reading-history")] [HttpGet("user/reading-history")]
[ResponseCache(CacheProfileName = "Statistics")] [ResponseCache(CacheProfileName = "Statistics")]
public async Task<ActionResult<IEnumerable<ReadHistoryEvent>>> GetReadingHistory(int userId) public async Task<ActionResult<IEnumerable<ReadHistoryEvent>>> GetReadingHistory(int userId)
{ {
// TODO: Put a check in if the calling user is said userId or has admin var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
var isAdmin = User.IsInRole(PolicyConstants.AdminRole);
if (!isAdmin && userId != user.Id) return BadRequest();
return Ok(await _statService.GetReadingHistory(userId)); return Ok(await _statService.GetReadingHistory(userId));
} }

View File

@ -55,6 +55,13 @@ public class UsersController : BaseApiController
return Ok(await _unitOfWork.UserRepository.GetPendingMemberDtosAsync()); return Ok(await _unitOfWork.UserRepository.GetPendingMemberDtosAsync());
} }
[HttpGet("myself")]
public async Task<ActionResult<IEnumerable<MemberDto>>> GetMyself()
{
var users = await _unitOfWork.UserRepository.GetAllUsersAsync();
return Ok(users.Where(u => u.UserName == User.GetUsername()).DefaultIfEmpty().Select(u => _mapper.Map<MemberDto>(u)).SingleOrDefault());
}
[HttpGet("has-reading-progress")] [HttpGet("has-reading-progress")]
public async Task<ActionResult<bool>> HasReadingProgress(int libraryId) public async Task<ActionResult<bool>> HasReadingProgress(int libraryId)

View File

@ -0,0 +1,21 @@
using System;
using API.Entities.Enums;
namespace API.DTOs.Statistics;
public class PagesReadOnADayCount<T> : ICount<T>
{
/// <summary>
/// The day of the readings
/// </summary>
public T Value { get; set; }
/// <summary>
/// Number of pages read
/// </summary>
public int Count { get; set; }
/// <summary>
/// Format of those files
/// </summary>
public MangaFormat Format { get; set; }
}

View File

@ -27,6 +27,7 @@ public interface IStatisticService
Task<IEnumerable<TopReadDto>> GetTopUsers(int days); Task<IEnumerable<TopReadDto>> GetTopUsers(int days);
Task<IEnumerable<ReadHistoryEvent>> GetReadingHistory(int userId); Task<IEnumerable<ReadHistoryEvent>> GetReadingHistory(int userId);
Task<IEnumerable<ReadHistoryEvent>> GetHistory(); Task<IEnumerable<ReadHistoryEvent>> GetHistory();
Task<IEnumerable<PagesReadOnADayCount<DateTime>>> ReadCountByDay(int userId = 0);
} }
/// <summary> /// <summary>
@ -51,7 +52,6 @@ public class StatisticService : IStatisticService
if (libraryIds.Count == 0) if (libraryIds.Count == 0)
libraryIds = await _context.Library.GetUserLibraries(userId).ToListAsync(); libraryIds = await _context.Library.GetUserLibraries(userId).ToListAsync();
// Total Pages Read // Total Pages Read
var totalPagesRead = await _context.AppUserProgresses var totalPagesRead = await _context.AppUserProgresses
.Where(p => p.AppUserId == userId) .Where(p => p.AppUserId == userId)
@ -226,19 +226,20 @@ public class StatisticService : IStatisticService
.OrderByDescending(d => d.Count) .OrderByDescending(d => d.Count)
.Take(5); .Take(5);
var seriesIds = (await _context.AppUserProgresses // Remember: Ordering does not apply if there is a distinct
.AsSplitQuery() var recentlyRead = _context.AppUserProgresses
.OrderByDescending(d => d.LastModified) .Join(_context.Series, p => p.SeriesId, s => s.Id,
.Select(d => d.SeriesId) (appUserProgresses, series) => new
.ToListAsync()) {
.Distinct() Series = series,
AppUserProgresses = appUserProgresses
})
.AsEnumerable()
.DistinctBy(s => s.AppUserProgresses.SeriesId)
.OrderByDescending(x => x.AppUserProgresses.LastModified)
.Select(x => _mapper.Map<SeriesDto>(x.Series))
.Take(5); .Take(5);
var recentlyRead = _context.Series
.AsSplitQuery()
.Where(s => seriesIds.Contains(s.Id))
.ProjectTo<SeriesDto>(_mapper.ConfigurationProvider)
.AsEnumerable();
var distinctPeople = _context.Person var distinctPeople = _context.Person
.AsSplitQuery() .AsSplitQuery()
@ -281,6 +282,7 @@ public class StatisticService : IStatisticService
TotalSize = _context.MangaFile.Where(mf2 => mf2.Extension == mf.Key).Distinct().Sum(mf2 => mf2.Bytes), TotalSize = _context.MangaFile.Where(mf2 => mf2.Extension == mf.Key).Distinct().Sum(mf2 => mf2.Bytes),
TotalFiles = _context.MangaFile.Where(mf2 => mf2.Extension == mf.Key).Distinct().Count() TotalFiles = _context.MangaFile.Where(mf2 => mf2.Extension == mf.Key).Distinct().Count()
}) })
.OrderBy(d => d.TotalFiles)
.ToListAsync(), .ToListAsync(),
TotalFileSize = await _context.MangaFile TotalFileSize = await _context.MangaFile
.AsNoTracking() .AsNoTracking()
@ -310,14 +312,36 @@ public class StatisticService : IStatisticService
.ToListAsync(); .ToListAsync();
} }
public void ReadCountByDay() public async Task<IEnumerable<PagesReadOnADayCount<DateTime>>> ReadCountByDay(int userId = 0)
{ {
// _context.AppUserProgresses var query = _context.AppUserProgresses
// .GroupBy(p => p.LastModified.Day) .AsSplitQuery()
// .Select(g => .AsNoTracking()
// { .Join(_context.Chapter, appUserProgresses => appUserProgresses.ChapterId, chapter => chapter.Id,
// Day = g.Key, (appUserProgresses, chapter) => new {appUserProgresses, chapter})
// }) .Join(_context.Volume, x => x.chapter.VolumeId, volume => volume.Id,
(x, volume) => new {x.appUserProgresses, x.chapter, volume})
.Join(_context.Series, x => x.appUserProgresses.SeriesId, series => series.Id,
(x, series) => new {x.appUserProgresses, x.chapter, x.volume, series});
if (userId > 0)
{
query = query.Where(x => x.appUserProgresses.AppUserId == userId);
}
return await query.GroupBy(x => new
{
Day = x.appUserProgresses.Created.Date,
x.series.Format
})
.Select(g => new PagesReadOnADayCount<DateTime>
{
Value = g.Key.Day,
Format = g.Key.Format,
Count = g.Count()
})
.OrderBy(d => d.Value)
.ToListAsync();
} }
public Task<IEnumerable<ReadHistoryEvent>> GetHistory() public Task<IEnumerable<ReadHistoryEvent>> GetHistory()
@ -329,12 +353,12 @@ public class StatisticService : IStatisticService
// .Select(sm => new // .Select(sm => new
// { // {
// User = _context.AppUser.Single(u => u.Id == sm.Key), // User = _context.AppUser.Single(u => u.Id == sm.Key),
// Chapters = _context.Chapter.Where(c => _context.AppUserProgresses // Chapters = _context.Chapter.Where(c => _context.AppUserProgresses
// .Where(u => u.AppUserId == sm.Key) // .Where(u => u.AppUserId == sm.Key)
// .Where(p => p.PagesRead > 0) // .Where(p => p.PagesRead > 0)
// .Select(p => p.ChapterId) // .Select(p => p.ChapterId)
// .Distinct() // .Distinct()
// .Contains(c.Id)) // .Contains(c.Id))
// }) // })
// .OrderByDescending(d => d.Chapters.Sum(c => c.AvgHoursToRead)) // .OrderByDescending(d => d.Chapters.Sum(c => c.AvgHoursToRead))
// .Take(5) // .Take(5)

View File

@ -147,6 +147,7 @@ public class TaskScheduler : ITaskScheduler
/// <summary> /// <summary>
/// First time run stat collection. Executes immediately on a background thread. Does not block. /// First time run stat collection. Executes immediately on a background thread. Does not block.
/// </summary> /// </summary>
/// <remarks>Schedules it for 1 day in the future to ensure we don't have users that try the software out</remarks>
public async Task RunStatCollection() public async Task RunStatCollection()
{ {
var allowStatCollection = (await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).AllowStatCollection; var allowStatCollection = (await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).AllowStatCollection;
@ -155,7 +156,7 @@ public class TaskScheduler : ITaskScheduler
_logger.LogDebug("User has opted out of stat collection, not sending stats"); _logger.LogDebug("User has opted out of stat collection, not sending stats");
return; return;
} }
BackgroundJob.Enqueue(() => _statsService.Send()); BackgroundJob.Schedule(() => _statsService.Send(), DateTimeOffset.Now.AddDays(1));
} }
public void ScanSiteThemes() public void ScanSiteThemes()

View File

@ -47,5 +47,9 @@ export class MemberService {
removeSeriesToWantToRead(seriesIds: Array<number>) { removeSeriesToWantToRead(seriesIds: Array<number>) {
return this.httpClient.post<Array<Member>>(this.baseUrl + 'want-to-read/remove-series', {seriesIds}); return this.httpClient.post<Array<Member>>(this.baseUrl + 'want-to-read/remove-series', {seriesIds});
} }
getMember() {
return this.httpClient.get<Member>(this.baseUrl + 'users/myself');
}
} }

View File

@ -81,4 +81,8 @@ export class StatisticsService {
getFileBreakdown() { getFileBreakdown() {
return this.httpClient.get<FileExtensionBreakdown>(this.baseUrl + 'stats/server/file-breakdown'); 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);
}
} }

View File

@ -17,11 +17,11 @@ export class ManagaReaderService {
this.renderer = rendererFactory.createRenderer(null, null); this.renderer = rendererFactory.createRenderer(null, null);
} }
loadPageDimensions(dims: Array<FileDimension>) { loadPageDimensions(dims: Array<FileDimension>) {
this.pageDimensions = {}; this.pageDimensions = {};
let counter = 0; let counter = 0;
let i = 0; let i = 0;
dims.forEach(d => { dims.forEach(d => {
const isWide = (d.width > d.height); const isWide = (d.width > d.height);
this.pageDimensions[d.pageNumber] = { this.pageDimensions[d.pageNumber] = {
@ -30,16 +30,22 @@ export class ManagaReaderService {
isWide: isWide isWide: isWide
}; };
//console.log('Page Number: ', d.pageNumber);
if (isWide) { if (isWide) {
console.log('\tPage is wide, counter: ', counter, 'i: ', i);
this.pairs[d.pageNumber] = d.pageNumber; this.pairs[d.pageNumber] = d.pageNumber;
//this.pairs[d.pageNumber] = this.pairs[d.pageNumber - 1] + 1;
} else { } else {
//console.log('\tPage is single, counter: ', counter, 'i: ', i);
this.pairs[d.pageNumber] = counter % 2 === 0 ? Math.max(i - 1, 0) : counter; this.pairs[d.pageNumber] = counter % 2 === 0 ? Math.max(i - 1, 0) : counter;
counter++; counter++;
} }
//console.log('\t\tMapped to ', this.pairs[d.pageNumber]);
i++; i++;
}); });
console.log('pairs: ', this.pairs); //console.log('pairs: ', this.pairs);
} }
adjustForDoubleReader(page: number) { adjustForDoubleReader(page: number) {

View File

@ -72,8 +72,6 @@ export class FileBreakdownStatsComponent implements OnInit {
} }
ngOnInit(): void { ngOnInit(): void {
this.onDestroy.next();
this.onDestroy.complete();
} }
ngOnDestroy(): void { ngOnDestroy(): void {

View File

@ -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>

View File

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

View File

@ -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();
}
}

View File

@ -72,21 +72,22 @@
</ng-container> </ng-container>
</div> </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"> <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>
<div class="col-auto"> <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>
<div class="col-auto"> <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>
<div class="col-auto"> <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>
<div class="col-auto"> <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>
</div> </div>
@ -94,7 +95,7 @@
<app-top-readers></app-top-readers> <app-top-readers></app-top-readers>
</div> </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"> <div class="col-md-6 col-sm-12">
<app-file-breakdown-stats></app-file-breakdown-stats> <app-file-breakdown-stats></app-file-breakdown-stats>
</div> </div>
@ -102,5 +103,10 @@
<app-publication-status-stats></app-publication-status-stats> <app-publication-status-stats></app-publication-status-stats>
</div> </div>
</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> </div>

View File

@ -1,14 +1,13 @@
import { ChangeDetectionStrategy, Component, OnDestroy, OnInit } from '@angular/core'; import { ChangeDetectionStrategy, Component, OnDestroy, OnInit } from '@angular/core';
import { Router } from '@angular/router'; 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 { DownloadService } from 'src/app/shared/_services/download.service';
import { Series } from 'src/app/_models/series'; import { Series } from 'src/app/_models/series';
import { User } from 'src/app/_models/user'; import { User } from 'src/app/_models/user';
import { ImageService } from 'src/app/_services/image.service';
import { StatisticsService } from 'src/app/_services/statistics.service'; import { StatisticsService } from 'src/app/_services/statistics.service';
import { FileExtensionBreakdown } from '../../_models/file-breakdown';
import { PieDataItem } from '../../_models/pie-data-item'; import { PieDataItem } from '../../_models/pie-data-item';
import { ServerStatistics } from '../../_models/server-statistics'; import { ServerStatistics } from '../../_models/server-statistics';
import { StatCount } from '../../_models/stat-count';
@Component({ @Component({
selector: 'app-server-stats', selector: 'app-server-stats',
@ -24,9 +23,15 @@ export class ServerStatsComponent implements OnInit, OnDestroy {
mostActiveSeries$!: Observable<Array<PieDataItem>>; mostActiveSeries$!: Observable<Array<PieDataItem>>;
recentlyRead$!: Observable<Array<PieDataItem>>; recentlyRead$!: Observable<Array<PieDataItem>>;
stats$!: Observable<ServerStatistics>; stats$!: Observable<ServerStatistics>;
seriesImage: (data: PieDataItem) => string;
private readonly onDestroy = new Subject<void>(); 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.stats$ = this.statService.getServerStatistics().pipe(takeUntil(this.onDestroy), shareReplay());
this.releaseYears$ = this.statService.getTopYears().pipe(takeUntil(this.onDestroy)); this.releaseYears$ = this.statService.getTopYears().pipe(takeUntil(this.onDestroy));
this.mostActiveUsers$ = this.stats$.pipe( this.mostActiveUsers$ = this.stats$.pipe(
@ -46,9 +51,9 @@ export class ServerStatsComponent implements OnInit, OnDestroy {
); );
this.mostActiveSeries$ = this.stats$.pipe( this.mostActiveSeries$ = this.stats$.pipe(
map(d => d.mostActiveLibraries), map(d => d.mostReadSeries),
map(counts => counts.map(count => { 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) takeUntil(this.onDestroy)
); );
@ -60,8 +65,6 @@ export class ServerStatsComponent implements OnInit, OnDestroy {
})), })),
takeUntil(this.onDestroy) takeUntil(this.onDestroy)
); );
} }
ngOnInit(): void { ngOnInit(): void {
@ -72,7 +75,7 @@ export class ServerStatsComponent implements OnInit, OnDestroy {
this.onDestroy.complete(); this.onDestroy.complete();
} }
handleRecentlyReadClick = (data: PieDataItem) => { openSeries = (data: PieDataItem) => {
const series = data.extra as Series; const series = data.extra as Series;
this.router.navigate(['library', series.libraryId, 'series', series.id]); this.router.navigate(['library', series.libraryId, 'series', series.id]);
} }

View File

@ -6,6 +6,9 @@
</div> </div>
<ul class="list-group list-group-flush"> <ul class="list-group list-group-flush">
<li class="list-group-item" [ngClass]="{'underline': handleClick != undefined}" *ngFor="let item of data" (click)="doClick(item)"> <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> {{item.name}} <span class="float-end" *ngIf="item.value >= 0">{{item.value}} {{label}}</span>
</li> </li>
</ul> </ul>

View File

@ -24,6 +24,7 @@ export class StatListComponent {
*/ */
@Input() description: string = ''; @Input() description: string = '';
@Input() data$!: Observable<PieDataItem[]>; @Input() data$!: Observable<PieDataItem[]>;
@Input() image: ((data: PieDataItem) => string) | undefined = undefined;
/** /**
* Optional callback handler when an item is clicked * Optional callback handler when an item is clicked
*/ */

View File

@ -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> <ng-container>
<div class="col-auto mb-2"> <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"> <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> </app-icon-and-title>
</div> </div>
<div class="vr d-none d-lg-block m-2"></div> <div class="vr d-none d-lg-block m-2"></div>

View File

@ -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)--> <!-- 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"> <ng-container *ngIf="userStats$ | async as userStats">
<app-user-stats-info-cards [totalPagesRead]="userStats.totalPagesRead" [timeSpentReading]="userStats.timeSpentReading" <app-user-stats-info-cards [totalPagesRead]="userStats.totalPagesRead" [timeSpentReading]="userStats.timeSpentReading"
[chaptersRead]="userStats.chaptersRead" [lastActive]="userStats.lastActive"></app-user-stats-info-cards> [chaptersRead]="userStats.chaptersRead" [lastActive]="userStats.lastActive"></app-user-stats-info-cards>
</ng-container> </ng-container>
</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 [userId]="userId" [isAdmin]="(isAdmin$ | async) || false"></app-read-by-day-and>
</div>
</div>
<!-- <div class="row g-0"> <!-- <div class="row g-0">
Books Read (this can be chapters read fully) Books Read (this can be chapters read fully)
Number of bookmarks Number of bookmarks

View File

@ -7,6 +7,8 @@ import { SeriesService } from 'src/app/_services/series.service';
import { StatisticsService } from 'src/app/_services/statistics.service'; import { StatisticsService } from 'src/app/_services/statistics.service';
import { SortableHeader, SortEvent } from 'src/app/_single-module/table/_directives/sortable-header.directive'; import { SortableHeader, SortEvent } from 'src/app/_single-module/table/_directives/sortable-header.directive';
import { ReadHistoryEvent } from '../../_models/read-history-event'; 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}; type SeriesWithProgress = Series & {progress: number};
@ -18,25 +20,35 @@ type SeriesWithProgress = Series & {progress: number};
}) })
export class UserStatsComponent implements OnInit, OnDestroy { export class UserStatsComponent implements OnInit, OnDestroy {
@Input() userId!: number; userId: number | undefined = undefined;
@ViewChildren(SortableHeader) headers!: QueryList<SortableHeader<SeriesWithProgress>>;
userStats$!: Observable<UserReadStatistics>; userStats$!: Observable<UserReadStatistics>;
readSeries$!: Observable<ReadHistoryEvent[]>; readSeries$!: Observable<ReadHistoryEvent[]>;
isAdmin$: Observable<boolean>;
private readonly onDestroy = new Subject<void>(); private readonly onDestroy = new Subject<void>();
constructor(private readonly cdRef: ChangeDetectorRef, private statService: StatisticsService, private seriesService: SeriesService, constructor(private readonly cdRef: ChangeDetectorRef, private statService: StatisticsService,
private filterService: FilterUtilitiesService) { } 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 { ngOnInit(): void {
const filter = this.filterService.createSeriesFilter(); const filter = this.filterService.createSeriesFilter();
filter.readStatus = {read: true, notRead: false, inProgress: true}; filter.readStatus = {read: true, notRead: false, inProgress: true};
this.userStats$ = this.statService.getUserStatistics(this.userId).pipe(takeUntil(this.onDestroy)); this.memberService.getMember().subscribe(me => {
this.readSeries$ = this.statService.getReadingHistory(this.userId).pipe( this.userId = me.id;
takeUntil(this.onDestroy), 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 { ngOnDestroy(): void {
@ -44,23 +56,4 @@ export class UserStatsComponent implements OnInit, OnDestroy {
this.onDestroy.complete(); 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;
// });
// }
}
} }

View File

@ -0,0 +1,5 @@
export interface LineDataItem {
name: string;
value: number;
extra?: any;
}

View File

@ -14,6 +14,6 @@ export interface ServerStatistics {
totalPeople: number; totalPeople: number;
mostActiveUsers: Array<StatCount<User>>; mostActiveUsers: Array<StatCount<User>>;
mostActiveLibraries: Array<StatCount<Library>>; mostActiveLibraries: Array<StatCount<Library>>;
mostActiveSeries: Array<StatCount<Series>>; mostReadSeries: Array<StatCount<Series>>;
recentlyRead: Array<Series>; recentlyRead: Array<Series>;
} }

View File

@ -14,6 +14,7 @@ import { MangaFormatStatsComponent } from './_components/manga-format-stats/mang
import { FileBreakdownStatsComponent } from './_components/file-breakdown-stats/file-breakdown-stats.component'; import { FileBreakdownStatsComponent } from './_components/file-breakdown-stats/file-breakdown-stats.component';
import { PipeModule } from '../pipe/pipe.module'; import { PipeModule } from '../pipe/pipe.module';
import { TopReadersComponent } from './_components/top-readers/top-readers.component'; 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, PublicationStatusStatsComponent,
MangaFormatStatsComponent, MangaFormatStatsComponent,
FileBreakdownStatsComponent, FileBreakdownStatsComponent,
TopReadersComponent TopReadersComponent,
ReadByDayAndComponent
], ],
imports: [ imports: [
CommonModule, CommonModule,

View File

@ -321,7 +321,7 @@
<app-manage-devices></app-manage-devices> <app-manage-devices></app-manage-devices>
</ng-container> </ng-container>
<ng-container *ngIf="tab.fragment === FragmentID.Stats"> <ng-container *ngIf="tab.fragment === FragmentID.Stats">
<app-user-stats [userId]="1"></app-user-stats> <app-user-stats></app-user-stats>
</ng-container> </ng-container>
</ng-template> </ng-template>
</li> </li>

View File

@ -7,7 +7,7 @@
"name": "GPL-3.0", "name": "GPL-3.0",
"url": "https://github.com/Kareadita/Kavita/blob/develop/LICENSE" "url": "https://github.com/Kareadita/Kavita/blob/develop/LICENSE"
}, },
"version": "0.6.1.15" "version": "0.6.1.16"
}, },
"servers": [ "servers": [
{ {
@ -7874,7 +7874,7 @@
"tags": [ "tags": [
"Stats" "Stats"
], ],
"summary": "Returns", "summary": "Returns users with the top reads in the server",
"parameters": [ "parameters": [
{ {
"name": "days", "name": "days",
@ -7925,6 +7925,7 @@
"tags": [ "tags": [
"Stats" "Stats"
], ],
"summary": "A breakdown of different files, their size, and format",
"responses": { "responses": {
"200": { "200": {
"description": "Success", "description": "Success",
@ -7958,6 +7959,57 @@
} }
} }
}, },
"/api/Stats/reading-count-by-day": {
"get": {
"tags": [
"Stats"
],
"summary": "Returns reading history events for a give or all users, broken up by day, and format",
"parameters": [
{
"name": "userId",
"in": "query",
"description": "If 0, defaults to all users, else just userId",
"schema": {
"type": "integer",
"format": "int32",
"default": 0
}
}
],
"responses": {
"200": {
"description": "Success",
"content": {
"text/plain": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/DateTimePagesReadOnADayCount"
}
}
},
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/DateTimePagesReadOnADayCount"
}
}
},
"text/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/DateTimePagesReadOnADayCount"
}
}
}
}
}
}
}
},
"/api/Stats/user/reading-history": { "/api/Stats/user/reading-history": {
"get": { "get": {
"tags": [ "tags": [
@ -8562,6 +8614,44 @@
} }
} }
}, },
"/api/Users/myself": {
"get": {
"tags": [
"Users"
],
"responses": {
"200": {
"description": "Success",
"content": {
"text/plain": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/MemberDto"
}
}
},
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/MemberDto"
}
}
},
"text/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/MemberDto"
}
}
}
}
}
}
}
},
"/api/Users/has-reading-progress": { "/api/Users/has-reading-progress": {
"get": { "get": {
"tags": [ "tags": [
@ -10161,6 +10251,25 @@
}, },
"additionalProperties": false "additionalProperties": false
}, },
"DateTimePagesReadOnADayCount": {
"type": "object",
"properties": {
"value": {
"type": "string",
"description": "The day of the readings",
"format": "date-time"
},
"count": {
"type": "integer",
"description": "Number of pages read",
"format": "int32"
},
"format": {
"$ref": "#/components/schemas/MangaFormat"
}
},
"additionalProperties": false
},
"DeleteSeriesDto": { "DeleteSeriesDto": {
"type": "object", "type": "object",
"properties": { "properties": {