mirror of
https://github.com/Kareadita/Kavita.git
synced 2025-07-09 03:04:19 -04:00
Holiday Changes (#1706)
* Fixed a bug on bookmark mode not finding correct image for prefetcher. * Fixed up the edit series relationship modal on tablet viewports. * On double page mode, only bookmark 1 page if only 1 pages is renderered on screen. * Added percentage read of a given library and average hours read per week to user stats. * Fixed a bug in the reader with paging in bookmark mode * Added a "This Week" option to top readers history * Added date ranges for reading time. Added dates that don't have anything, but might remove. * On phone, when applying a metadata filter, when clicking apply, collapse the filter automatically. * Disable jump bar and the resuming from last spot when a custom sort is applied. * Ensure all Regex.Replace or Matches have timeouts set
This commit is contained in:
parent
13c1787165
commit
7b51fdfb2e
@ -108,16 +108,17 @@ public class StatsController : BaseApiController
|
||||
/// 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>
|
||||
/// <param name="days">If 0, defaults to all time, else just those days asked for</param>
|
||||
/// <returns></returns>
|
||||
[HttpGet("reading-count-by-day")]
|
||||
[ResponseCache(CacheProfileName = "Statistics")]
|
||||
public async Task<ActionResult<IEnumerable<PagesReadOnADayCount<DateTime>>>> ReadCountByDay(int userId = 0)
|
||||
public async Task<ActionResult<IEnumerable<PagesReadOnADayCount<DateTime>>>> ReadCountByDay(int userId = 0, int days = 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));
|
||||
return Ok(await _statService.ReadCountByDay(userId, days));
|
||||
}
|
||||
|
||||
|
||||
|
@ -13,12 +13,9 @@ public class UserReadStatistics
|
||||
/// Total time spent reading based on estimates
|
||||
/// </summary>
|
||||
public long TimeSpentReading { get; set; }
|
||||
/// <summary>
|
||||
/// A list of genres mapped with genre and number of series that fall into said genre
|
||||
/// </summary>
|
||||
public ICollection<Tuple<string, long>> FavoriteGenres { get; set; }
|
||||
|
||||
public long ChaptersRead { get; set; }
|
||||
public DateTime LastActive { get; set; }
|
||||
public long AvgHoursPerWeekSpentReading { get; set; }
|
||||
public double AvgHoursPerWeekSpentReading { get; set; }
|
||||
public IEnumerable<StatCount<float>> PercentReadPerLibrary { get; set; }
|
||||
|
||||
}
|
||||
|
@ -322,7 +322,7 @@ public class SeriesRepository : ISeriesRepository
|
||||
.ProjectTo<LibraryDto>(_mapper.ConfigurationProvider)
|
||||
.ToListAsync();
|
||||
|
||||
var justYear = Regex.Match(searchQuery, @"\d{4}").Value;
|
||||
var justYear = Regex.Match(searchQuery, @"\d{4}", RegexOptions.None, Services.Tasks.Scanner.Parser.Parser.RegexTimeout).Value;
|
||||
var hasYearInQuery = !string.IsNullOrEmpty(justYear);
|
||||
var yearComparison = hasYearInQuery ? int.Parse(justYear) : 0;
|
||||
|
||||
|
@ -535,8 +535,8 @@ public class BookService : IBookService
|
||||
|
||||
private static string EscapeTags(string content)
|
||||
{
|
||||
content = Regex.Replace(content, @"<script(.*)(/>)", "<script$1></script>");
|
||||
content = Regex.Replace(content, @"<title(.*)(/>)", "<title$1></title>");
|
||||
content = Regex.Replace(content, @"<script(.*)(/>)", "<script$1></script>", RegexOptions.None, Tasks.Scanner.Parser.Parser.RegexTimeout);
|
||||
content = Regex.Replace(content, @"<title(.*)(/>)", "<title$1></title>", RegexOptions.None, Tasks.Scanner.Parser.Parser.RegexTimeout);
|
||||
return content;
|
||||
}
|
||||
|
||||
@ -995,12 +995,12 @@ public class BookService : IBookService
|
||||
}
|
||||
|
||||
// Remove comments from CSS
|
||||
body = Regex.Replace(body, @"/\*[\d\D]*?\*/", string.Empty);
|
||||
body = Regex.Replace(body, @"/\*[\d\D]*?\*/", string.Empty, RegexOptions.None, Tasks.Scanner.Parser.Parser.RegexTimeout);
|
||||
|
||||
body = Regex.Replace(body, @"[a-zA-Z]+#", "#");
|
||||
body = Regex.Replace(body, @"[\n\r]+\s*", string.Empty);
|
||||
body = Regex.Replace(body, @"\s+", " ");
|
||||
body = Regex.Replace(body, @"\s?([:,;{}])\s?", "$1");
|
||||
body = Regex.Replace(body, @"[a-zA-Z]+#", "#", RegexOptions.None, Tasks.Scanner.Parser.Parser.RegexTimeout);
|
||||
body = Regex.Replace(body, @"[\n\r]+\s*", string.Empty, RegexOptions.None, Tasks.Scanner.Parser.Parser.RegexTimeout);
|
||||
body = Regex.Replace(body, @"\s+", " ", RegexOptions.None, Tasks.Scanner.Parser.Parser.RegexTimeout);
|
||||
body = Regex.Replace(body, @"\s?([:,;{}])\s?", "$1", RegexOptions.None, Tasks.Scanner.Parser.Parser.RegexTimeout);
|
||||
try
|
||||
{
|
||||
body = body.Replace(";}", "}");
|
||||
@ -1010,7 +1010,7 @@ public class BookService : IBookService
|
||||
//Swallow exception. Some css don't have style rules ending in ';'
|
||||
}
|
||||
|
||||
body = Regex.Replace(body, @"([\s:]0)(px|pt|%|em)", "$1");
|
||||
body = Regex.Replace(body, @"([\s:]0)(px|pt|%|em)", "$1", RegexOptions.None, Tasks.Scanner.Parser.Parser.RegexTimeout);
|
||||
|
||||
|
||||
return body;
|
||||
|
@ -26,7 +26,7 @@ public interface IStatisticService
|
||||
Task<FileExtensionBreakdownDto> GetFileBreakdown();
|
||||
Task<IEnumerable<TopReadDto>> GetTopUsers(int days);
|
||||
Task<IEnumerable<ReadHistoryEvent>> GetReadingHistory(int userId);
|
||||
Task<IEnumerable<PagesReadOnADayCount<DateTime>>> ReadCountByDay(int userId = 0);
|
||||
Task<IEnumerable<PagesReadOnADayCount<DateTime>>> ReadCountByDay(int userId = 0, int days = 0);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -81,7 +81,34 @@ public class StatisticService : IStatisticService
|
||||
.Select(p => p.LastModified)
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
//var
|
||||
// Reading Progress by Library Name
|
||||
|
||||
// First get the total pages per library
|
||||
var totalPageCountByLibrary = _context.Chapter
|
||||
.Join(_context.Volume, c => c.VolumeId, v => v.Id, (chapter, volume) => new { chapter, volume })
|
||||
.Join(_context.Series, g => g.volume.SeriesId, s => s.Id, (g, series) => new { g.chapter, series })
|
||||
.AsEnumerable()
|
||||
.GroupBy(g => g.series.LibraryId)
|
||||
.ToDictionary(g => g.Key, g => g.Sum(c => c.chapter.Pages));
|
||||
//
|
||||
//
|
||||
var totalProgressByLibrary = await _context.AppUserProgresses
|
||||
.Where(p => p.AppUserId == userId)
|
||||
.Where(p => p.LibraryId > 0)
|
||||
.GroupBy(p => p.LibraryId)
|
||||
.Select(g => new StatCount<float>
|
||||
{
|
||||
Count = g.Key,
|
||||
Value = g.Sum(p => p.PagesRead) / (float) totalPageCountByLibrary[g.Key]
|
||||
})
|
||||
.ToListAsync();
|
||||
|
||||
|
||||
var averageReadingTimePerWeek = _context.AppUserProgresses
|
||||
.Where(p => p.AppUserId == userId)
|
||||
.Join(_context.Chapter, p => p.ChapterId, c => c.Id,
|
||||
(p, c) => (p.PagesRead / (float) c.Pages) * c.AvgHoursToRead)
|
||||
.Average() / 7;
|
||||
|
||||
return new UserReadStatistics()
|
||||
{
|
||||
@ -89,6 +116,8 @@ public class StatisticService : IStatisticService
|
||||
TimeSpentReading = timeSpentReading,
|
||||
ChaptersRead = chaptersRead,
|
||||
LastActive = lastActive,
|
||||
PercentReadPerLibrary = totalProgressByLibrary,
|
||||
AvgHoursPerWeekSpentReading = averageReadingTimePerWeek
|
||||
};
|
||||
}
|
||||
|
||||
@ -297,7 +326,7 @@ public class StatisticService : IStatisticService
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<PagesReadOnADayCount<DateTime>>> ReadCountByDay(int userId = 0)
|
||||
public async Task<IEnumerable<PagesReadOnADayCount<DateTime>>> ReadCountByDay(int userId = 0, int days = 0)
|
||||
{
|
||||
var query = _context.AppUserProgresses
|
||||
.AsSplitQuery()
|
||||
@ -314,7 +343,13 @@ public class StatisticService : IStatisticService
|
||||
query = query.Where(x => x.appUserProgresses.AppUserId == userId);
|
||||
}
|
||||
|
||||
return await query.GroupBy(x => new
|
||||
if (days > 0)
|
||||
{
|
||||
var date = DateTime.Now.AddDays(days * -1);
|
||||
query = query.Where(x => x.appUserProgresses.LastModified >= date && x.appUserProgresses.Created >= date);
|
||||
}
|
||||
|
||||
var results = await query.GroupBy(x => new
|
||||
{
|
||||
Day = x.appUserProgresses.Created.Date,
|
||||
x.series.Format
|
||||
@ -327,6 +362,23 @@ public class StatisticService : IStatisticService
|
||||
})
|
||||
.OrderBy(d => d.Value)
|
||||
.ToListAsync();
|
||||
|
||||
if (results.Count > 0)
|
||||
{
|
||||
var minDay = results.Min(d => d.Value);
|
||||
for (var date = minDay; date < DateTime.Now; date = date.AddDays(1))
|
||||
{
|
||||
if (results.Any(d => d.Value == date)) continue;
|
||||
results.Add(new PagesReadOnADayCount<DateTime>()
|
||||
{
|
||||
Format = MangaFormat.Unknown,
|
||||
Value = date,
|
||||
Count = 0
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<TopReadDto>> GetTopUsers(int days)
|
||||
|
@ -911,7 +911,7 @@ public static class Parser
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!Regex.IsMatch(range, @"^[\d\-.]+$"))
|
||||
if (!Regex.IsMatch(range, @"^[\d\-.]+$", MatchOptions, RegexTimeout))
|
||||
{
|
||||
return (float) 0.0;
|
||||
}
|
||||
@ -929,7 +929,7 @@ public static class Parser
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!Regex.IsMatch(range, @"^[\d\-.]+$"))
|
||||
if (!Regex.IsMatch(range, @"^[\d\-.]+$", MatchOptions, RegexTimeout))
|
||||
{
|
||||
return (float) 0.0;
|
||||
}
|
||||
|
@ -82,7 +82,7 @@ export class StatisticsService {
|
||||
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);
|
||||
getReadCountByDay(userId: number = 0, days: number = 0) {
|
||||
return this.httpClient.get<Array<any>>(this.baseUrl + 'stats/reading-count-by-day?userId=' + userId + '&days=' + days);
|
||||
}
|
||||
}
|
||||
|
@ -56,7 +56,7 @@
|
||||
<ng-template #jumpBar>
|
||||
<div class="jump-bar">
|
||||
<ng-container *ngFor="let jumpKey of jumpBarKeysToRender; let i = index;">
|
||||
<button class="btn btn-link" (click)="scrollTo(jumpKey)">
|
||||
<button class="btn btn-link" [ngClass]="{'disabled': hasCustomSort()}" (click)="scrollTo(jumpKey)">
|
||||
{{jumpKey.title}}
|
||||
</button>
|
||||
</ng-container>
|
||||
|
@ -83,6 +83,11 @@
|
||||
.active {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
color: lightgrey;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -116,6 +116,8 @@ export class CardDetailLayoutComponent implements OnInit, OnDestroy, OnChanges {
|
||||
this.jumpBarKeysToRender = [...this.jumpBarKeys];
|
||||
this.resizeJumpBar();
|
||||
|
||||
// Don't resume jump key when there is a custom sort order, as it won't work
|
||||
if (this.hasCustomSort()) {
|
||||
if (!this.hasResumedJumpKey && this.jumpBarKeysToRender.length > 0) {
|
||||
const resumeKey = this.jumpbarService.getResumeKey(this.router.url);
|
||||
if (resumeKey === '') return;
|
||||
@ -126,6 +128,7 @@ export class CardDetailLayoutComponent implements OnInit, OnDestroy, OnChanges {
|
||||
setTimeout(() => this.scrollTo(keys[0]), 100);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
ngOnDestroy() {
|
||||
@ -133,6 +136,10 @@ export class CardDetailLayoutComponent implements OnInit, OnDestroy, OnChanges {
|
||||
this.onDestory.complete();
|
||||
}
|
||||
|
||||
hasCustomSort() {
|
||||
return this.filter.sortOptions !== null || this.filterSettings.presets?.sortOptions !== null;
|
||||
}
|
||||
|
||||
performAction(action: ActionItem<any>) {
|
||||
if (typeof action.callback === 'function') {
|
||||
action.callback(action, undefined);
|
||||
@ -147,6 +154,8 @@ export class CardDetailLayoutComponent implements OnInit, OnDestroy, OnChanges {
|
||||
|
||||
|
||||
scrollTo(jumpKey: JumpKey) {
|
||||
if (this.hasCustomSort()) return;
|
||||
|
||||
let targetIndex = 0;
|
||||
for(var i = 0; i < this.jumpBarKeys.length; i++) {
|
||||
if (this.jumpBarKeys[i].key === jumpKey.key) break;
|
||||
|
@ -10,8 +10,8 @@
|
||||
</div>
|
||||
|
||||
<form>
|
||||
<div class="row g-0 mb-3" *ngFor="let relation of relations; let idx = index; let isLast = last;">
|
||||
<div class="col-sm-12 col-md-7">
|
||||
<div class="row g-0" *ngFor="let relation of relations; let idx = index; let isLast = last;">
|
||||
<div class="col-sm-12 col-md-12 col-lg-7 mb-3">
|
||||
<app-typeahead (selectedData)="updateSeries($event, relation)" [settings]="relation.typeaheadSettings" id="relation--{{idx}}" [focus]="focusTypeahead">
|
||||
<ng-template #badgeItem let-item let-position="idx">
|
||||
{{item.name}} ({{libraryNames[item.libraryId]}})
|
||||
@ -21,13 +21,13 @@
|
||||
</ng-template>
|
||||
</app-typeahead>
|
||||
</div>
|
||||
<div class="col-sm-auto col-md-3">
|
||||
<div class="col-sm-12 col-md-10 col-lg-3 mb-3">
|
||||
<select class="form-select" [formControl]="relation.formControl">
|
||||
<option [value]="RelationKind.Parent" disabled>Parent</option>
|
||||
<option *ngFor="let opt of relationOptions" [value]="opt.value">{{opt.text}}</option>
|
||||
</select>
|
||||
</div>
|
||||
<button class="col-sm-auto col-md-2 btn btn-outline-secondary" (click)="removeRelation(idx)">Remove</button>
|
||||
<button class="col-sm-auto col-md-2 mb-3 btn btn-outline-secondary" (click)="removeRelation(idx)"><i class="fa fa-trash"></i><span class="visually-hidden">Remove</span></button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
|
@ -251,4 +251,8 @@ export class CanvasRendererComponent implements OnInit, AfterViewInit, OnDestroy
|
||||
this.imageHeight.emit(this.canvas.nativeElement.height);
|
||||
this.cdRef.markForCheck();
|
||||
}
|
||||
|
||||
getBookmarkPageCount(): number {
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
@ -282,6 +282,10 @@ export class DoubleRendererComponent implements OnInit, OnDestroy, ImageRenderer
|
||||
}
|
||||
reset(): void {}
|
||||
|
||||
getBookmarkPageCount(): number {
|
||||
return this.shouldRenderDouble() ? 2 : 1;
|
||||
}
|
||||
|
||||
debugLog(message: string, extraData?: any) {
|
||||
if (!(this.debugMode & DEBUG_MODES.Logs)) return;
|
||||
|
||||
|
@ -296,6 +296,10 @@ export class DoubleReverseRendererComponent implements OnInit, OnDestroy, ImageR
|
||||
}
|
||||
reset(): void {}
|
||||
|
||||
getBookmarkPageCount(): number {
|
||||
return this.shouldRenderDouble() ? 2 : 1;
|
||||
}
|
||||
|
||||
debugLog(message: string, extraData?: any) {
|
||||
if (!(this.debugMode & DEBUG_MODES.Logs)) return;
|
||||
|
||||
|
@ -627,11 +627,14 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
* Gets a page from cache else gets a brand new Image
|
||||
* @param pageNum Page Number to load
|
||||
* @param forceNew Forces to fetch a new image
|
||||
* @param chapterId ChapterId to fetch page from. Defaults to current chapterId
|
||||
* @param chapterId ChapterId to fetch page from. Defaults to current chapterId. Not used when in bookmark mode
|
||||
* @returns
|
||||
*/
|
||||
getPage(pageNum: number, chapterId: number = this.chapterId, forceNew: boolean = false) {
|
||||
let img = this.cachedImages.find(img => this.readerService.imageUrlToPageNum(img.src) === pageNum
|
||||
|
||||
let img = undefined;
|
||||
if (this.bookmarkMode) img = this.cachedImages.find(img => this.readerService.imageUrlToPageNum(img.src) === pageNum);
|
||||
else img = this.cachedImages.find(img => this.readerService.imageUrlToPageNum(img.src) === pageNum
|
||||
&& (this.readerService.imageUrlToChapterId(img.src) == chapterId || this.readerService.imageUrlToChapterId(img.src) === -1)
|
||||
);
|
||||
if (!img || forceNew) {
|
||||
@ -1273,7 +1276,8 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
if (this.bookmarkMode) return;
|
||||
|
||||
const pageNum = this.pageNum;
|
||||
const isDouble = this.layoutMode === LayoutMode.Double || this.layoutMode === LayoutMode.DoubleReversed;
|
||||
const isDouble = Math.max(this.canvasRenderer.getBookmarkPageCount(), this.singleRenderer.getBookmarkPageCount(),
|
||||
this.doubleRenderer.getBookmarkPageCount(), this.doubleReverseRenderer.getBookmarkPageCount()) > 1;
|
||||
|
||||
if (this.CurrentPageBookmarked) {
|
||||
let apis = [this.readerService.unbookmark(this.seriesId, this.volumeId, this.chapterId, pageNum)];
|
||||
|
@ -143,4 +143,8 @@ export class SingleRendererComponent implements OnInit, OnDestroy, ImageRenderer
|
||||
return 1;
|
||||
}
|
||||
reset(): void {}
|
||||
|
||||
getBookmarkPageCount(): number {
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
@ -55,4 +55,8 @@ export interface ImageRenderer {
|
||||
* This should reset any needed state, but not unset the image.
|
||||
*/
|
||||
reset(): void;
|
||||
/**
|
||||
* Returns the number of pages that are currently rendererd on screen and thus should be bookmarked.
|
||||
*/
|
||||
getBookmarkPageCount(): number;
|
||||
}
|
@ -3,7 +3,7 @@ import { FormControl, FormGroup, Validators } from '@angular/forms';
|
||||
import { NgbCollapse } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { distinctUntilChanged, forkJoin, map, Observable, of, ReplaySubject, Subject, takeUntil } from 'rxjs';
|
||||
import { FilterUtilitiesService } from '../shared/_services/filter-utilities.service';
|
||||
import { UtilityService } from '../shared/_services/utility.service';
|
||||
import { Breakpoint, UtilityService } from '../shared/_services/utility.service';
|
||||
import { TypeaheadSettings } from '../typeahead/_models/typeahead-settings';
|
||||
import { CollectionTag } from '../_models/collection-tag';
|
||||
import { Genre } from '../_models/metadata/genre';
|
||||
@ -630,6 +630,11 @@ export class MetadataFilterComponent implements OnInit, OnDestroy {
|
||||
apply() {
|
||||
this.applyFilter.emit({filter: this.filter, isFirst: this.updateApplied === 0});
|
||||
this.updateApplied++;
|
||||
|
||||
if (this.utilityService.getActiveBreakpoint() === Breakpoint.Mobile) {
|
||||
this.toggleSelected();
|
||||
}
|
||||
|
||||
this.cdRef.markForCheck();
|
||||
}
|
||||
|
||||
|
@ -4,8 +4,8 @@
|
||||
<h4>Top Readers</h4>
|
||||
</div>
|
||||
<div class="col-8">
|
||||
<form [formGroup]="formGroup" class="d-inline-flex float-end" *ngIf="isAdmin">
|
||||
<div class="d-flex">
|
||||
<form [formGroup]="formGroup" class="d-inline-flex float-end">
|
||||
<div class="d-flex me-1" *ngIf="isAdmin">
|
||||
<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">
|
||||
@ -13,6 +13,13 @@
|
||||
<option *ngFor="let item of users$ | async" [value]="item.id">{{item.username}}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="d-flex">
|
||||
<label for="time-select-top-reads" class="form-check-label"></label>
|
||||
<select id="time-select-top-reads" class="form-select" formControlName="days"
|
||||
[class.is-invalid]="formGroup.get('days')?.invalid && formGroup.get('days')?.touched">
|
||||
<option *ngFor="let item of timePeriods" [value]="item.value">{{item.title}}</option>
|
||||
</select>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -6,6 +6,7 @@ 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';
|
||||
import { TimePeriods } from '../top-readers/top-readers.component';
|
||||
|
||||
const options: Intl.DateTimeFormatOptions = { month: "short", day: "numeric" };
|
||||
const mangaFormatPipe = new MangaFormatPipe();
|
||||
@ -26,14 +27,16 @@ export class ReadByDayAndComponent implements OnInit, OnDestroy {
|
||||
view: [number, number] = [0, 400];
|
||||
formGroup: FormGroup = new FormGroup({
|
||||
'users': new FormControl(-1, []),
|
||||
'days': new FormControl(TimePeriods[0].value, []),
|
||||
});
|
||||
users$: Observable<Member[]> | undefined;
|
||||
data$: Observable<Array<PieDataItem>>;
|
||||
timePeriods = TimePeriods;
|
||||
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)),
|
||||
this.data$ = this.formGroup.valueChanges.pipe(
|
||||
switchMap(_ => this.statService.getReadCountByDay(this.formGroup.get('users')!.value, this.formGroup.get('days')!.value)),
|
||||
map(data => {
|
||||
const gList = data.reduce((formats, entry) => {
|
||||
const formatTranslated = mangaFormatPipe.transform(entry.format);
|
||||
|
@ -4,6 +4,8 @@ import { Observable, Subject, takeUntil, switchMap, shareReplay } from 'rxjs';
|
||||
import { StatisticsService } from 'src/app/_services/statistics.service';
|
||||
import { TopUserRead } from '../../_models/top-reads';
|
||||
|
||||
export const TimePeriods: Array<{title: string, value: number}> = [{title: 'This Week', value: new Date().getDay() || 1}, {title: 'Last 7 Days', value: 7}, {title: 'Last 30 Days', value: 30}, {title: 'Last 90 Days', value: 90}, {title: 'Last Year', value: 365}, {title: 'All Time', value: 0}];
|
||||
|
||||
@Component({
|
||||
selector: 'app-top-readers',
|
||||
templateUrl: './top-readers.component.html',
|
||||
@ -13,7 +15,7 @@ import { TopUserRead } from '../../_models/top-reads';
|
||||
export class TopReadersComponent implements OnInit, OnDestroy {
|
||||
|
||||
formGroup: FormGroup;
|
||||
timePeriods: Array<{title: string, value: number}> = [{title: 'Last 7 Days', value: 7}, {title: 'Last 30 Days', value: 30}, {title: 'Last 90 Days', value: 90}, {title: 'Last Year', value: 365}, {title: 'All Time', value: 0}];
|
||||
timePeriods = TimePeriods;
|
||||
|
||||
users$: Observable<TopUserRead[]>;
|
||||
private readonly onDestroy = new Subject<void>();
|
||||
|
@ -17,6 +17,15 @@
|
||||
<div class="vr d-none d-lg-block m-2"></div>
|
||||
</ng-container>
|
||||
|
||||
<ng-container>
|
||||
<div class="col-auto mb-2">
|
||||
<app-icon-and-title label="Average Hours Read / Week" [clickable]="false" fontClasses="fas fa-eye" title="Average Hours Read / Week">
|
||||
{{avgHoursPerWeekSpentReading | compactNumber}} hours
|
||||
</app-icon-and-title>
|
||||
</div>
|
||||
<div class="vr d-none d-lg-block m-2"></div>
|
||||
</ng-container>
|
||||
|
||||
<ng-container>
|
||||
<div class="col-auto mb-2">
|
||||
<app-icon-and-title label="Chapters Read" [clickable]="false" fontClasses="fa-regular fa-file-lines" title="Chapters Read">
|
||||
|
@ -14,6 +14,10 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-0 pt-4 pb-2 " style="height: 242px">
|
||||
<app-stat-list [data$]="precentageRead$" label="% Read" title="Library Read Progress"></app-stat-list>
|
||||
</div>
|
||||
|
||||
<!-- <div class="row g-0">
|
||||
Books Read (this can be chapters read fully)
|
||||
Number of bookmarks
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Input, OnDestroy, OnInit, QueryList, ViewChildren } from '@angular/core';
|
||||
import { map, Observable, of, Subject, takeUntil } from 'rxjs';
|
||||
import { map, Observable, of, shareReplay, Subject, takeUntil } from 'rxjs';
|
||||
import { FilterUtilitiesService } from 'src/app/shared/_services/filter-utilities.service';
|
||||
import { Series } from 'src/app/_models/series';
|
||||
import { UserReadStatistics } from 'src/app/statistics/_models/user-read-statistics';
|
||||
@ -9,6 +9,10 @@ import { SortableHeader, SortEvent } from 'src/app/_single-module/table/_directi
|
||||
import { ReadHistoryEvent } from '../../_models/read-history-event';
|
||||
import { MemberService } from 'src/app/_services/member.service';
|
||||
import { AccountService } from 'src/app/_services/account.service';
|
||||
import { PieDataItem } from '../../_models/pie-data-item';
|
||||
import { LibraryTypePipe } from 'src/app/pipe/library-type.pipe';
|
||||
import { LibraryService } from 'src/app/_services/library.service';
|
||||
import { PercentPipe } from '@angular/common';
|
||||
|
||||
type SeriesWithProgress = Series & {progress: number};
|
||||
|
||||
@ -24,11 +28,13 @@ export class UserStatsComponent implements OnInit, OnDestroy {
|
||||
userStats$!: Observable<UserReadStatistics>;
|
||||
readSeries$!: Observable<ReadHistoryEvent[]>;
|
||||
isAdmin$: Observable<boolean>;
|
||||
precentageRead$!: Observable<PieDataItem[]>;
|
||||
|
||||
private readonly onDestroy = new Subject<void>();
|
||||
|
||||
constructor(private readonly cdRef: ChangeDetectorRef, private statService: StatisticsService,
|
||||
private filterService: FilterUtilitiesService, private accountService: AccountService, private memberService: MemberService) {
|
||||
private filterService: FilterUtilitiesService, private accountService: AccountService, private memberService: MemberService,
|
||||
private libraryService: LibraryService) {
|
||||
this.isAdmin$ = this.accountService.currentUser$.pipe(takeUntil(this.onDestroy), map(u => {
|
||||
if (!u) return false;
|
||||
return this.accountService.hasAdminRole(u);
|
||||
@ -43,10 +49,18 @@ export class UserStatsComponent implements OnInit, OnDestroy {
|
||||
this.userId = me.id;
|
||||
this.cdRef.markForCheck();
|
||||
|
||||
this.userStats$ = this.statService.getUserStatistics(this.userId).pipe(takeUntil(this.onDestroy));
|
||||
this.userStats$ = this.statService.getUserStatistics(this.userId).pipe(takeUntil(this.onDestroy), shareReplay());
|
||||
this.readSeries$ = this.statService.getReadingHistory(this.userId).pipe(
|
||||
takeUntil(this.onDestroy),
|
||||
);
|
||||
|
||||
const pipe = new PercentPipe('en-US');
|
||||
this.libraryService.getLibraryNames().subscribe(names => {
|
||||
this.precentageRead$ = this.userStats$.pipe(takeUntil(this.onDestroy), map(d => d.percentReadPerLibrary.map(l => {
|
||||
return {name: names[l.count], value: parseFloat((pipe.transform(l.value, '1.1-1') || '0').replace('%', ''))};
|
||||
}).sort((a: PieDataItem, b: PieDataItem) => b.value - a.value)));
|
||||
})
|
||||
|
||||
});
|
||||
|
||||
}
|
||||
|
@ -1,8 +1,10 @@
|
||||
import { StatCount } from "./stat-count";
|
||||
|
||||
export interface UserReadStatistics {
|
||||
totalPagesRead: number;
|
||||
timeSpentReading: number;
|
||||
favoriteGenres: Array<any>;
|
||||
chaptersRead: number;
|
||||
lastActive: string;
|
||||
avgHoursPerWeekSpentReading: number;
|
||||
percentReadPerLibrary: Array<StatCount<number>>;
|
||||
}
|
64
openapi.json
64
openapi.json
@ -7,7 +7,7 @@
|
||||
"name": "GPL-3.0",
|
||||
"url": "https://github.com/Kareadita/Kavita/blob/develop/LICENSE"
|
||||
},
|
||||
"version": "0.6.1.17"
|
||||
"version": "0.6.1.18"
|
||||
},
|
||||
"servers": [
|
||||
{
|
||||
@ -7975,6 +7975,16 @@
|
||||
"format": "int32",
|
||||
"default": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "days",
|
||||
"in": "query",
|
||||
"description": "If 0, defaults to all time, else just those days asked for",
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"format": "int32",
|
||||
"default": 0
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
@ -11431,6 +11441,11 @@
|
||||
"type": "string",
|
||||
"description": "For Book reader, this can be an optional string of the id of a part marker, to help resume reading position\r\non pages that combine multiple \"chapters\".",
|
||||
"nullable": true
|
||||
},
|
||||
"lastModified": {
|
||||
"type": "string",
|
||||
"description": "Last time the progress was synced from UI or external app",
|
||||
"format": "date-time"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
@ -13152,6 +13167,20 @@
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"SingleStatCount": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"value": {
|
||||
"type": "number",
|
||||
"format": "float"
|
||||
},
|
||||
"count": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"SiteTheme": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@ -13259,20 +13288,6 @@
|
||||
"additionalProperties": false,
|
||||
"description": "Sorting Options for a query"
|
||||
},
|
||||
"StringInt64Tuple": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"item1": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"item2": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Tag": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@ -14140,14 +14155,6 @@
|
||||
"description": "Total time spent reading based on estimates",
|
||||
"format": "int64"
|
||||
},
|
||||
"favoriteGenres": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/StringInt64Tuple"
|
||||
},
|
||||
"description": "A list of genres mapped with genre and number of series that fall into said genre",
|
||||
"nullable": true
|
||||
},
|
||||
"chaptersRead": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
@ -14157,8 +14164,15 @@
|
||||
"format": "date-time"
|
||||
},
|
||||
"avgHoursPerWeekSpentReading": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
"type": "number",
|
||||
"format": "double"
|
||||
},
|
||||
"percentReadPerLibrary": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/SingleStatCount"
|
||||
},
|
||||
"nullable": true
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
|
Loading…
x
Reference in New Issue
Block a user