From 1c1e48d28c333ce76d60a276d11225591d809ccb Mon Sep 17 00:00:00 2001 From: Joe Milazzo Date: Thu, 15 Dec 2022 17:28:01 -0600 Subject: [PATCH] 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. --- API/Controllers/StatsController.cs | 27 ++++- API/Controllers/UsersController.cs | 7 ++ API/DTOs/Statistics/PagesReadOnADayCount.cs | 21 ++++ API/Services/StatisticService.cs | 74 ++++++++---- API/Services/TaskScheduler.cs | 3 +- UI/Web/src/app/_services/member.service.ts | 4 + .../src/app/_services/statistics.service.ts | 4 + .../_series/managa-reader.service.ts | 10 +- .../file-breakdown-stats.component.ts | 2 - .../read-by-day-and.component.html | 44 +++++++ .../read-by-day-and.component.scss | 3 + .../read-by-day-and.component.ts | 77 ++++++++++++ .../server-stats/server-stats.component.html | 20 ++-- .../server-stats/server-stats.component.ts | 21 ++-- .../stat-list/stat-list.component.html | 3 + .../stat-list/stat-list.component.ts | 1 + .../user-stats-info-cards.component.html | 4 +- .../user-stats/user-stats.component.html | 10 +- .../user-stats/user-stats.component.ts | 51 ++++---- .../app/statistics/_models/line-data-item.ts | 5 + .../statistics/_models/server-statistics.ts | 2 +- .../src/app/statistics/statistics.module.ts | 4 +- .../user-preferences.component.html | 2 +- openapi.json | 113 +++++++++++++++++- 24 files changed, 426 insertions(+), 86 deletions(-) create mode 100644 API/DTOs/Statistics/PagesReadOnADayCount.cs create mode 100644 UI/Web/src/app/statistics/_components/read-by-day-and/read-by-day-and.component.html create mode 100644 UI/Web/src/app/statistics/_components/read-by-day-and/read-by-day-and.component.scss create mode 100644 UI/Web/src/app/statistics/_components/read-by-day-and/read-by-day-and.component.ts create mode 100644 UI/Web/src/app/statistics/_models/line-data-item.ts diff --git a/API/Controllers/StatsController.cs b/API/Controllers/StatsController.cs index 4f0f1fcdf..4bafd4831 100644 --- a/API/Controllers/StatsController.cs +++ b/API/Controllers/StatsController.cs @@ -79,7 +79,7 @@ public class StatsController : BaseApiController } /// - /// Returns + /// Returns users with the top reads in the server /// /// /// @@ -91,6 +91,10 @@ public class StatsController : BaseApiController return Ok(await _statService.GetTopUsers(days)); } + /// + /// A breakdown of different files, their size, and format + /// + /// [Authorize("RequireAdminRole")] [HttpGet("server/file-breakdown")] [ResponseCache(CacheProfileName = "Statistics")] @@ -100,11 +104,30 @@ public class StatsController : BaseApiController } + /// + /// Returns reading history events for a give or all users, broken up by day, and format + /// + /// If 0, defaults to all users, else just userId + /// + [HttpGet("reading-count-by-day")] + [ResponseCache(CacheProfileName = "Statistics")] + public async Task>>> 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>> 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)); } diff --git a/API/Controllers/UsersController.cs b/API/Controllers/UsersController.cs index 1738ea539..d23e6facb 100644 --- a/API/Controllers/UsersController.cs +++ b/API/Controllers/UsersController.cs @@ -55,6 +55,13 @@ public class UsersController : BaseApiController return Ok(await _unitOfWork.UserRepository.GetPendingMemberDtosAsync()); } + [HttpGet("myself")] + public async Task>> GetMyself() + { + var users = await _unitOfWork.UserRepository.GetAllUsersAsync(); + return Ok(users.Where(u => u.UserName == User.GetUsername()).DefaultIfEmpty().Select(u => _mapper.Map(u)).SingleOrDefault()); + } + [HttpGet("has-reading-progress")] public async Task> HasReadingProgress(int libraryId) diff --git a/API/DTOs/Statistics/PagesReadOnADayCount.cs b/API/DTOs/Statistics/PagesReadOnADayCount.cs new file mode 100644 index 000000000..f2bfab74b --- /dev/null +++ b/API/DTOs/Statistics/PagesReadOnADayCount.cs @@ -0,0 +1,21 @@ +using System; +using API.Entities.Enums; + +namespace API.DTOs.Statistics; + +public class PagesReadOnADayCount : ICount +{ + /// + /// The day of the readings + /// + public T Value { get; set; } + /// + /// Number of pages read + /// + public int Count { get; set; } + /// + /// Format of those files + /// + public MangaFormat Format { get; set; } + +} diff --git a/API/Services/StatisticService.cs b/API/Services/StatisticService.cs index 2d2504856..e1b9f89be 100644 --- a/API/Services/StatisticService.cs +++ b/API/Services/StatisticService.cs @@ -27,6 +27,7 @@ public interface IStatisticService Task> GetTopUsers(int days); Task> GetReadingHistory(int userId); Task> GetHistory(); + Task>> ReadCountByDay(int userId = 0); } /// @@ -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(x.Series)) .Take(5); - var recentlyRead = _context.Series - .AsSplitQuery() - .Where(s => seriesIds.Contains(s.Id)) - .ProjectTo(_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>> 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 + { + Value = g.Key.Day, + Format = g.Key.Format, + Count = g.Count() + }) + .OrderBy(d => d.Value) + .ToListAsync(); } public Task> 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) diff --git a/API/Services/TaskScheduler.cs b/API/Services/TaskScheduler.cs index d225b3b99..072f9bfbf 100644 --- a/API/Services/TaskScheduler.cs +++ b/API/Services/TaskScheduler.cs @@ -147,6 +147,7 @@ public class TaskScheduler : ITaskScheduler /// /// First time run stat collection. Executes immediately on a background thread. Does not block. /// + /// Schedules it for 1 day in the future to ensure we don't have users that try the software out 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() diff --git a/UI/Web/src/app/_services/member.service.ts b/UI/Web/src/app/_services/member.service.ts index 31cf187fd..b06ee3e38 100644 --- a/UI/Web/src/app/_services/member.service.ts +++ b/UI/Web/src/app/_services/member.service.ts @@ -47,5 +47,9 @@ export class MemberService { removeSeriesToWantToRead(seriesIds: Array) { return this.httpClient.post>(this.baseUrl + 'want-to-read/remove-series', {seriesIds}); } + + getMember() { + return this.httpClient.get(this.baseUrl + 'users/myself'); + } } diff --git a/UI/Web/src/app/_services/statistics.service.ts b/UI/Web/src/app/_services/statistics.service.ts index 4cb72fd27..e65ec4856 100644 --- a/UI/Web/src/app/_services/statistics.service.ts +++ b/UI/Web/src/app/_services/statistics.service.ts @@ -81,4 +81,8 @@ export class StatisticsService { getFileBreakdown() { return this.httpClient.get(this.baseUrl + 'stats/server/file-breakdown'); } + + getReadCountByDay(userId: number = 0) { + return this.httpClient.get>(this.baseUrl + 'stats/reading-count-by-day?userId=' + userId); + } } diff --git a/UI/Web/src/app/manga-reader/_series/managa-reader.service.ts b/UI/Web/src/app/manga-reader/_series/managa-reader.service.ts index 3980f87cc..958d81a86 100644 --- a/UI/Web/src/app/manga-reader/_series/managa-reader.service.ts +++ b/UI/Web/src/app/manga-reader/_series/managa-reader.service.ts @@ -17,11 +17,11 @@ export class ManagaReaderService { this.renderer = rendererFactory.createRenderer(null, null); } - loadPageDimensions(dims: Array) { 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) { diff --git a/UI/Web/src/app/statistics/_components/file-breakdown-stats/file-breakdown-stats.component.ts b/UI/Web/src/app/statistics/_components/file-breakdown-stats/file-breakdown-stats.component.ts index 15bef0e58..89c048166 100644 --- a/UI/Web/src/app/statistics/_components/file-breakdown-stats/file-breakdown-stats.component.ts +++ b/UI/Web/src/app/statistics/_components/file-breakdown-stats/file-breakdown-stats.component.ts @@ -72,8 +72,6 @@ export class FileBreakdownStatsComponent implements OnInit { } ngOnInit(): void { - this.onDestroy.next(); - this.onDestroy.complete(); } ngOnDestroy(): void { diff --git a/UI/Web/src/app/statistics/_components/read-by-day-and/read-by-day-and.component.html b/UI/Web/src/app/statistics/_components/read-by-day-and/read-by-day-and.component.html new file mode 100644 index 000000000..229864efa --- /dev/null +++ b/UI/Web/src/app/statistics/_components/read-by-day-and/read-by-day-and.component.html @@ -0,0 +1,44 @@ + +
+
+

