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>
/// 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));
}

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

@ -72,8 +72,6 @@ export class FileBreakdownStatsComponent implements OnInit {
}
ngOnInit(): void {
this.onDestroy.next();
this.onDestroy.complete();
}
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>
</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>

View File

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

View File

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

View File

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

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

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

View File

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

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;
mostActiveUsers: Array<StatCount<User>>;
mostActiveLibraries: Array<StatCount<Library>>;
mostActiveSeries: Array<StatCount<Series>>;
mostReadSeries: Array<StatCount<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 { 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,

View File

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

View File

@ -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": {