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