Top Readers

+
+
+
+
+ + +
+
+
+
+
+ + + + +
+ + No Reading progress + +
\ No newline at end of file diff --git a/UI/Web/src/app/statistics/_components/read-by-day-and/read-by-day-and.component.scss b/UI/Web/src/app/statistics/_components/read-by-day-and/read-by-day-and.component.scss new file mode 100644 index 000000000..eb069bb88 --- /dev/null +++ b/UI/Web/src/app/statistics/_components/read-by-day-and/read-by-day-and.component.scss @@ -0,0 +1,3 @@ +::ng-deep .dark .ngx-charts text { + fill: #a0aabe; +} \ No newline at end of file diff --git a/UI/Web/src/app/statistics/_components/read-by-day-and/read-by-day-and.component.ts b/UI/Web/src/app/statistics/_components/read-by-day-and/read-by-day-and.component.ts new file mode 100644 index 000000000..49d101d8a --- /dev/null +++ b/UI/Web/src/app/statistics/_components/read-by-day-and/read-by-day-and.component.ts @@ -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 | undefined; + data$: Observable>; + private readonly onDestroy = new Subject(); + + 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(); + } + +} + diff --git a/UI/Web/src/app/statistics/_components/server-stats/server-stats.component.html b/UI/Web/src/app/statistics/_components/server-stats/server-stats.component.html index f9554771b..47fbc0189 100644 --- a/UI/Web/src/app/statistics/_components/server-stats/server-stats.component.html +++ b/UI/Web/src/app/statistics/_components/server-stats/server-stats.component.html @@ -72,21 +72,22 @@ -
+
- +
- +
- +
- + +
- +
@@ -94,7 +95,7 @@
-
+
@@ -102,5 +103,10 @@
+
+
+ +
+
diff --git a/UI/Web/src/app/statistics/_components/server-stats/server-stats.component.ts b/UI/Web/src/app/statistics/_components/server-stats/server-stats.component.ts index b01432aa0..5316f1ecc 100644 --- a/UI/Web/src/app/statistics/_components/server-stats/server-stats.component.ts +++ b/UI/Web/src/app/statistics/_components/server-stats/server-stats.component.ts @@ -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>; recentlyRead$!: Observable>; stats$!: Observable; + seriesImage: (data: PieDataItem) => string; private readonly onDestroy = new Subject(); - 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]); } diff --git a/UI/Web/src/app/statistics/_components/stat-list/stat-list.component.html b/UI/Web/src/app/statistics/_components/stat-list/stat-list.component.html index f6dcdd18d..16e7190f5 100644 --- a/UI/Web/src/app/statistics/_components/stat-list/stat-list.component.html +++ b/UI/Web/src/app/statistics/_components/stat-list/stat-list.component.html @@ -6,6 +6,9 @@
  • + + + {{item.name}} {{item.value}} {{label}}
