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:
Joe Milazzo 2022-12-22 08:53:27 -06:00 committed by GitHub
parent 13c1787165
commit 7b51fdfb2e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
26 changed files with 224 additions and 76 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -83,6 +83,11 @@
.active {
font-weight: bold;
}
&.disabled {
color: lightgrey;
cursor: not-allowed;
}
}
}

View File

@ -116,14 +116,17 @@ export class CardDetailLayoutComponent implements OnInit, OnDestroy, OnChanges {
this.jumpBarKeysToRender = [...this.jumpBarKeys];
this.resizeJumpBar();
if (!this.hasResumedJumpKey && this.jumpBarKeysToRender.length > 0) {
const resumeKey = this.jumpbarService.getResumeKey(this.router.url);
if (resumeKey === '') return;
const keys = this.jumpBarKeysToRender.filter(k => k.key === resumeKey);
if (keys.length < 1) return;
this.hasResumedJumpKey = true;
setTimeout(() => this.scrollTo(keys[0]), 100);
// 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;
const keys = this.jumpBarKeysToRender.filter(k => k.key === resumeKey);
if (keys.length < 1) return;
this.hasResumedJumpKey = true;
setTimeout(() => this.scrollTo(keys[0]), 100);
}
}
}
@ -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;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -143,4 +143,8 @@ export class SingleRendererComponent implements OnInit, OnDestroy, ImageRenderer
return 1;
}
reset(): void {}
getBookmarkPageCount(): number {
return 1;
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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