Stat Polish (#1775)

* SeriesGroup tag can now have comma separated value to allow a series to be a part of multiple collections.

* Added a missing unit test

* Refactored how collection tags are created to work in the scan loop reliably.

* Added a unit test for RemoveTagsWithoutSeries

* Fixed a bug in reading list title generation to avoid Volume 0 if the underlying file had a title set. Fixed a misconfigured unit test.

* On User stats page, don't show the user selector on reading history, despite if youre an admin. Cleaned up how we show days with 0 reading events to be more clear.

* Refactored the name of a component to reflect what it does

* Removed plugin not using

* Fix an issue where coalescing a key in epub might have multiple html files ending with the key. In this case, let's take the first.

* Added PikaPods to the Readme

* Tried to fix layout shift for charts, but need Robbie's help

* Chart styling

# Added:
- Added: Added styling to force charts into their respective containers.

# Removed:
- Removed: Removed code blocking charts from being visible on mobile.

* Merge conflict

---------

Co-authored-by: Robbie Davis <robbie@therobbiedavis.com>
This commit is contained in:
Joe Milazzo 2023-02-04 02:53:21 -08:00 committed by GitHub
parent c60fdcf6e0
commit cad897015c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 258 additions and 214 deletions

View File

@ -731,7 +731,7 @@ public class BookService : IBookService
if (mappings.ContainsKey(CleanContentKeys(key))) return key;
// Fallback to searching for key (bad epub metadata)
var correctedKey = book.Content.Html.Keys.SingleOrDefault(s => s.EndsWith(key));
var correctedKey = book.Content.Html.Keys.FirstOrDefault(s => s.EndsWith(key));
if (!string.IsNullOrEmpty(correctedKey))
{
key = correctedKey;

View File

@ -63,19 +63,6 @@ public class StatisticService : IStatisticService
.Where(p => libraryIds.Contains(p.LibraryId))
.SumAsync(p => p.PagesRead);
// var ids = await _context.AppUserProgresses
// .Where(p => p.AppUserId == userId)
// .Where(p => libraryIds.Contains(p.LibraryId))
// .Where(p => p.PagesRead > 0)
// .Select(p => new {p.ChapterId, p.SeriesId})
// .ToListAsync();
//var chapterIds = ids.Select(id => id.ChapterId);
// var timeSpentReading = await _context.Chapter
// .Where(c => chapterIds.Contains(c.Id))
// .SumAsync(c => c.AvgHoursToRead);
var timeSpentReading = await TimeSpentReadingForUsersAsync(new List<int>() {userId}, libraryIds);
var totalWordsRead = (long) Math.Round(await _context.AppUserProgresses
@ -364,12 +351,12 @@ public class StatisticService : IStatisticService
if (days > 0)
{
var date = DateTime.Now.AddDays(days * -1);
query = query.Where(x => x.appUserProgresses.LastModified >= date && x.appUserProgresses.Created >= date);
query = query.Where(x => x.appUserProgresses.LastModified >= date);
}
var results = await query.GroupBy(x => new
{
Day = x.appUserProgresses.Created.Date,
Day = x.appUserProgresses.LastModified.Date,
x.series.Format
})
.Select(g => new PagesReadOnADayCount<DateTime>
@ -389,7 +376,25 @@ public class StatisticService : IStatisticService
if (results.Any(d => d.Value == date)) continue;
results.Add(new PagesReadOnADayCount<DateTime>()
{
Format = MangaFormat.Unknown,
Format = MangaFormat.Archive,
Value = date,
Count = 0
});
results.Add(new PagesReadOnADayCount<DateTime>()
{
Format = MangaFormat.Epub,
Value = date,
Count = 0
});
results.Add(new PagesReadOnADayCount<DateTime>()
{
Format = MangaFormat.Pdf,
Value = date,
Count = 0
});
results.Add(new PagesReadOnADayCount<DateTime>()
{
Format = MangaFormat.Image,
Value = date,
Count = 0
});

View File

@ -1,6 +1,5 @@
using System;
using System.IO;
using System.Linq;
using System.Text.Json;
using Kavita.Common.EnvironmentInfo;
using Microsoft.Extensions.Hosting;

View File

@ -97,6 +97,10 @@ We would like to extend a big thank you to [<img src="/Logo/hosting-sponsor.png"
We would like to extend a big thank you to [Huntr](https://huntr.dev/repos/kareadita/kavita) who has worked with Kavita in reporting security vulnerabilities. If you are interested in
being paid to help secure Kavita, please give them a try.
## PikaPods
If you are looking to try your hand at self-hosting but lack the machine, [PikaPods](https://www.pikapods.com/pods?run=kavita) is a great service that
allows you to easily spin up a server. 20% of app revenues are contributed back to Kavita via OpenCollective.
### License
* [GNU GPL v3](http://www.gnu.org/licenses/gpl.html)

View File

@ -21,6 +21,7 @@
.content-wrapper {
padding: 0 5px 0;
overflow: hidden;
height: calc(100vh - 56px);
&.closed {
overflow: auto;

View File

@ -1,16 +1,17 @@
<div class="row g-0 mb-2">
<h4>Day Breakdown</h4>
</div>
<div class="dashboard-card-content">
<div class="row g-0 mb-2">
<h4>Day Breakdown</h4>
</div>
<ngx-charts-bar-vertical
class="dark"
[view]="view"
[results]="dayBreakdown$ | async"
[xAxis]="true"
[yAxis]="true"
[legend]="showLegend"
[showXAxisLabel]="true"
[showYAxisLabel]="true"
xAxisLabel="Day of Week"
yAxisLabel="Reading Events">
</ngx-charts-bar-vertical>
<ngx-charts-bar-vertical
class="dark"
[results]="dayBreakdown$ | async"
[xAxis]="true"
[yAxis]="true"
[legend]="showLegend"
[showXAxisLabel]="true"
[showYAxisLabel]="true"
xAxisLabel="Day of Week"
yAxisLabel="Reading Events">
</ngx-charts-bar-vertical>
</div>

View File

@ -1,3 +1,11 @@
::ng-deep .dark .ngx-charts text {
fill: #a0aabe;
}
.dashboard-card-content {
width: 100%;
height: 242px;
display: flex;
flex-flow: column;
box-sizing: border-box;
}

View File

@ -16,7 +16,7 @@ export class DayBreakdownComponent implements OnDestroy {
private readonly onDestroy = new Subject<void>();
view: [number, number] = [700, 400];
view: [number, number] = [0,0];
gradient: boolean = true;
showLegend: boolean = true;
showLabels: boolean = true;

View File

@ -1,71 +1,74 @@
<div class="row g-0 mb-2">
<div class="col-8">
<h4><span>Format</span>
<i class="fa fa-info-circle ms-1" aria-hidden="true" placement="right" [ngbTooltip]="tooltip" role="button" tabindex="0"></i>
</h4>
</div>
<div class="col-4">
<form>
<div class="form-check form-switch mt-2">
<input id="pub-file-breakdown-viz" type="checkbox" class="form-check-input" [formControl]="formControl" role="switch">
<label for="pub-file-breakdown-viz" class="form-check-label">{{formControl.value ? 'Vizualization' : 'Data Table' }}</label>
</div>
</form>
<div class="dashboard-card-content">
<div class="row g-0 mb-2">
<div class="col-8">
<h4><span>Format</span>
<i class="fa fa-info-circle ms-1" aria-hidden="true" placement="right" [ngbTooltip]="tooltip" role="button" tabindex="0"></i>
</h4>
</div>
<div class="col-4">
<form>
<div class="form-check form-switch mt-2">
<input id="pub-file-breakdown-viz" type="checkbox" class="form-check-input" [formControl]="formControl" role="switch">
<label for="pub-file-breakdown-viz" class="form-check-label">{{formControl.value ? 'Vizualization' : 'Data Table' }}</label>
</div>
</form>
</div>
</div>
<ng-template #tooltip>Not Classified means Kavita has not scanned some files. This occurs on old files existing prior to v0.7. You may need to run a forced scan via Library settings modal.</ng-template>
<ng-container *ngIf="files$ | async as files">
<ng-container *ngIf="formControl.value; else tableLayout">
<ngx-charts-advanced-pie-chart [results]="vizData2$ | async"></ngx-charts-advanced-pie-chart>
</ng-container>
<ng-template #tableLayout>
<table class="table table-light table-striped table-hover table-sm scrollable">
<thead>
<tr>
<th scope="col" sortable="extension" (sort)="onSort($event)">
Extension
</th>
<th scope="col" sortable="format" (sort)="onSort($event)">
Format
</th>
<th scope="col" sortable="totalSize" (sort)="onSort($event)">
Total Size
</th>
<th scope="col" sortable="totalFiles" (sort)="onSort($event)">
Total Files
</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let item of files; let idx = index;">
<td id="adhoctask--{{idx}}">
{{item.extension || 'Not Classified'}}
</td>
<td>
{{item.format | mangaFormat}}
</td>
<td>
{{item.totalSize | bytes}}
</td>
<td>
{{item.totalFiles | number:'1.0-0'}}
</td>
</tr>
</tbody>
<tfoot>
<tr>
<td>Total File Size:</td>
<td></td>
<td></td>
<td>{{((rawData$ | async)?.totalFileSize || 0) | bytes}}</td>
</tr>
</tfoot>
</table>
</ng-template>
</ng-container>
</div>
<ng-template #tooltip>Not Classified means Kavita has not scanned some files. This occurs on old files existing prior to v0.7. You may need to run a forced scan via Library settings modal.</ng-template>
<ng-container *ngIf="files$ | async as files">
<ng-container *ngIf="formControl.value; else tableLayout">
<ngx-charts-advanced-pie-chart [results]="vizData2$ | async"></ngx-charts-advanced-pie-chart>
</ng-container>
<ng-template #tableLayout>
<table class="table table-light table-striped table-hover table-sm scrollable">
<thead>
<tr>
<th scope="col" sortable="extension" (sort)="onSort($event)">
Extension
</th>
<th scope="col" sortable="format" (sort)="onSort($event)">
Format
</th>
<th scope="col" sortable="totalSize" (sort)="onSort($event)">
Total Size
</th>
<th scope="col" sortable="totalFiles" (sort)="onSort($event)">
Total Files
</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let item of files; let idx = index;">
<td id="adhoctask--{{idx}}">
{{item.extension || 'Not Classified'}}
</td>
<td>
{{item.format | mangaFormat}}
</td>
<td>
{{item.totalSize | bytes}}
</td>
<td>
{{item.totalFiles | number:'1.0-0'}}
</td>
</tr>
</tbody>
<tfoot>
<tr>
<td>Total File Size:</td>
<td></td>
<td></td>
<td>{{((rawData$ | async)?.totalFileSize || 0) | bytes}}</td>
</tr>
</tfoot>
</table>
</ng-template>
</ng-container>

View File

@ -2,3 +2,11 @@
top: unset !important;
transform: unset !important;
}
.dashboard-card-content {
width: 100%;
height: 242px;
display: flex;
flex-flow: column;
box-sizing: border-box;
}

View File

@ -1,51 +1,54 @@
<div class="row g-0 mb-2">
<div class="col-8">
<h4><span>Publication Status</span>
</h4>
</div>
<div class="col-4">
<form>
<div class="form-check form-switch mt-2">
<input id="pub-status-viz" type="checkbox" class="form-check-input" [formControl]="formControl" role="switch">
<label for="pub-status-viz" class="form-check-label">{{formControl.value ? 'Vizualization' : 'Data Table' }}</label>
</div>
</form>
<div class="dashboard-card-content">
<div class="row g-0 mb-2">
<div class="col-8">
<h4><span>Publication Status</span>
</h4>
</div>
<div class="col-4">
<form>
<div class="form-check form-switch mt-2">
<input id="pub-status-viz" type="checkbox" class="form-check-input" [formControl]="formControl" role="switch">
<label for="pub-status-viz" class="form-check-label">{{formControl.value ? 'Vizualization' : 'Data Table' }}</label>
</div>
</form>
</div>
</div>
<ng-container *ngIf="publicationStatues$ | async as statuses">
<ng-container *ngIf="formControl.value; else tableLayout">
<ngx-charts-advanced-pie-chart
[results]="statuses"
>
</ngx-charts-advanced-pie-chart>
</ng-container>
<ng-template #tableLayout>
<table class="table table-light table-hover table-striped table-sm scrollable">
<thead>
<tr>
<th scope="col" sortable="name" (sort)="onSort($event)">
Year
</th>
<th scope="col" sortable="value" (sort)="onSort($event)">
Count
</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let item of statuses; let idx = index;">
<td id="adhoctask--{{idx}}">
{{item.name}}
</td>
<td>
{{item.value | number:'1.0-0'}}
</td>
</tr>
</tbody>
</table>
</ng-template>
</ng-container>
</div>
<ng-container *ngIf="publicationStatues$ | async as statuses">
<ng-container *ngIf="formControl.value; else tableLayout">
<ngx-charts-advanced-pie-chart
[results]="statuses"
>
</ngx-charts-advanced-pie-chart>
</ng-container>
<ng-template #tableLayout>
<table class="table table-light table-hover table-striped table-sm scrollable">
<thead>
<tr>
<th scope="col" sortable="name" (sort)="onSort($event)">
Year
</th>
<th scope="col" sortable="value" (sort)="onSort($event)">
Count
</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let item of statuses; let idx = index;">
<td id="adhoctask--{{idx}}">
{{item.name}}
</td>
<td>
{{item.value | number:'1.0-0'}}
</td>
</tr>
</tbody>
</table>
</ng-template>
</ng-container>

View File

@ -1,3 +1,11 @@
::ng-deep .pie-label {
color: var(--body-text-color) !important;
}
.dashboard-card-content {
width: 100%;
height: 242px;
display: flex;
flex-flow: column;
box-sizing: border-box;
}

View File

@ -1,52 +0,0 @@
<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">
<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">
<option [value]="0">All Users</option>
<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>
<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"
[autoScale]="true"
xAxisLabel="Time"
yAxisLabel="Reading Activity"
[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,54 @@
<ng-container>
<div class="dashboard-card-content">
<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">
<div class="d-flex me-1" *ngIf="isAdmin && !individualUserMode">
<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>
<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>
<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"
[autoScale]="true"
xAxisLabel="Time"
yAxisLabel="Reading Activity"
[timeline]="false"
[results]="data"
>
</ngx-charts-line-chart>
</ng-container>
</div>
<ng-template #noData>
No Reading progress
</ng-template>
</div>
</ng-container>

View File

@ -12,17 +12,18 @@ 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'],
selector: 'app-reading-activity',
templateUrl: './reading-activity.component.html',
styleUrls: ['./reading-activity.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ReadByDayAndComponent implements OnInit, OnDestroy {
export class ReadingActivityComponent implements OnInit, OnDestroy {
/**
* Only show for one user
*/
@Input() userId: number = 0;
@Input() isAdmin: boolean = true;
@Input() individualUserMode: boolean = false;
view: [number, number] = [0, 400];
formGroup: FormGroup = new FormGroup({

View File

@ -92,26 +92,26 @@
</div>
<ng-container *ngIf="breakpoint$ | async as bp">
<div class="row g-0 pt-2 pb-2" *ngIf="bp > Breakpoint.Mobile">
<div class="row g-0 pt-2 pb-2">
<app-top-readers></app-top-readers>
</div>
<div class="row g-0 pt-4 pb-2" style="height: 242px" *ngIf="bp > Breakpoint.Mobile">
<div class="col-md-6 col-sm-12">
<div class="row g-0 pt-4 pb-2">
<div class="col-lg-6 col-md-12 mb-md-5">
<app-file-breakdown-stats></app-file-breakdown-stats>
</div>
<div class="col-md-6 col-sm-12">
<div class="col-lg-6 col-md-12">
<app-publication-status-stats></app-publication-status-stats>
</div>
</div>
<div class="row g-0 pt-4 pb-2 " style="height: 242px" *ngIf="bp > Breakpoint.Mobile">
<div class="row g-0 pt-4 pb-2">
<div class="col-md-12 col-sm-12 mt-4 pt-2">
<app-read-by-day-and [isAdmin]="true"></app-read-by-day-and>
<app-reading-activity [isAdmin]="true"></app-reading-activity>
</div>
</div>
<div class="row g-0 pt-4 pb-2 " style="height: 242px" *ngIf="bp > Breakpoint.Mobile">
<div class="row g-0 pt-4 pb-2">
<div class="col-md-12 col-sm-12 mt-4 pt-2">
<app-day-breakdown></app-day-breakdown>
</div>

View File

@ -8,3 +8,4 @@
overflow-x: hidden;
align-items: start;
}

View File

@ -9,7 +9,7 @@
<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>
<app-reading-activity [userId]="userId" [isAdmin]="(isAdmin$ | async) || false" [individualUserMode]="true"></app-reading-activity>
</div>
</div>

View File

@ -14,7 +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';
import { ReadingActivityComponent } from './_components/reading-activity/reading-activity.component';
import { GenericListModalComponent } from './_components/_modals/generic-list-modal/generic-list-modal.component';
import { DayBreakdownComponent } from './_components/day-breakdown/day-breakdown.component';
import { DayOfWeekPipe } from './_pipes/day-of-week.pipe';
@ -31,7 +31,7 @@ import { DayOfWeekPipe } from './_pipes/day-of-week.pipe';
MangaFormatStatsComponent,
FileBreakdownStatsComponent,
TopReadersComponent,
ReadByDayAndComponent,
ReadingActivityComponent,
GenericListModalComponent,
DayBreakdownComponent,
DayOfWeekPipe