diff --git a/UI/Web/src/app/statistics/_components/stat-list/stat-list.component.ts b/UI/Web/src/app/statistics/_components/stat-list/stat-list.component.ts index 1b2b28441..5b35a7328 100644 --- a/UI/Web/src/app/statistics/_components/stat-list/stat-list.component.ts +++ b/UI/Web/src/app/statistics/_components/stat-list/stat-list.component.ts @@ -24,6 +24,7 @@ export class StatListComponent { */ @Input() description: string = ''; @Input() data$!: Observable; + @Input() image: ((data: PieDataItem) => string) | undefined = undefined; /** * Optional callback handler when an item is clicked */ diff --git a/UI/Web/src/app/statistics/_components/user-stats-info-cards/user-stats-info-cards.component.html b/UI/Web/src/app/statistics/_components/user-stats-info-cards/user-stats-info-cards.component.html index 2dc0c5750..687602d26 100644 --- a/UI/Web/src/app/statistics/_components/user-stats-info-cards/user-stats-info-cards.component.html +++ b/UI/Web/src/app/statistics/_components/user-stats-info-cards/user-stats-info-cards.component.html @@ -1,8 +1,8 @@ -
+
- {{totalPagesRead | number}} + {{totalPagesRead | compactNumber}}
diff --git a/UI/Web/src/app/statistics/_components/user-stats/user-stats.component.html b/UI/Web/src/app/statistics/_components/user-stats/user-stats.component.html index 28fe3cd59..c9e76bc9a 100644 --- a/UI/Web/src/app/statistics/_components/user-stats/user-stats.component.html +++ b/UI/Web/src/app/statistics/_components/user-stats/user-stats.component.html @@ -1,13 +1,19 @@ -
+
-
+
+
+
+ +
+
+