mirror of
https://github.com/Kareadita/Kavita.git
synced 2025-07-09 03:04:19 -04:00
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
@ -79,7 +79,7 @@ public class StatsController : BaseApiController
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns
|
||||
/// Returns users with the top reads in the server
|
||||
/// </summary>
|
||||
/// <param name="days"></param>
|
||||
/// <returns></returns>
|
||||
@ -91,6 +91,10 @@ public class StatsController : BaseApiController
|
||||
return Ok(await _statService.GetTopUsers(days));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A breakdown of different files, their size, and format
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
[Authorize("RequireAdminRole")]
|
||||
[HttpGet("server/file-breakdown")]
|
||||
[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")]
|
||||
[ResponseCache(CacheProfileName = "Statistics")]
|
||||
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));
|
||||
}
|
||||
|
@ -55,6 +55,13 @@ public class UsersController : BaseApiController
|
||||
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")]
|
||||
public async Task<ActionResult<bool>> HasReadingProgress(int libraryId)
|
||||
|
21
API/DTOs/Statistics/PagesReadOnADayCount.cs
Normal file
21
API/DTOs/Statistics/PagesReadOnADayCount.cs
Normal 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; }
|
||||
|
||||
}
|
@ -27,6 +27,7 @@ public interface IStatisticService
|
||||
Task<IEnumerable<TopReadDto>> GetTopUsers(int days);
|
||||
Task<IEnumerable<ReadHistoryEvent>> GetReadingHistory(int userId);
|
||||
Task<IEnumerable<ReadHistoryEvent>> GetHistory();
|
||||
Task<IEnumerable<PagesReadOnADayCount<DateTime>>> ReadCountByDay(int userId = 0);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -51,7 +52,6 @@ public class StatisticService : IStatisticService
|
||||
if (libraryIds.Count == 0)
|
||||
libraryIds = await _context.Library.GetUserLibraries(userId).ToListAsync();
|
||||
|
||||
|
||||
// Total Pages Read
|
||||
var totalPagesRead = await _context.AppUserProgresses
|
||||
.Where(p => p.AppUserId == userId)
|
||||
@ -226,19 +226,20 @@ public class StatisticService : IStatisticService
|
||||
.OrderByDescending(d => d.Count)
|
||||
.Take(5);
|
||||
|
||||
var seriesIds = (await _context.AppUserProgresses
|
||||
.AsSplitQuery()
|
||||
.OrderByDescending(d => d.LastModified)
|
||||
.Select(d => d.SeriesId)
|
||||
.ToListAsync())
|
||||
.Distinct()
|
||||
// Remember: Ordering does not apply if there is a distinct
|
||||
var recentlyRead = _context.AppUserProgresses
|
||||
.Join(_context.Series, p => p.SeriesId, s => s.Id,
|
||||
(appUserProgresses, series) => new
|
||||
{
|
||||
Series = series,
|
||||
AppUserProgresses = appUserProgresses
|
||||
})
|
||||
.AsEnumerable()
|
||||
.DistinctBy(s => s.AppUserProgresses.SeriesId)
|
||||
.OrderByDescending(x => x.AppUserProgresses.LastModified)
|
||||
.Select(x => _mapper.Map<SeriesDto>(x.Series))
|
||||
.Take(5);
|
||||
|
||||
var recentlyRead = _context.Series
|
||||
.AsSplitQuery()
|
||||
.Where(s => seriesIds.Contains(s.Id))
|
||||
.ProjectTo<SeriesDto>(_mapper.ConfigurationProvider)
|
||||
.AsEnumerable();
|
||||
|
||||
var distinctPeople = _context.Person
|
||||
.AsSplitQuery()
|
||||
@ -281,6 +282,7 @@ public class StatisticService : IStatisticService
|
||||
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()
|
||||
})
|
||||
.OrderBy(d => d.TotalFiles)
|
||||
.ToListAsync(),
|
||||
TotalFileSize = await _context.MangaFile
|
||||
.AsNoTracking()
|
||||
@ -310,14 +312,36 @@ public class StatisticService : IStatisticService
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public void ReadCountByDay()
|
||||
public async Task<IEnumerable<PagesReadOnADayCount<DateTime>>> ReadCountByDay(int userId = 0)
|
||||
{
|
||||
// _context.AppUserProgresses
|
||||
// .GroupBy(p => p.LastModified.Day)
|
||||
// .Select(g =>
|
||||
// {
|
||||
// Day = g.Key,
|
||||
// })
|
||||
var query = _context.AppUserProgresses
|
||||
.AsSplitQuery()
|
||||
.AsNoTracking()
|
||||
.Join(_context.Chapter, appUserProgresses => appUserProgresses.ChapterId, chapter => chapter.Id,
|
||||
(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()
|
||||
@ -329,12 +353,12 @@ public class StatisticService : IStatisticService
|
||||
// .Select(sm => new
|
||||
// {
|
||||
// User = _context.AppUser.Single(u => u.Id == sm.Key),
|
||||
// Chapters = _context.Chapter.Where(c => _context.AppUserProgresses
|
||||
// .Where(u => u.AppUserId == sm.Key)
|
||||
// .Where(p => p.PagesRead > 0)
|
||||
// .Select(p => p.ChapterId)
|
||||
// .Distinct()
|
||||
// .Contains(c.Id))
|
||||
// Chapters = _context.Chapter.Where(c => _context.AppUserProgresses
|
||||
// .Where(u => u.AppUserId == sm.Key)
|
||||
// .Where(p => p.PagesRead > 0)
|
||||
// .Select(p => p.ChapterId)
|
||||
// .Distinct()
|
||||
// .Contains(c.Id))
|
||||
// })
|
||||
// .OrderByDescending(d => d.Chapters.Sum(c => c.AvgHoursToRead))
|
||||
// .Take(5)
|
||||
|
@ -147,6 +147,7 @@ public class TaskScheduler : ITaskScheduler
|
||||
/// <summary>
|
||||
/// First time run stat collection. Executes immediately on a background thread. Does not block.
|
||||
/// </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()
|
||||
{
|
||||
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");
|
||||
return;
|
||||
}
|
||||
BackgroundJob.Enqueue(() => _statsService.Send());
|
||||
BackgroundJob.Schedule(() => _statsService.Send(), DateTimeOffset.Now.AddDays(1));
|
||||
}
|
||||
|
||||
public void ScanSiteThemes()
|
||||
|
@ -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>
|
||||
|
113
openapi.json
113
openapi.json
@ -7,7 +7,7 @@
|
||||
"name": "GPL-3.0",
|
||||
"url": "https://github.com/Kareadita/Kavita/blob/develop/LICENSE"
|
||||
},
|
||||
"version": "0.6.1.15"
|
||||
"version": "0.6.1.16"
|
||||
},
|
||||
"servers": [
|
||||
{
|
||||
@ -7874,7 +7874,7 @@
|
||||
"tags": [
|
||||
"Stats"
|
||||
],
|
||||
"summary": "Returns",
|
||||
"summary": "Returns users with the top reads in the server",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "days",
|
||||
@ -7925,6 +7925,7 @@
|
||||
"tags": [
|
||||
"Stats"
|
||||
],
|
||||
"summary": "A breakdown of different files, their size, and format",
|
||||
"responses": {
|
||||
"200": {
|
||||
"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": {
|
||||
"get": {
|
||||
"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": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@ -10161,6 +10251,25 @@
|
||||
},
|
||||
"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": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
Loading…
x
Reference in New Issue
Block a user