mirror of
https://github.com/adityachandelgit/BookLore.git
synced 2026-01-06 13:40:05 -06:00
Merge pull request #1090 from booklore-app/develop
Merge develop into master for the release
This commit is contained in:
@@ -68,6 +68,9 @@ Kick off your BookLore journey with our official documentation and helpful video
|
||||
📘 [BookLore Documentation: Getting Started](https://booklore-app.github.io/booklore-docs/docs/getting-started/)
|
||||
Our up-to-date docs walk you through installation, setup, configuration, and key features, everything you need to get up and running smoothly.
|
||||
|
||||
> 💡 **Want to improve the documentation?**
|
||||
> You can update the docs at [booklore-app/booklore-docs](https://github.com/booklore-app/booklore-docs) and create a pull request to contribute your changes!
|
||||
|
||||
🎥 [BookLore Tutorials: YouTube](https://www.youtube.com/watch?v=UMrn_fIeFRo&list=PLi0fq0zaM7lqY7dX0R66jQtKW64z4_Tdz)
|
||||
These older videos provide useful walkthroughs and visual guidance, but note that some content may be outdated compared to the current docs.
|
||||
|
||||
|
||||
@@ -36,7 +36,6 @@ public class BookController {
|
||||
|
||||
private final BookService bookService;
|
||||
private final BookRecommendationService bookRecommendationService;
|
||||
private final BookMetadataService bookMetadataService;
|
||||
|
||||
@GetMapping
|
||||
public ResponseEntity<List<Book>> getBooks(@RequestParam(required = false, defaultValue = "false") boolean withDescription) {
|
||||
|
||||
@@ -71,13 +71,13 @@
|
||||
"budgets": [
|
||||
{
|
||||
"type": "initial",
|
||||
"maximumWarning": "500kB",
|
||||
"maximumError": "4.5MB"
|
||||
"maximumWarning": "5MB",
|
||||
"maximumError": "5MB"
|
||||
},
|
||||
{
|
||||
"type": "anyComponentStyle",
|
||||
"maximumWarning": "4kB",
|
||||
"maximumError": "8kB"
|
||||
"maximumWarning": "25kB",
|
||||
"maximumError": "25kB"
|
||||
}
|
||||
],
|
||||
"outputHashing": "all"
|
||||
|
||||
61
booklore-ui/package-lock.json
generated
61
booklore-ui/package-lock.json
generated
@@ -9,6 +9,7 @@
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@angular/animations": "^20.1.6",
|
||||
"@angular/cdk": "^20.2.2",
|
||||
"@angular/common": "^20.1.6",
|
||||
"@angular/compiler": "^20.1.6",
|
||||
"@angular/core": "^20.1.6",
|
||||
@@ -23,9 +24,12 @@
|
||||
"@tailwindcss/postcss": "^4.1.8",
|
||||
"@tweenjs/tween.js": "^25.0.0",
|
||||
"angular-oauth2-oidc": "^20.0.0",
|
||||
"chart.js": "^4.5.0",
|
||||
"chartjs-plugin-datalabels": "^2.2.0",
|
||||
"epubjs": "^0.3.93",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"ng-lazyload-image": "^9.1.3",
|
||||
"ng2-charts": "^8.0.0",
|
||||
"ngx-extended-pdf-viewer": "^23.3.1",
|
||||
"ngx-infinite-scroll": "^20.0.0",
|
||||
"primeicons": "^7.0.0",
|
||||
@@ -55,7 +59,7 @@
|
||||
"karma-jasmine": "^5.1.0",
|
||||
"karma-jasmine-html-reporter": "^2.1.0",
|
||||
"tailwindcss": "3.4.17",
|
||||
"typescript": "^5.8.2",
|
||||
"typescript": "~5.8.2",
|
||||
"typescript-eslint": "^8.39.0"
|
||||
}
|
||||
},
|
||||
@@ -714,11 +718,10 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@angular/cdk": {
|
||||
"version": "20.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@angular/cdk/-/cdk-20.1.5.tgz",
|
||||
"integrity": "sha512-uJezXaVPAbumxTCv5JA7oIuWCgPlz9/Fj6dJl6bxcRD7DfMyHGq3dtoLhthuU/uk+OfK0FlTklR92Yss5frFUw==",
|
||||
"version": "20.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@angular/cdk/-/cdk-20.2.2.tgz",
|
||||
"integrity": "sha512-jLvIMmFI8zoi6vAu1Aszua59GmhqBOtsVfkwLUGg5Hi86DI/inJr9BznNX2EKDtaulYMGZCmDgsltXQXeqP5Lg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"parse5": "^8.0.0",
|
||||
"tslib": "^2.3.0"
|
||||
@@ -3962,6 +3965,12 @@
|
||||
"tslib": "2"
|
||||
}
|
||||
},
|
||||
"node_modules/@kurkle/color": {
|
||||
"version": "0.3.4",
|
||||
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
|
||||
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@leichtgewicht/ip-codec": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.5.tgz",
|
||||
@@ -7429,6 +7438,27 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/chart.js": {
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.0.tgz",
|
||||
"integrity": "sha512-aYeC/jDgSEx8SHWZvANYMioYMZ2KX02W6f6uVfyteuCGcadDLcYVHdfdygsTQkQ4TKn5lghoojAsPj5pu0SnvQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@kurkle/color": "^0.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"pnpm": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/chartjs-plugin-datalabels": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/chartjs-plugin-datalabels/-/chartjs-plugin-datalabels-2.2.0.tgz",
|
||||
"integrity": "sha512-14ZU30lH7n89oq+A4bWaJPnAG8a7ZTk7dKf48YAzMvJjQtjrgg5Dpk9f+LbjCF6bpx3RAGTeL13IXpKQYyRvlw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"chart.js": ">=3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/chokidar": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
|
||||
@@ -12498,6 +12528,24 @@
|
||||
"rxjs": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ng2-charts": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ng2-charts/-/ng2-charts-8.0.0.tgz",
|
||||
"integrity": "sha512-nofsNHI2Zt+EAwT+BJBVg0kgOhNo9ukO4CxULlaIi7VwZSr7I1km38kWSoU41Oq6os6qqIh5srnL+CcV+RFPFA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lodash-es": "^4.17.15",
|
||||
"tslib": "^2.3.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@angular/cdk": ">=19.0.0",
|
||||
"@angular/common": ">=19.0.0",
|
||||
"@angular/core": ">=19.0.0",
|
||||
"@angular/platform-browser": ">=19.0.0",
|
||||
"chart.js": "^3.4.0 || ^4.0.0",
|
||||
"rxjs": "^6.5.3 || ^7.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ngx-extended-pdf-viewer": {
|
||||
"version": "23.3.1",
|
||||
"resolved": "https://registry.npmjs.org/ngx-extended-pdf-viewer/-/ngx-extended-pdf-viewer-23.3.1.tgz",
|
||||
@@ -13222,7 +13270,6 @@
|
||||
"resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz",
|
||||
"integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"entities": "^6.0.0"
|
||||
},
|
||||
@@ -13315,7 +13362,6 @@
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
|
||||
"integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
|
||||
"license": "BSD-2-Clause",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.12"
|
||||
},
|
||||
@@ -16277,6 +16323,7 @@
|
||||
"https://github.com/sponsors/ctavan"
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"uuid": "dist/esm/bin/uuid"
|
||||
}
|
||||
|
||||
@@ -27,9 +27,12 @@
|
||||
"@tailwindcss/postcss": "^4.1.8",
|
||||
"@tweenjs/tween.js": "^25.0.0",
|
||||
"angular-oauth2-oidc": "^20.0.0",
|
||||
"chart.js": "^4.5.0",
|
||||
"chartjs-plugin-datalabels": "^2.2.0",
|
||||
"epubjs": "^0.3.93",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"ng-lazyload-image": "^9.1.3",
|
||||
"ng2-charts": "^8.0.0",
|
||||
"ngx-extended-pdf-viewer": "^23.3.1",
|
||||
"ngx-infinite-scroll": "^20.0.0",
|
||||
"primeicons": "^7.0.0",
|
||||
@@ -40,7 +43,8 @@
|
||||
"tailwindcss-primeui": "^0.6.1",
|
||||
"tslib": "^2.8.1",
|
||||
"ws": "^8.18.2",
|
||||
"zone.js": "^0.15.1"
|
||||
"zone.js": "^0.15.1",
|
||||
"@angular/cdk": "^20.2.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@angular-devkit/build-angular": "^20.1.5",
|
||||
|
||||
@@ -16,8 +16,8 @@ import {LoginGuard} from './core/setup/ login.guard';
|
||||
import {OidcCallbackComponent} from './core/security/oidc-callback/oidc-callback.component';
|
||||
import {CbxReaderComponent} from './book/components/cbx-reader/cbx-reader.component';
|
||||
import {BookdropFileReviewComponent} from './bookdrop/bookdrop-file-review-component/bookdrop-file-review.component';
|
||||
import {MagicShelfComponent} from './magic-shelf-component/magic-shelf-component';
|
||||
import {MainDashboardComponent} from './dashboard/components/main-dashboard/main-dashboard.component';
|
||||
import {StatsComponent} from './stats-component/stats-component';
|
||||
|
||||
export const routes: Routes = [
|
||||
{
|
||||
@@ -44,7 +44,8 @@ export const routes: Routes = [
|
||||
{path: 'unshelved-books', component: BookBrowserComponent, canActivate: [AuthGuard]},
|
||||
{ path: 'magic-shelf/:magicShelfId/books', component: BookBrowserComponent, canActivate: [AuthGuard] },
|
||||
{path: 'book/:bookId', component: BookMetadataCenterComponent, canActivate: [AuthGuard]},
|
||||
{path: 'bookdrop', component: BookdropFileReviewComponent, canActivate: [AuthGuard]}
|
||||
{path: 'bookdrop', component: BookdropFileReviewComponent, canActivate: [AuthGuard]},
|
||||
{path: 'stats', component: StatsComponent, canActivate: [AuthGuard]},
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -67,6 +67,10 @@ export class BookService {
|
||||
})
|
||||
);
|
||||
|
||||
getCurrentBookState(): BookState {
|
||||
return this.bookStateSubject.value;
|
||||
}
|
||||
|
||||
private fetchBooks(): Observable<Book[]> {
|
||||
return this.http.get<Book[]>(this.url).pipe(
|
||||
tap(books => {
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
<div class="flex items-center w-full gap-4">
|
||||
<app-book-searcher class="md:block flex-grow"></app-book-searcher>
|
||||
<ul class="topbar-items hidden md:flex items-center gap-3 ml-auto pl-4">
|
||||
<div class="flex gap-6">
|
||||
<div class="flex gap-4">
|
||||
@if (userService.userState$ | async; as userState) {
|
||||
<li>
|
||||
@if (userState.user?.permissions?.canManipulateLibrary || userState.user?.permissions?.admin) {
|
||||
@@ -52,6 +52,11 @@
|
||||
}
|
||||
</li>
|
||||
}
|
||||
<li>
|
||||
<a class="topbar-item" (click)="navigateToStats()" pTooltip="Stats" tooltipPosition="bottom">
|
||||
<i class="pi pi-chart-bar text-surface-100"></i>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="topbar-item" (click)="navigateToSettings()" pTooltip="Settings" tooltipPosition="bottom">
|
||||
<i class="pi pi-cog text-surface-100"></i>
|
||||
@@ -62,7 +67,7 @@
|
||||
<p-divider layout="vertical"/>
|
||||
|
||||
|
||||
<div class="flex gap-6">
|
||||
<div class="flex gap-4">
|
||||
<li class="relative">
|
||||
<button
|
||||
type="button"
|
||||
@@ -110,7 +115,7 @@
|
||||
</li>
|
||||
</div>
|
||||
<p-divider layout="vertical"/>
|
||||
<div class="flex gap-6">
|
||||
<div class="flex gap-4">
|
||||
<li>
|
||||
<a class="topbar-item"
|
||||
href="https://booklore-app.github.io/booklore-docs/docs/getting-started/"
|
||||
|
||||
@@ -137,7 +137,11 @@ export class AppTopBarComponent implements OnDestroy {
|
||||
}
|
||||
|
||||
navigateToBookdrop() {
|
||||
this.router.navigate(['/bookdrop'], {queryParams: {reload: Date.now()}});
|
||||
this.router.navigate(['/bookdrop']);
|
||||
}
|
||||
|
||||
navigateToStats() {
|
||||
this.router.navigate(['/stats']);
|
||||
}
|
||||
|
||||
logout() {
|
||||
|
||||
@@ -97,4 +97,3 @@
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,321 @@
|
||||
import {inject, Injectable, OnDestroy} from '@angular/core';
|
||||
import {BehaviorSubject, EMPTY, Observable, Subject} from 'rxjs';
|
||||
import {map, takeUntil, catchError, filter, first, switchMap} from 'rxjs/operators';
|
||||
import {LibraryFilterService} from './library-filter.service';
|
||||
import {BookService} from '../../book/service/book.service';
|
||||
import {Book, ReadStatus} from '../../book/model/book.model';
|
||||
import {ChartConfiguration, ChartData, ChartType} from 'chart.js';
|
||||
|
||||
interface AuthorStats {
|
||||
author: string;
|
||||
bookCount: number;
|
||||
averageRating: number;
|
||||
totalPages: number;
|
||||
readStatusCounts: Record<ReadStatus, number>;
|
||||
}
|
||||
|
||||
const READ_STATUS_COLORS: Record<ReadStatus, string> = {
|
||||
[ReadStatus.READ]: '#2ecc71',
|
||||
[ReadStatus.READING]: '#f39c12',
|
||||
[ReadStatus.RE_READING]: '#9b59b6',
|
||||
[ReadStatus.PARTIALLY_READ]: '#e67e22',
|
||||
[ReadStatus.PAUSED]: '#34495e',
|
||||
[ReadStatus.UNREAD]: '#4169e1',
|
||||
[ReadStatus.WONT_READ]: '#95a5a6',
|
||||
[ReadStatus.ABANDONED]: '#e74c3c',
|
||||
[ReadStatus.UNSET]: '#3498db'
|
||||
};
|
||||
|
||||
const CHART_DEFAULTS = {
|
||||
borderColor: '#ffffff',
|
||||
hoverBorderWidth: 1,
|
||||
hoverBorderColor: '#ffffff'
|
||||
} as const;
|
||||
|
||||
type AuthorChartData = ChartData<'bar', number[], string>;
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class AuthorPopularityChartService implements OnDestroy {
|
||||
private readonly bookService = inject(BookService);
|
||||
private readonly libraryFilterService = inject(LibraryFilterService);
|
||||
private readonly destroy$ = new Subject<void>();
|
||||
|
||||
public readonly authorChartType = 'bar' as const;
|
||||
|
||||
public readonly authorChartOptions: ChartConfiguration<'bar'>['options'] = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
scales: {
|
||||
x: {
|
||||
stacked: true,
|
||||
ticks: {
|
||||
color: '#ffffff',
|
||||
font: {size: 10},
|
||||
maxRotation: 45,
|
||||
minRotation: 0
|
||||
},
|
||||
grid: {
|
||||
color: 'rgba(255, 255, 255, 0.1)'
|
||||
},
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Authors',
|
||||
color: '#ffffff',
|
||||
font: {
|
||||
family: "'Inter', sans-serif",
|
||||
size: 11
|
||||
},
|
||||
}
|
||||
},
|
||||
y: {
|
||||
stacked: true,
|
||||
beginAtZero: true,
|
||||
ticks: {
|
||||
color: '#ffffff',
|
||||
font: {
|
||||
family: "'Inter', sans-serif",
|
||||
size: 11
|
||||
},
|
||||
stepSize: 1,
|
||||
maxTicksLimit: 25
|
||||
},
|
||||
grid: {
|
||||
color: 'rgba(255, 255, 255, 0.05)'
|
||||
},
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Number of Books',
|
||||
color: '#ffffff',
|
||||
font: {
|
||||
family: "'Inter', sans-serif",
|
||||
size: 11.5
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: true,
|
||||
position: 'top',
|
||||
labels: {
|
||||
color: '#ffffff',
|
||||
font: {
|
||||
family: "'Inter', sans-serif",
|
||||
size: 10
|
||||
},
|
||||
padding: 15,
|
||||
boxWidth: 12
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.9)',
|
||||
titleColor: '#ffffff',
|
||||
bodyColor: '#ffffff',
|
||||
borderColor: '#ffffff',
|
||||
borderWidth: 1,
|
||||
cornerRadius: 6,
|
||||
displayColors: true,
|
||||
padding: 12,
|
||||
titleFont: {size: 14, weight: 'bold'},
|
||||
bodyFont: {size: 12},
|
||||
callbacks: {
|
||||
title: (context) => {
|
||||
const dataIndex = context[0].dataIndex;
|
||||
const stats = this.getLastCalculatedStats();
|
||||
return stats[dataIndex]?.author || 'Unknown Author';
|
||||
},
|
||||
label: this.formatTooltipLabel.bind(this)
|
||||
}
|
||||
}
|
||||
},
|
||||
interaction: {
|
||||
intersect: false,
|
||||
mode: 'index'
|
||||
}
|
||||
};
|
||||
|
||||
private readonly authorChartDataSubject = new BehaviorSubject<AuthorChartData>({
|
||||
labels: [],
|
||||
datasets: Object.values(ReadStatus).map(status => ({
|
||||
label: this.formatReadStatusLabel(status),
|
||||
data: [],
|
||||
backgroundColor: READ_STATUS_COLORS[status],
|
||||
...CHART_DEFAULTS
|
||||
}))
|
||||
});
|
||||
|
||||
public readonly authorChartData$: Observable<AuthorChartData> =
|
||||
this.authorChartDataSubject.asObservable();
|
||||
|
||||
private lastCalculatedStats: AuthorStats[] = [];
|
||||
|
||||
constructor() {
|
||||
this.bookService.bookState$
|
||||
.pipe(
|
||||
filter(state => state.loaded),
|
||||
first(),
|
||||
switchMap(() =>
|
||||
this.libraryFilterService.selectedLibrary$.pipe(
|
||||
takeUntil(this.destroy$)
|
||||
)
|
||||
),
|
||||
catchError((error) => {
|
||||
console.error('Error processing author stats:', error);
|
||||
return EMPTY;
|
||||
})
|
||||
)
|
||||
.subscribe(() => {
|
||||
const stats = this.calculateAuthorStats();
|
||||
this.updateChartData(stats);
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
}
|
||||
|
||||
private updateChartData(stats: AuthorStats[]): void {
|
||||
try {
|
||||
this.lastCalculatedStats = stats;
|
||||
const topAuthors = stats
|
||||
.sort((a, b) => b.bookCount - a.bookCount)
|
||||
.slice(0, 25);
|
||||
|
||||
const labels = topAuthors.map(s => {
|
||||
return s.author.length > 20
|
||||
? s.author.substring(0, 15) + '..'
|
||||
: s.author;
|
||||
});
|
||||
|
||||
const datasets = Object.values(ReadStatus).map(status => ({
|
||||
label: this.formatReadStatusLabel(status),
|
||||
data: topAuthors.map(s => s.readStatusCounts[status] || 0),
|
||||
backgroundColor: READ_STATUS_COLORS[status],
|
||||
...CHART_DEFAULTS
|
||||
}));
|
||||
|
||||
this.authorChartDataSubject.next({
|
||||
labels,
|
||||
datasets
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error updating author chart data:', error);
|
||||
}
|
||||
}
|
||||
|
||||
private calculateAuthorStats(): AuthorStats[] {
|
||||
const currentState = this.bookService.getCurrentBookState();
|
||||
const selectedLibraryId = this.libraryFilterService.getCurrentSelectedLibrary();
|
||||
|
||||
if (!this.isValidBookState(currentState)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const filteredBooks = this.filterBooksByLibrary(currentState.books!, selectedLibraryId);
|
||||
return this.processAuthorStats(filteredBooks);
|
||||
}
|
||||
|
||||
private isValidBookState(state: any): boolean {
|
||||
return state?.loaded && state?.books && Array.isArray(state.books) && state.books.length > 0;
|
||||
}
|
||||
|
||||
private filterBooksByLibrary(books: Book[], selectedLibraryId: string | number | null): Book[] {
|
||||
return selectedLibraryId
|
||||
? books.filter(book => book.libraryId === selectedLibraryId)
|
||||
: books;
|
||||
}
|
||||
|
||||
private processAuthorStats(books: Book[]): AuthorStats[] {
|
||||
const authorMap = new Map<string, {
|
||||
bookCount: number;
|
||||
totalRating: number;
|
||||
ratingCount: number;
|
||||
totalPages: number;
|
||||
readStatusCounts: Record<ReadStatus, number>;
|
||||
}>();
|
||||
|
||||
books.forEach(book => {
|
||||
const authors = book.metadata?.authors || ['Unknown Author'];
|
||||
|
||||
authors.forEach(author => {
|
||||
if (!authorMap.has(author)) {
|
||||
authorMap.set(author, {
|
||||
bookCount: 0,
|
||||
totalRating: 0,
|
||||
ratingCount: 0,
|
||||
totalPages: 0,
|
||||
readStatusCounts: Object.values(ReadStatus).reduce((acc, status) => {
|
||||
acc[status] = 0;
|
||||
return acc;
|
||||
}, {} as Record<ReadStatus, number>)
|
||||
});
|
||||
}
|
||||
|
||||
const stats = authorMap.get(author)!;
|
||||
stats.bookCount++;
|
||||
|
||||
if (book.metadata?.pageCount) {
|
||||
stats.totalPages += book.metadata.pageCount;
|
||||
}
|
||||
|
||||
const rawStatus = book.readStatus;
|
||||
const readStatus: ReadStatus = Object.values(ReadStatus).includes(rawStatus as ReadStatus)
|
||||
? (rawStatus as ReadStatus)
|
||||
: ReadStatus.UNSET;
|
||||
stats.readStatusCounts[readStatus]++;
|
||||
|
||||
const ratings = [];
|
||||
if (book.metadata?.goodreadsRating) ratings.push(book.metadata.goodreadsRating);
|
||||
if (book.metadata?.amazonRating) ratings.push(book.metadata.amazonRating);
|
||||
if (book.metadata?.hardcoverRating) ratings.push(book.metadata.hardcoverRating);
|
||||
if (book.metadata?.personalRating) ratings.push(book.metadata.personalRating);
|
||||
|
||||
if (ratings.length > 0) {
|
||||
const avgRating = ratings.reduce((sum, rating) => sum + rating, 0) / ratings.length;
|
||||
stats.totalRating += avgRating;
|
||||
stats.ratingCount++;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return Array.from(authorMap.entries()).map(([author, stats]) => ({
|
||||
author,
|
||||
bookCount: stats.bookCount,
|
||||
averageRating: stats.ratingCount > 0 ? stats.totalRating / stats.ratingCount : 0,
|
||||
totalPages: stats.totalPages,
|
||||
readStatusCounts: stats.readStatusCounts
|
||||
})).filter(stat => stat.bookCount > 0);
|
||||
}
|
||||
|
||||
private formatTooltipLabel(context: any): string {
|
||||
const dataIndex = context.dataIndex;
|
||||
const stats = this.getLastCalculatedStats();
|
||||
|
||||
if (!stats || dataIndex >= stats.length) {
|
||||
return `${context.parsed.y} books`;
|
||||
}
|
||||
|
||||
const author = stats[dataIndex];
|
||||
const value = context.parsed.y;
|
||||
const datasetLabel = context.dataset.label;
|
||||
|
||||
if (context.dataset.label === 'Read' && author.averageRating > 0) {
|
||||
return `${datasetLabel}: ${value} | Avg Rating: ${author.averageRating.toFixed(1)}`;
|
||||
} else {
|
||||
return `${datasetLabel}: ${value}`;
|
||||
}
|
||||
}
|
||||
|
||||
private formatReadStatusLabel(status: ReadStatus): string {
|
||||
return status.split('_').map(word =>
|
||||
word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()
|
||||
).join(' ');
|
||||
}
|
||||
|
||||
private getLastCalculatedStats(): AuthorStats[] {
|
||||
return this.lastCalculatedStats;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
import {inject, Injectable, OnDestroy} from '@angular/core';
|
||||
import {BehaviorSubject, combineLatest, Observable, Subject} from 'rxjs';
|
||||
import {map, takeUntil, catchError} from 'rxjs/operators';
|
||||
import {ChartConfiguration, ChartData} from 'chart.js';
|
||||
|
||||
import {LibraryFilterService} from './library-filter.service';
|
||||
import {BookService} from '../../book/service/book.service';
|
||||
import {Book} from '../../book/model/book.model';
|
||||
|
||||
interface AuthorSeriesStats {
|
||||
category: string;
|
||||
count: number;
|
||||
authorNames: string[];
|
||||
totalSeries: number;
|
||||
averageSeriesLength: number;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const CHART_COLORS = [
|
||||
'#e74c3c', '#3498db', '#2ecc71', '#f39c12', '#9b59b6',
|
||||
'#e67e22', '#1abc9c', '#34495e', '#95a5a6', '#27ae60',
|
||||
'#2980b9', '#c0392b', '#d35400', '#8e44ad', '#16a085'
|
||||
] as const;
|
||||
|
||||
const CHART_DEFAULTS = {
|
||||
borderColor: '#ffffff',
|
||||
borderWidth: 2,
|
||||
hoverBorderWidth: 3,
|
||||
hoverBorderColor: '#ffffff'
|
||||
} as const;
|
||||
|
||||
type AuthorSeriesChartData = ChartData<'polarArea', number[], string>;
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class AuthorSeriesPortfolioChartService implements OnDestroy {
|
||||
private readonly bookService = inject(BookService);
|
||||
private readonly libraryFilterService = inject(LibraryFilterService);
|
||||
private readonly destroy$ = new Subject<void>();
|
||||
|
||||
public readonly authorSeriesChartType = 'polarArea' as const;
|
||||
|
||||
public readonly authorSeriesChartOptions: ChartConfiguration<'polarArea'>['options'] = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
scales: {
|
||||
r: {
|
||||
beginAtZero: true,
|
||||
ticks: {
|
||||
color: '#ffffff',
|
||||
font: {
|
||||
family: "'Inter', sans-serif",
|
||||
size: 11
|
||||
},
|
||||
stepSize: 1,
|
||||
backdropColor: 'transparent'
|
||||
},
|
||||
grid: {
|
||||
color: 'rgba(255, 255, 255, 0.15)'
|
||||
},
|
||||
angleLines: {
|
||||
color: 'rgba(255, 255, 255, 0.15)'
|
||||
},
|
||||
pointLabels: {
|
||||
color: '#ffffff',
|
||||
font: {
|
||||
family: "'Inter', sans-serif",
|
||||
size: 11
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: true,
|
||||
position: 'bottom',
|
||||
labels: {
|
||||
color: '#ffffff',
|
||||
font: {
|
||||
family: "'Inter', sans-serif",
|
||||
size: 11
|
||||
},
|
||||
padding: 10,
|
||||
usePointStyle: true,
|
||||
generateLabels: this.generateLegendLabels.bind(this)
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.9)',
|
||||
titleColor: '#ffffff',
|
||||
bodyColor: '#ffffff',
|
||||
borderColor: '#ffffff',
|
||||
borderWidth: 1,
|
||||
cornerRadius: 6,
|
||||
displayColors: true,
|
||||
padding: 12,
|
||||
titleFont: {size: 14, weight: 'bold'},
|
||||
bodyFont: {size: 12},
|
||||
callbacks: {
|
||||
title: (context) => context[0]?.label || '',
|
||||
label: this.formatTooltipLabel.bind(this)
|
||||
}
|
||||
}
|
||||
},
|
||||
interaction: {
|
||||
intersect: false,
|
||||
mode: 'point'
|
||||
}
|
||||
};
|
||||
|
||||
private readonly authorSeriesChartDataSubject = new BehaviorSubject<AuthorSeriesChartData>({
|
||||
labels: [],
|
||||
datasets: [{
|
||||
data: [],
|
||||
backgroundColor: [...CHART_COLORS],
|
||||
...CHART_DEFAULTS
|
||||
}]
|
||||
});
|
||||
|
||||
public readonly authorSeriesChartData$: Observable<AuthorSeriesChartData> = this.authorSeriesChartDataSubject.asObservable();
|
||||
|
||||
constructor() {
|
||||
this.initializeChartDataSubscription();
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
}
|
||||
|
||||
private initializeChartDataSubscription(): void {
|
||||
this.getAuthorSeriesStats()
|
||||
.pipe(
|
||||
takeUntil(this.destroy$),
|
||||
catchError((error) => {
|
||||
console.error('Error processing author series stats:', error);
|
||||
return [];
|
||||
})
|
||||
)
|
||||
.subscribe((stats) => this.updateChartData(stats));
|
||||
}
|
||||
|
||||
private updateChartData(stats: AuthorSeriesStats[]): void {
|
||||
try {
|
||||
this.lastCalculatedStats = stats;
|
||||
const labels = stats.map(s => s.category);
|
||||
const dataValues = stats.map(s => s.count);
|
||||
const colors = this.getColorsForData(stats.length);
|
||||
|
||||
this.authorSeriesChartDataSubject.next({
|
||||
labels,
|
||||
datasets: [{
|
||||
data: dataValues,
|
||||
backgroundColor: colors,
|
||||
...CHART_DEFAULTS
|
||||
}]
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error updating author series chart data:', error);
|
||||
}
|
||||
}
|
||||
|
||||
private getColorsForData(dataLength: number): string[] {
|
||||
const colors = [...CHART_COLORS];
|
||||
while (colors.length < dataLength) {
|
||||
colors.push(...CHART_COLORS);
|
||||
}
|
||||
return colors.slice(0, dataLength);
|
||||
}
|
||||
|
||||
public getAuthorSeriesStats(): Observable<AuthorSeriesStats[]> {
|
||||
return combineLatest([
|
||||
this.bookService.bookState$,
|
||||
this.libraryFilterService.selectedLibrary$
|
||||
]).pipe(
|
||||
map(([state, selectedLibraryId]) => {
|
||||
if (!this.isValidBookState(state)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const filteredBooks = this.filterBooksByLibrary(state.books!, String(selectedLibraryId));
|
||||
return this.processAuthorSeriesStats(filteredBooks);
|
||||
}),
|
||||
catchError((error) => {
|
||||
console.error('Error getting author series stats:', error);
|
||||
return [];
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private isValidBookState(state: any): boolean {
|
||||
return state?.loaded && state?.books && Array.isArray(state.books) && state.books.length > 0;
|
||||
}
|
||||
|
||||
private filterBooksByLibrary(books: Book[], selectedLibraryId: string | null): Book[] {
|
||||
return selectedLibraryId && selectedLibraryId !== 'null'
|
||||
? books.filter(book => String(book.libraryId) === selectedLibraryId)
|
||||
: books;
|
||||
}
|
||||
|
||||
private processAuthorSeriesStats(books: Book[]): AuthorSeriesStats[] {
|
||||
if (books.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const authorSeriesMap = new Map<string, Map<string, Book[]>>();
|
||||
|
||||
books.forEach(book => {
|
||||
if (!book.metadata?.authors || !book.metadata.seriesName) return;
|
||||
|
||||
book.metadata.authors.forEach(author => {
|
||||
if (!authorSeriesMap.has(author)) {
|
||||
authorSeriesMap.set(author, new Map());
|
||||
}
|
||||
|
||||
const authorSeries = authorSeriesMap.get(author)!;
|
||||
const seriesName = book.metadata!.seriesName!;
|
||||
|
||||
if (!authorSeries.has(seriesName)) {
|
||||
authorSeries.set(seriesName, []);
|
||||
}
|
||||
authorSeries.get(seriesName)!.push(book);
|
||||
});
|
||||
});
|
||||
|
||||
const authorPatterns = new Map<string, { authors: string[], totalSeries: number, totalSeriesLength: number }>();
|
||||
|
||||
const categories = [
|
||||
'Single Series Masters',
|
||||
'Prolific Series Writers',
|
||||
'Multi-Series Creators',
|
||||
'Series Experimenters',
|
||||
'Franchise Builders',
|
||||
'Diverse Portfolio Authors'
|
||||
];
|
||||
|
||||
categories.forEach(cat => {
|
||||
authorPatterns.set(cat, {authors: [], totalSeries: 0, totalSeriesLength: 0});
|
||||
});
|
||||
|
||||
for (const [author, authorSeries] of authorSeriesMap) {
|
||||
const seriesCount = authorSeries.size;
|
||||
const seriesLengths: number[] = [];
|
||||
let totalBooks = 0;
|
||||
|
||||
for (const [_, seriesBooks] of authorSeries) {
|
||||
const seriesTotals = seriesBooks
|
||||
.map(book => book.metadata?.seriesTotal)
|
||||
.filter((total): total is number => total != null && total > 0);
|
||||
|
||||
const seriesLength = seriesTotals.length > 0
|
||||
? Math.max(...seriesTotals)
|
||||
: seriesBooks.length;
|
||||
|
||||
seriesLengths.push(seriesLength);
|
||||
totalBooks += seriesBooks.length;
|
||||
}
|
||||
|
||||
const averageSeriesLength = seriesLengths.reduce((sum, length) => sum + length, 0) / seriesLengths.length;
|
||||
const maxSeriesLength = Math.max(...seriesLengths);
|
||||
|
||||
let category: string;
|
||||
|
||||
if (seriesCount === 1 && maxSeriesLength >= 10) {
|
||||
category = 'Single Series Masters';
|
||||
} else if (seriesCount >= 5 && averageSeriesLength >= 5) {
|
||||
category = 'Prolific Series Writers';
|
||||
} else if (seriesCount >= 3 && seriesCount <= 4) {
|
||||
category = 'Multi-Series Creators';
|
||||
} else if (seriesCount >= 2 && averageSeriesLength <= 3) {
|
||||
category = 'Series Experimenters';
|
||||
} else if (seriesCount >= 2 && maxSeriesLength >= 15) {
|
||||
category = 'Franchise Builders';
|
||||
} else {
|
||||
category = 'Diverse Portfolio Authors';
|
||||
}
|
||||
|
||||
const categoryData = authorPatterns.get(category)!;
|
||||
categoryData.authors.push(author);
|
||||
categoryData.totalSeries += seriesCount;
|
||||
categoryData.totalSeriesLength += Math.round(averageSeriesLength);
|
||||
}
|
||||
|
||||
const descriptions: Record<string, string> = {
|
||||
'Single Series Masters': 'Authors focused on one long-running series',
|
||||
'Prolific Series Writers': 'Authors with multiple substantial series',
|
||||
'Multi-Series Creators': 'Authors balancing several series',
|
||||
'Series Experimenters': 'Authors exploring shorter series formats',
|
||||
'Franchise Builders': 'Authors creating extensive fictional universes',
|
||||
'Diverse Portfolio Authors': 'Authors with varied series approaches'
|
||||
};
|
||||
|
||||
return Array.from(authorPatterns.entries())
|
||||
.filter(([_, data]) => data.authors.length > 0)
|
||||
.map(([category, data]) => ({
|
||||
category,
|
||||
count: data.authors.length,
|
||||
authorNames: data.authors,
|
||||
totalSeries: data.totalSeries,
|
||||
averageSeriesLength: data.authors.length > 0
|
||||
? Math.round(data.totalSeriesLength / data.authors.length)
|
||||
: 0,
|
||||
description: descriptions[category] || 'Various series approaches'
|
||||
}))
|
||||
.sort((a, b) => b.count - a.count);
|
||||
}
|
||||
|
||||
private generateLegendLabels(chart: any) {
|
||||
const data = chart.data;
|
||||
if (!data.labels?.length || !data.datasets?.[0]?.data?.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const dataset = data.datasets[0];
|
||||
const dataValues = dataset.data as number[];
|
||||
|
||||
return data.labels.map((label: string, index: number) => {
|
||||
const isVisible = typeof chart.getDataVisibility === 'function'
|
||||
? chart.getDataVisibility(index)
|
||||
: !((chart.getDatasetMeta && chart.getDatasetMeta(0)?.data?.[index]?.hidden) || false);
|
||||
|
||||
return {
|
||||
text: `${label} (${dataValues[index]})`,
|
||||
fillStyle: (dataset.backgroundColor as string[])[index],
|
||||
strokeStyle: '#ffffff',
|
||||
lineWidth: 1,
|
||||
hidden: !isVisible,
|
||||
index,
|
||||
fontColor: '#ffffff'
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private formatTooltipLabel(context: any): string {
|
||||
const dataIndex = context.dataIndex;
|
||||
const stats = this.getLastCalculatedStats();
|
||||
|
||||
if (!stats || dataIndex >= stats.length) {
|
||||
return `${context.parsed} authors`;
|
||||
}
|
||||
|
||||
const categoryStats = stats[dataIndex];
|
||||
const exampleAuthors = categoryStats.authorNames.slice(0, 3).join(', ');
|
||||
const moreAuthors = categoryStats.authorNames.length > 3 ? `... +${categoryStats.authorNames.length - 3} more` : '';
|
||||
|
||||
return `${categoryStats.count} authors | ${categoryStats.totalSeries} total series | Avg ${categoryStats.averageSeriesLength} books/series | ${categoryStats.description} | Examples: ${exampleAuthors}${moreAuthors}`;
|
||||
}
|
||||
|
||||
private lastCalculatedStats: AuthorSeriesStats[] = [];
|
||||
|
||||
private getLastCalculatedStats(): AuthorSeriesStats[] {
|
||||
return this.lastCalculatedStats;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
import {inject, Injectable, OnDestroy} from '@angular/core';
|
||||
import {BehaviorSubject, EMPTY, Observable, Subject} from 'rxjs';
|
||||
import {map, takeUntil, catchError, filter, first, switchMap} from 'rxjs/operators';
|
||||
import {ChartConfiguration, ChartData} from 'chart.js';
|
||||
|
||||
import {LibraryFilterService} from './library-filter.service';
|
||||
import {BookService} from '../../book/service/book.service';
|
||||
import {Book} from '../../book/model/book.model';
|
||||
|
||||
interface QualityScoreStats {
|
||||
category: string;
|
||||
count: number;
|
||||
averageScore: number;
|
||||
scoreRange: string;
|
||||
}
|
||||
|
||||
const CHART_COLORS = [
|
||||
'#e74c3c', '#e67e22', '#f39c12', '#f1c40f', '#2ecc71',
|
||||
'#27ae60', '#3498db', '#9b59b6', '#8e44ad', '#34495e'
|
||||
] as const;
|
||||
|
||||
const CHART_DEFAULTS = {
|
||||
borderColor: '#ffffff',
|
||||
borderWidth: 2,
|
||||
hoverBorderWidth: 3,
|
||||
hoverBorderColor: '#ffffff'
|
||||
} as const;
|
||||
|
||||
const QUALITY_COLORS = {
|
||||
'Excellent (9+)': '#2ecc71', // Green
|
||||
'Very Good (8-9)': '#27ae60', // Dark Green
|
||||
'Good (6-8)': '#3498db', // Blue
|
||||
'Average (4-6)': '#f39c12', // Orange
|
||||
'Poor (2-4)': '#e67e22', // Dark Orange
|
||||
'Very Poor (0-2)': '#e74c3c' // Red
|
||||
} as const;
|
||||
|
||||
type QualityChartData = ChartData<'doughnut', number[], string>;
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class BookQualityScoreChartService implements OnDestroy {
|
||||
private readonly bookService = inject(BookService);
|
||||
private readonly libraryFilterService = inject(LibraryFilterService);
|
||||
private readonly destroy$ = new Subject<void>();
|
||||
|
||||
public readonly qualityChartType = 'doughnut' as const;
|
||||
|
||||
public readonly qualityChartOptions: ChartConfiguration<'doughnut'>['options'] = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: true,
|
||||
position: 'bottom',
|
||||
labels: {
|
||||
padding: 12,
|
||||
usePointStyle: true,
|
||||
generateLabels: this.generateLegendLabels.bind(this)
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
enabled: true,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.9)',
|
||||
titleColor: '#ffffff',
|
||||
bodyColor: '#ffffff',
|
||||
borderColor: '#ffffff',
|
||||
borderWidth: 1,
|
||||
cornerRadius: 6,
|
||||
displayColors: true,
|
||||
padding: 12,
|
||||
titleFont: {size: 14, weight: 'bold'},
|
||||
bodyFont: {size: 12},
|
||||
position: 'nearest',
|
||||
callbacks: {
|
||||
title: (context) => context[0]?.label || '',
|
||||
label: this.formatTooltipLabel.bind(this)
|
||||
}
|
||||
}
|
||||
},
|
||||
interaction: {
|
||||
intersect: false,
|
||||
mode: 'point'
|
||||
},
|
||||
cutout: '45%'
|
||||
};
|
||||
|
||||
private readonly qualityChartDataSubject = new BehaviorSubject<QualityChartData>({
|
||||
labels: [],
|
||||
datasets: [{
|
||||
data: [],
|
||||
backgroundColor: [...CHART_COLORS],
|
||||
...CHART_DEFAULTS
|
||||
}]
|
||||
});
|
||||
|
||||
public readonly qualityChartData$: Observable<QualityChartData> = this.qualityChartDataSubject.asObservable();
|
||||
|
||||
constructor() {
|
||||
this.bookService.bookState$
|
||||
.pipe(
|
||||
filter(state => state.loaded),
|
||||
first(),
|
||||
switchMap(() =>
|
||||
this.libraryFilterService.selectedLibrary$.pipe(
|
||||
takeUntil(this.destroy$)
|
||||
)
|
||||
),
|
||||
catchError((error) => {
|
||||
console.error('Error processing quality score stats:', error);
|
||||
return EMPTY;
|
||||
})
|
||||
)
|
||||
.subscribe(() => {
|
||||
const stats = this.calculateQualityScoreStats();
|
||||
this.updateChartData(stats);
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
}
|
||||
|
||||
private updateChartData(stats: QualityScoreStats[]): void {
|
||||
try {
|
||||
this.lastCalculatedStats = stats;
|
||||
const labels = stats.map(s => s.category);
|
||||
const dataValues = stats.map(s => s.count);
|
||||
const colors = this.getColorsForQualityData(stats);
|
||||
|
||||
this.qualityChartDataSubject.next({
|
||||
labels,
|
||||
datasets: [{
|
||||
data: dataValues,
|
||||
backgroundColor: colors,
|
||||
...CHART_DEFAULTS
|
||||
}]
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error updating quality chart data:', error);
|
||||
}
|
||||
}
|
||||
|
||||
private getColorsForQualityData(stats: QualityScoreStats[]): string[] {
|
||||
return stats.map(stat => QUALITY_COLORS[stat.category as keyof typeof QUALITY_COLORS] || '#34495e');
|
||||
}
|
||||
|
||||
private calculateQualityScoreStats(): QualityScoreStats[] {
|
||||
const currentState = this.bookService.getCurrentBookState();
|
||||
const selectedLibraryId = this.libraryFilterService.getCurrentSelectedLibrary();
|
||||
|
||||
if (!this.isValidBookState(currentState)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const filteredBooks = this.filterBooksByLibrary(currentState.books!, String(selectedLibraryId));
|
||||
return this.processQualityScoreStats(filteredBooks);
|
||||
}
|
||||
|
||||
private isValidBookState(state: any): boolean {
|
||||
return state?.loaded && state?.books && Array.isArray(state.books) && state.books.length > 0;
|
||||
}
|
||||
|
||||
private filterBooksByLibrary(books: Book[], selectedLibraryId: string | null): Book[] {
|
||||
return selectedLibraryId && selectedLibraryId !== 'null'
|
||||
? books.filter(book => String(book.libraryId) === selectedLibraryId)
|
||||
: books;
|
||||
}
|
||||
|
||||
private processQualityScoreStats(books: Book[]): QualityScoreStats[] {
|
||||
if (books.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const qualityCategories = this.categorizeByQualityScore(books);
|
||||
return this.convertToQualityStats(qualityCategories);
|
||||
}
|
||||
|
||||
private categorizeByQualityScore(books: Book[]): Map<string, { books: Book[], scores: number[] }> {
|
||||
const categories = new Map<string, { books: Book[], scores: number[] }>();
|
||||
|
||||
// Initialize 6 categories
|
||||
categories.set('Excellent (9+)', {books: [], scores: []});
|
||||
categories.set('Very Good (8-9)', {books: [], scores: []});
|
||||
categories.set('Good (6-8)', {books: [], scores: []});
|
||||
categories.set('Average (4-6)', {books: [], scores: []});
|
||||
categories.set('Poor (2-4)', {books: [], scores: []});
|
||||
categories.set('Very Poor (0-2)', {books: [], scores: []});
|
||||
|
||||
for (const book of books) {
|
||||
const qualityScore = this.calculateQualityScore(book);
|
||||
const category = this.getQualityCategory(qualityScore);
|
||||
|
||||
categories.get(category)!.books.push(book);
|
||||
categories.get(category)!.scores.push(qualityScore);
|
||||
}
|
||||
|
||||
return categories;
|
||||
}
|
||||
|
||||
private calculateQualityScore(book: Book): number {
|
||||
// Use metadataMatchScore directly, scale from 0-100 to 0-10
|
||||
if (book.metadataMatchScore !== null && book.metadataMatchScore !== undefined) {
|
||||
return Math.min(10, Math.max(0, book.metadataMatchScore / 10));
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
private getQualityCategory(score: number): string {
|
||||
if (score >= 9) return 'Excellent (9+)';
|
||||
if (score >= 8) return 'Very Good (8-9)';
|
||||
if (score >= 6) return 'Good (6-8)';
|
||||
if (score >= 4) return 'Average (4-6)';
|
||||
if (score >= 2) return 'Poor (2-4)';
|
||||
return 'Very Poor (0-2)';
|
||||
}
|
||||
|
||||
private convertToQualityStats(categoriesMap: Map<string, { books: Book[], scores: number[] }>): QualityScoreStats[] {
|
||||
const scoreRanges: Record<string, string> = {
|
||||
'Excellent (9+)': '90-100%',
|
||||
'Very Good (8-9)': '80-89%',
|
||||
'Good (6-8)': '60-79%',
|
||||
'Average (4-6)': '40-59%',
|
||||
'Poor (2-4)': '20-39%',
|
||||
'Very Poor (0-2)': '0-19%'
|
||||
};
|
||||
|
||||
return Array.from(categoriesMap.entries())
|
||||
.filter(([_, data]) => data.books.length > 0)
|
||||
.map(([category, data]) => {
|
||||
const averageScore = data.scores.reduce((sum, score) => sum + score, 0) / data.scores.length;
|
||||
|
||||
return {
|
||||
category,
|
||||
count: data.books.length,
|
||||
averageScore: Number(averageScore.toFixed(1)),
|
||||
scoreRange: scoreRanges[category] || 'Unknown'
|
||||
};
|
||||
})
|
||||
.sort((a, b) => b.averageScore - a.averageScore);
|
||||
}
|
||||
|
||||
private generateLegendLabels(chart: any) {
|
||||
const data = chart.data;
|
||||
if (!data.labels?.length || !data.datasets?.[0]?.data?.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const dataset = data.datasets[0];
|
||||
|
||||
return data.labels.map((label: string, index: number) => {
|
||||
const isVisible = typeof chart.getDataVisibility === 'function'
|
||||
? chart.getDataVisibility(index)
|
||||
: !((chart.getDatasetMeta && chart.getDatasetMeta(0)?.data?.[index]?.hidden) || false);
|
||||
|
||||
return {
|
||||
text: label,
|
||||
fillStyle: (dataset.backgroundColor as string[])[index],
|
||||
strokeStyle: '#ffffff',
|
||||
lineWidth: 1,
|
||||
hidden: !isVisible,
|
||||
index,
|
||||
fontColor: '#ffffff'
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private formatTooltipLabel(context: any): string {
|
||||
const dataIndex = context.dataIndex;
|
||||
const qualityStats = this.getLastCalculatedStats();
|
||||
|
||||
if (!qualityStats || dataIndex >= qualityStats.length) {
|
||||
return `${context.parsed} books`;
|
||||
}
|
||||
|
||||
const stats = qualityStats[dataIndex];
|
||||
const total = context.chart.data.datasets[0].data.reduce((a: number, b: number) => a + b, 0);
|
||||
const percentage = ((stats.count / total) * 100).toFixed(1);
|
||||
|
||||
return `${stats.count} books (${percentage}%) | Average Score: ${stats.averageScore}/10 (${stats.scoreRange})`;
|
||||
}
|
||||
|
||||
private lastCalculatedStats: QualityScoreStats[] = [];
|
||||
|
||||
private getLastCalculatedStats(): QualityScoreStats[] {
|
||||
return this.lastCalculatedStats;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
import {inject, Injectable, OnDestroy} from '@angular/core';
|
||||
import {BehaviorSubject, EMPTY, Observable, Subject} from 'rxjs';
|
||||
import {map, takeUntil, catchError, filter, first, switchMap} from 'rxjs/operators';
|
||||
import {LibraryFilterService} from './library-filter.service';
|
||||
import {BookService} from '../../book/service/book.service';
|
||||
import {Book} from '../../book/model/book.model';
|
||||
import {ChartConfiguration, ChartData, ChartType} from 'chart.js';
|
||||
|
||||
interface RatingStats {
|
||||
ratingRange: string;
|
||||
count: number;
|
||||
averageRating: number;
|
||||
}
|
||||
|
||||
const CHART_COLORS = [
|
||||
'#DC2626', // Red (1.0-1.9)
|
||||
'#EA580C', // Red-orange (2.0-2.9)
|
||||
'#F59E0B', // Orange (3.0-3.9)
|
||||
'#16A34A', // Green (4.0-4.5)
|
||||
'#2563EB' // Blue (4.6-5.0)
|
||||
] as const;
|
||||
|
||||
const CHART_DEFAULTS = {
|
||||
borderColor: '#ffffff',
|
||||
borderWidth: 1,
|
||||
hoverBorderWidth: 2,
|
||||
hoverBorderColor: '#ffffff'
|
||||
} as const;
|
||||
|
||||
const RATING_RANGES = [
|
||||
{range: '1.0-1.9', min: 1.0, max: 1.9},
|
||||
{range: '2.0-2.9', min: 2.0, max: 2.9},
|
||||
{range: '3.0-3.9', min: 3.0, max: 3.9},
|
||||
{range: '4.0-4.5', min: 4.0, max: 4.5},
|
||||
{range: '4.6-5.0', min: 4.6, max: 5.0},
|
||||
{range: 'No Rating', min: 0, max: 0}
|
||||
] as const;
|
||||
|
||||
type RatingChartData = ChartData<'bar', number[], string>;
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class BookRatingChartService implements OnDestroy {
|
||||
private readonly bookService = inject(BookService);
|
||||
private readonly libraryFilterService = inject(LibraryFilterService);
|
||||
private readonly destroy$ = new Subject<void>();
|
||||
|
||||
public readonly ratingChartType: ChartType = 'bar';
|
||||
|
||||
public readonly ratingChartOptions: ChartConfiguration['options'] = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false,
|
||||
labels: {
|
||||
font: {
|
||||
family: "'Inter', sans-serif",
|
||||
size: 11
|
||||
}
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.8)',
|
||||
titleColor: '#ffffff',
|
||||
bodyColor: '#ffffff',
|
||||
borderColor: '#666666',
|
||||
borderWidth: 1,
|
||||
callbacks: {
|
||||
title: (context) => `External Rating Range: ${context[0].label}`,
|
||||
label: (context) => {
|
||||
const value = context.parsed.y;
|
||||
return `${value} book${value === 1 ? '' : 's'}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
ticks: {
|
||||
color: '#ffffff',
|
||||
font: {
|
||||
family: "'Inter', sans-serif",
|
||||
size: 11
|
||||
}
|
||||
},
|
||||
grid: {color: 'rgba(255, 255, 255, 0.1)'},
|
||||
title: {
|
||||
display: true,
|
||||
text: 'External Rating Range',
|
||||
color: '#ffffff',
|
||||
font: {
|
||||
family: "'Inter', sans-serif",
|
||||
size: 11.5
|
||||
}
|
||||
}
|
||||
},
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
ticks: {
|
||||
color: '#ffffff',
|
||||
font: {
|
||||
family: "'Inter', sans-serif",
|
||||
size: 11
|
||||
},
|
||||
stepSize: 1
|
||||
},
|
||||
grid: {color: 'rgba(255, 255, 255, 0.05)'},
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Number of Books',
|
||||
color: '#ffffff',
|
||||
font: {
|
||||
family: "'Inter', sans-serif",
|
||||
size: 11.5
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
private readonly ratingChartDataSubject = new BehaviorSubject<RatingChartData>({
|
||||
labels: [],
|
||||
datasets: [{
|
||||
label: 'Books by External Rating',
|
||||
data: [],
|
||||
backgroundColor: [...CHART_COLORS],
|
||||
...CHART_DEFAULTS
|
||||
}]
|
||||
});
|
||||
|
||||
public readonly ratingChartData$: Observable<RatingChartData> =
|
||||
this.ratingChartDataSubject.asObservable();
|
||||
|
||||
constructor() {
|
||||
this.bookService.bookState$
|
||||
.pipe(
|
||||
filter(state => state.loaded),
|
||||
first(),
|
||||
switchMap(() =>
|
||||
this.libraryFilterService.selectedLibrary$.pipe(
|
||||
takeUntil(this.destroy$)
|
||||
)
|
||||
),
|
||||
catchError((error) => {
|
||||
console.error('Error processing rating stats:', error);
|
||||
return EMPTY;
|
||||
})
|
||||
)
|
||||
.subscribe(() => {
|
||||
const stats = this.calculateRatingStats();
|
||||
this.updateChartData(stats);
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
}
|
||||
|
||||
private updateChartData(stats: RatingStats[]): void {
|
||||
try {
|
||||
const labels = stats.map(s => s.ratingRange);
|
||||
const dataValues = stats.map(s => s.count);
|
||||
|
||||
this.ratingChartDataSubject.next({
|
||||
labels,
|
||||
datasets: [{
|
||||
label: 'Books by External Rating',
|
||||
data: dataValues,
|
||||
backgroundColor: [...CHART_COLORS],
|
||||
...CHART_DEFAULTS
|
||||
}]
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error updating chart data:', error);
|
||||
}
|
||||
}
|
||||
|
||||
private calculateRatingStats(): RatingStats[] {
|
||||
const currentState = this.bookService.getCurrentBookState();
|
||||
const selectedLibraryId = this.libraryFilterService.getCurrentSelectedLibrary();
|
||||
|
||||
if (!this.isValidBookState(currentState)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const filteredBooks = this.filterBooksByLibrary(currentState.books!, selectedLibraryId);
|
||||
return this.processRatingStats(filteredBooks);
|
||||
}
|
||||
|
||||
private isValidBookState(state: any): boolean {
|
||||
return state?.loaded && state?.books && Array.isArray(state.books) && state.books.length > 0;
|
||||
}
|
||||
|
||||
private filterBooksByLibrary(books: Book[], selectedLibraryId: string | number | null): Book[] {
|
||||
return selectedLibraryId
|
||||
? books.filter(book => book.libraryId === selectedLibraryId)
|
||||
: books;
|
||||
}
|
||||
|
||||
private processRatingStats(books: Book[]): RatingStats[] {
|
||||
const rangeCounts = new Map<string, { count: number, totalRating: number }>();
|
||||
RATING_RANGES.forEach(range => rangeCounts.set(range.range, {count: 0, totalRating: 0}));
|
||||
|
||||
books.forEach(book => {
|
||||
const rating = this.getBookRating(book);
|
||||
|
||||
if (rating === 0) {
|
||||
const noRatingData = rangeCounts.get('No Rating')!;
|
||||
noRatingData.count++;
|
||||
} else {
|
||||
for (const range of RATING_RANGES) {
|
||||
if (range.range !== 'No Rating' && rating >= range.min && rating <= range.max) {
|
||||
const rangeData = rangeCounts.get(range.range)!;
|
||||
rangeData.count++;
|
||||
rangeData.totalRating += rating;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return RATING_RANGES.map(range => {
|
||||
const data = rangeCounts.get(range.range)!;
|
||||
return {
|
||||
ratingRange: range.range,
|
||||
count: data.count,
|
||||
averageRating: data.count > 0 ? data.totalRating / data.count : 0
|
||||
};
|
||||
}).filter(stat => stat.ratingRange !== 'No Rating');
|
||||
}
|
||||
|
||||
private getBookRating(book: Book): number {
|
||||
const ratings = [];
|
||||
|
||||
if (book.metadata?.goodreadsRating) ratings.push(book.metadata.goodreadsRating);
|
||||
if (book.metadata?.amazonRating) ratings.push(book.metadata.amazonRating);
|
||||
if (book.metadata?.hardcoverRating) ratings.push(book.metadata.hardcoverRating);
|
||||
|
||||
if (ratings.length > 0) {
|
||||
return ratings.reduce((sum, rating) => sum + rating, 0) / ratings.length;
|
||||
}
|
||||
|
||||
if (book.metadata?.rating) return book.metadata.rating;
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
import {inject, Injectable, OnDestroy} from '@angular/core';
|
||||
import {BehaviorSubject, EMPTY, Observable, Subject} from 'rxjs';
|
||||
import {map, takeUntil, catchError, filter, first, switchMap} from 'rxjs/operators';
|
||||
import {ChartConfiguration, ChartData} from 'chart.js';
|
||||
|
||||
import {LibraryFilterService} from './library-filter.service';
|
||||
import {BookService} from '../../book/service/book.service';
|
||||
import {Book} from '../../book/model/book.model';
|
||||
|
||||
interface BookSizeStats {
|
||||
title: string;
|
||||
sizeMB: number;
|
||||
bookType: string;
|
||||
pageCount?: number;
|
||||
}
|
||||
|
||||
const BOOK_TYPE_COLORS = {
|
||||
'PDF': '#e74c3c',
|
||||
'EPUB': '#3498db',
|
||||
'CBZ': '#27a153',
|
||||
'CBX': '#d4b50f',
|
||||
'CBR': '#e67e22',
|
||||
'CB7': '#9b59b6'
|
||||
} as const;
|
||||
|
||||
const CHART_DEFAULTS = {
|
||||
borderWidth: 1,
|
||||
hoverBorderWidth: 2,
|
||||
hoverBorderColor: '#ffffff'
|
||||
} as const;
|
||||
|
||||
type BookSizeChartData = ChartData<'bar', number[], string>;
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class BookSizeChartService implements OnDestroy {
|
||||
private readonly bookService = inject(BookService);
|
||||
private readonly libraryFilterService = inject(LibraryFilterService);
|
||||
private readonly destroy$ = new Subject<void>();
|
||||
|
||||
public readonly bookSizeChartType = 'bar' as const;
|
||||
|
||||
public readonly bookSizeChartOptions: ChartConfiguration<'bar'>['options'] = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
indexAxis: 'y',
|
||||
scales: {
|
||||
x: {
|
||||
beginAtZero: true,
|
||||
ticks: {
|
||||
color: '#ffffff',
|
||||
font: {
|
||||
family: "'Inter', sans-serif",
|
||||
size: 11.5
|
||||
},
|
||||
callback: function (value) {
|
||||
return value + ' MB';
|
||||
}
|
||||
},
|
||||
grid: {
|
||||
color: 'rgba(255, 255, 255, 0.1)'
|
||||
},
|
||||
title: {
|
||||
display: true,
|
||||
text: 'File Size (MB)',
|
||||
color: '#ffffff',
|
||||
font: {
|
||||
family: "'Inter', sans-serif",
|
||||
size: 12
|
||||
}
|
||||
}
|
||||
},
|
||||
y: {
|
||||
ticks: {
|
||||
color: '#ffffff',
|
||||
font: {
|
||||
family: "'Inter', sans-serif",
|
||||
size: 11.5
|
||||
},
|
||||
maxTicksLimit: 25
|
||||
},
|
||||
grid: {
|
||||
color: 'rgba(255, 255, 255, 0.05)'
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false
|
||||
},
|
||||
datalabels: {
|
||||
display: true,
|
||||
color: '#ffffff',
|
||||
font: {
|
||||
size: 10,
|
||||
family: "'Inter', sans-serif",
|
||||
weight: 'bold'
|
||||
},
|
||||
align: 'center',
|
||||
offset: 8,
|
||||
formatter: (value: number) => `${Math.round(value)} MB`
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.9)',
|
||||
titleColor: '#ffffff',
|
||||
bodyColor: '#ffffff',
|
||||
borderColor: '#ffffff',
|
||||
borderWidth: 1,
|
||||
cornerRadius: 6,
|
||||
displayColors: true,
|
||||
padding: 12,
|
||||
titleFont: {size: 14, weight: 'bold'},
|
||||
bodyFont: {size: 12},
|
||||
callbacks: {
|
||||
title: (context) => {
|
||||
const dataIndex = context[0].dataIndex;
|
||||
const stats = this.getLastCalculatedStats();
|
||||
return stats[dataIndex]?.title || 'Unknown';
|
||||
},
|
||||
label: this.formatTooltipLabel.bind(this)
|
||||
}
|
||||
}
|
||||
},
|
||||
interaction: {
|
||||
intersect: false,
|
||||
mode: 'point'
|
||||
}
|
||||
};
|
||||
|
||||
private readonly bookSizeChartDataSubject = new BehaviorSubject<BookSizeChartData>({
|
||||
labels: [],
|
||||
datasets: []
|
||||
});
|
||||
|
||||
public readonly bookSizeChartData$: Observable<BookSizeChartData> = this.bookSizeChartDataSubject.asObservable();
|
||||
|
||||
constructor() {
|
||||
this.bookService.bookState$
|
||||
.pipe(
|
||||
filter(state => state.loaded),
|
||||
first(),
|
||||
switchMap(() =>
|
||||
this.libraryFilterService.selectedLibrary$.pipe(
|
||||
takeUntil(this.destroy$)
|
||||
)
|
||||
),
|
||||
catchError((error) => {
|
||||
console.error('Error processing book size stats:', error);
|
||||
return EMPTY;
|
||||
})
|
||||
)
|
||||
.subscribe(() => {
|
||||
const stats = this.calculateBookSizeStats();
|
||||
this.updateChartData(stats);
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
}
|
||||
|
||||
private updateChartData(stats: BookSizeStats[]): void {
|
||||
try {
|
||||
this.lastCalculatedStats = stats;
|
||||
const labels = stats.map(s => this.truncateTitle(s.title, 30));
|
||||
const dataValues = stats.map(s => s.sizeMB);
|
||||
const colors = stats.map(s => BOOK_TYPE_COLORS[s.bookType as keyof typeof BOOK_TYPE_COLORS] || '#95a5a6');
|
||||
|
||||
this.bookSizeChartDataSubject.next({
|
||||
labels,
|
||||
datasets: [{
|
||||
label: 'File Size',
|
||||
data: dataValues,
|
||||
backgroundColor: colors,
|
||||
borderColor: colors,
|
||||
...CHART_DEFAULTS
|
||||
}]
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error updating book size chart data:', error);
|
||||
}
|
||||
}
|
||||
|
||||
private calculateBookSizeStats(): BookSizeStats[] {
|
||||
const currentState = this.bookService.getCurrentBookState();
|
||||
const selectedLibraryId = this.libraryFilterService.getCurrentSelectedLibrary();
|
||||
|
||||
if (!this.isValidBookState(currentState)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const filteredBooks = this.filterBooksByLibrary(currentState.books!, String(selectedLibraryId));
|
||||
return this.processBookSizeStats(filteredBooks);
|
||||
}
|
||||
|
||||
private isValidBookState(state: any): boolean {
|
||||
return state?.loaded && state?.books && Array.isArray(state.books) && state.books.length > 0;
|
||||
}
|
||||
|
||||
private filterBooksByLibrary(books: Book[], selectedLibraryId: string | null): Book[] {
|
||||
return selectedLibraryId && selectedLibraryId !== 'null'
|
||||
? books.filter(book => String(book.libraryId) === selectedLibraryId)
|
||||
: books;
|
||||
}
|
||||
|
||||
private processBookSizeStats(books: Book[]): BookSizeStats[] {
|
||||
if (books.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const booksWithSize = books
|
||||
.filter(book => book.fileSizeKb && book.fileSizeKb > 0)
|
||||
.map(book => ({
|
||||
title: book.metadata?.title || book.fileName || 'Unknown Title',
|
||||
sizeMB: Number((book.fileSizeKb! / 1024).toFixed(2)),
|
||||
bookType: book.bookType,
|
||||
pageCount: book.metadata?.pageCount || undefined
|
||||
}))
|
||||
.sort((a, b) => b.sizeMB - a.sizeMB)
|
||||
.slice(0, 20);
|
||||
|
||||
return booksWithSize;
|
||||
}
|
||||
|
||||
private formatTooltipLabel(context: any): string {
|
||||
const dataIndex = context.dataIndex;
|
||||
const stats = this.getLastCalculatedStats();
|
||||
|
||||
if (!stats || dataIndex >= stats.length) {
|
||||
return `${context.parsed.x} MB`;
|
||||
}
|
||||
|
||||
const book = stats[dataIndex];
|
||||
const sizeInfo = `${book.sizeMB} MB`;
|
||||
const typeInfo = `Format: ${book.bookType}`;
|
||||
const pageInfo = book.pageCount ? `Pages: ${book.pageCount}` : 'Pages: Unknown';
|
||||
|
||||
return `${sizeInfo} | ${typeInfo} | ${pageInfo}`;
|
||||
}
|
||||
|
||||
private lastCalculatedStats: BookSizeStats[] = [];
|
||||
|
||||
private getLastCalculatedStats(): BookSizeStats[] {
|
||||
return this.lastCalculatedStats;
|
||||
}
|
||||
|
||||
private truncateTitle(title: string, maxLength: number): string {
|
||||
return title.length > maxLength ? title.substring(0, maxLength) + '...' : title;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
import {inject, Injectable, OnDestroy} from '@angular/core';
|
||||
import {BehaviorSubject, EMPTY, Observable, Subject} from 'rxjs';
|
||||
import {map, takeUntil, catchError, filter, first, switchMap} from 'rxjs/operators';
|
||||
import {ChartConfiguration, ChartData, ChartType} from 'chart.js';
|
||||
|
||||
import {LibraryFilterService} from './library-filter.service';
|
||||
import {BookService} from '../../book/service/book.service';
|
||||
import {Book} from '../../book/model/book.model';
|
||||
|
||||
interface BookTypeStats {
|
||||
bookType: string;
|
||||
count: number;
|
||||
percentage: number;
|
||||
}
|
||||
|
||||
const CHART_COLORS = ['#FF6B6B', '#4ECDC4', '#45B7D1', '#96CEB4', '#FFA726', '#AB47BC'] as const;
|
||||
const CHART_DEFAULTS = {
|
||||
borderWidth: 2,
|
||||
hoverBorderWidth: 3,
|
||||
borderColor: '#ffffff',
|
||||
hoverBorderColor: '#ffffff'
|
||||
} as const;
|
||||
|
||||
type BookTypeChartData = ChartData<'pie', number[], string>;
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class BookTypeChartService implements OnDestroy {
|
||||
private readonly bookService = inject(BookService);
|
||||
private readonly libraryFilterService = inject(LibraryFilterService);
|
||||
private readonly destroy$ = new Subject<void>();
|
||||
|
||||
public readonly bookTypeChartType: ChartType = 'pie';
|
||||
|
||||
public readonly bookTypeChartOptions: ChartConfiguration['options'] = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: true,
|
||||
position: 'bottom',
|
||||
labels: {
|
||||
color: '#ffffff',
|
||||
padding: 12,
|
||||
usePointStyle: true,
|
||||
generateLabels: this.generateLegendLabels.bind(this)
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
enabled: true,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.9)',
|
||||
titleColor: '#ffffff',
|
||||
bodyColor: '#ffffff',
|
||||
borderColor: '#ffffff',
|
||||
borderWidth: 1,
|
||||
cornerRadius: 6,
|
||||
displayColors: true,
|
||||
padding: 12,
|
||||
titleFont: {size: 14, weight: 'bold'},
|
||||
bodyFont: {size: 12},
|
||||
position: 'nearest',
|
||||
callbacks: {
|
||||
title: (context) => context[0]?.label || '',
|
||||
label: this.formatTooltipLabel
|
||||
}
|
||||
}
|
||||
},
|
||||
interaction: {
|
||||
intersect: false,
|
||||
mode: 'point'
|
||||
}
|
||||
};
|
||||
|
||||
private readonly bookTypeChartDataSubject = new BehaviorSubject<BookTypeChartData>({
|
||||
labels: [],
|
||||
datasets: [{
|
||||
data: [],
|
||||
backgroundColor: [...CHART_COLORS],
|
||||
...CHART_DEFAULTS
|
||||
}]
|
||||
});
|
||||
|
||||
public readonly bookTypeChartData$: Observable<BookTypeChartData> =
|
||||
this.bookTypeChartDataSubject.asObservable();
|
||||
|
||||
constructor() {
|
||||
this.bookService.bookState$
|
||||
.pipe(
|
||||
filter(state => state.loaded),
|
||||
first(),
|
||||
switchMap(() =>
|
||||
this.libraryFilterService.selectedLibrary$.pipe(
|
||||
takeUntil(this.destroy$)
|
||||
)
|
||||
),
|
||||
catchError((error) => {
|
||||
console.error('Error processing book type stats:', error);
|
||||
return EMPTY;
|
||||
})
|
||||
)
|
||||
.subscribe(() => {
|
||||
const stats = this.calculateBookTypeStats();
|
||||
this.updateChartData(stats);
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
}
|
||||
|
||||
private updateChartData(stats: BookTypeStats[]): void {
|
||||
try {
|
||||
const labels = stats.map(s => s.bookType);
|
||||
const dataValues = stats.map(s => s.count);
|
||||
const colors = this.getColorsForData(stats.length);
|
||||
|
||||
this.bookTypeChartDataSubject.next({
|
||||
labels,
|
||||
datasets: [{
|
||||
data: dataValues,
|
||||
backgroundColor: colors,
|
||||
...CHART_DEFAULTS
|
||||
}]
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error updating chart data:', error);
|
||||
}
|
||||
}
|
||||
|
||||
private getColorsForData(dataLength: number): string[] {
|
||||
// Repeat colors if we have more data points than colors
|
||||
const colors = [...CHART_COLORS];
|
||||
while (colors.length < dataLength) {
|
||||
colors.push(...CHART_COLORS);
|
||||
}
|
||||
return colors.slice(0, dataLength);
|
||||
}
|
||||
|
||||
private calculateBookTypeStats(): BookTypeStats[] {
|
||||
const currentState = this.bookService.getCurrentBookState();
|
||||
const selectedLibraryId = this.libraryFilterService.getCurrentSelectedLibrary();
|
||||
|
||||
if (!this.isValidBookState(currentState)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const filteredBooks = this.filterBooksByLibrary(currentState.books!, String(selectedLibraryId));
|
||||
return this.processBookTypeStats(filteredBooks);
|
||||
}
|
||||
|
||||
public updateFromStats(stats: BookTypeStats[]): void {
|
||||
this.updateChartData(stats);
|
||||
}
|
||||
|
||||
private isValidBookState(state: any): boolean {
|
||||
return state?.loaded && state?.books && Array.isArray(state.books) && state.books.length > 0;
|
||||
}
|
||||
|
||||
private filterBooksByLibrary(books: Book[], selectedLibraryId: string | null): Book[] {
|
||||
return selectedLibraryId && selectedLibraryId !== 'null'
|
||||
? books.filter(book => String(book.libraryId) === selectedLibraryId)
|
||||
: books;
|
||||
}
|
||||
|
||||
private processBookTypeStats(books: Book[]): BookTypeStats[] {
|
||||
if (books.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const typeMap = this.buildTypeMap(books);
|
||||
return this.convertMapToStats(typeMap, books.length);
|
||||
}
|
||||
|
||||
private buildTypeMap(books: Book[]): Map<string, number> {
|
||||
const typeMap = new Map<string, number>();
|
||||
|
||||
for (const book of books) {
|
||||
const type = book.bookType || 'Unknown';
|
||||
typeMap.set(type, (typeMap.get(type) || 0) + 1);
|
||||
}
|
||||
|
||||
return typeMap;
|
||||
}
|
||||
|
||||
private convertMapToStats(typeMap: Map<string, number>, totalBooks: number): BookTypeStats[] {
|
||||
return Array.from(typeMap.entries())
|
||||
.map(([bookType, count]) => ({
|
||||
bookType: this.formatBookType(bookType),
|
||||
count,
|
||||
percentage: Number(((count / totalBooks) * 100).toFixed(1))
|
||||
}))
|
||||
.sort((a, b) => b.count - a.count);
|
||||
}
|
||||
|
||||
private formatBookType(type: string): string {
|
||||
const TYPE_MAPPING: Record<string, string> = {
|
||||
PDF: 'PDF',
|
||||
EPUB: 'EPUB',
|
||||
CBX: 'Comic Books',
|
||||
Unknown: 'Unknown Format'
|
||||
} as const;
|
||||
|
||||
return TYPE_MAPPING[type] || type;
|
||||
}
|
||||
|
||||
private generateLegendLabels(chart: any) {
|
||||
const data = chart.data;
|
||||
if (!data.labels?.length || !data.datasets?.[0]?.data?.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const dataset = data.datasets[0];
|
||||
const dataValues = dataset.data as number[];
|
||||
|
||||
return data.labels.map((label: string, index: number) => ({
|
||||
text: `${label} (${dataValues[index]})`,
|
||||
fillStyle: (dataset.backgroundColor as string[])[index],
|
||||
strokeStyle: '#ffffff',
|
||||
lineWidth: 1,
|
||||
hidden: false,
|
||||
fontColor: '#ffffff',
|
||||
index
|
||||
}));
|
||||
}
|
||||
|
||||
private formatTooltipLabel(context: any): string {
|
||||
const dataIndex = context.dataIndex;
|
||||
const dataset = context.dataset;
|
||||
const value = dataset.data[dataIndex] as number;
|
||||
const label = context.chart.data.labels?.[dataIndex] || 'Unknown';
|
||||
const total = (dataset.data as number[]).reduce((a: number, b: number) => a + b, 0);
|
||||
const percentage = ((value / total) * 100).toFixed(1);
|
||||
return `${label}: ${value} books (${percentage}%)`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import {Injectable} from '@angular/core';
|
||||
import {BehaviorSubject} from 'rxjs';
|
||||
|
||||
export interface ChartConfig {
|
||||
id: string;
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
category: 'small' | 'medium' | 'large' | 'full-width';
|
||||
order: number;
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class ChartConfigService {
|
||||
private readonly STORAGE_KEY = 'booklore-chart-config';
|
||||
|
||||
private readonly defaultCharts: ChartConfig[] = [
|
||||
{id: 'readingStatus', name: 'Reading Status', enabled: true, category: 'small', order: 0},
|
||||
{id: 'bookFormats', name: 'Book Formats', enabled: true, category: 'small', order: 1},
|
||||
{id: 'languageDistribution', name: 'Language Distribution', enabled: true, category: 'small', order: 2},
|
||||
{id: 'bookMetadataScore', name: 'Book Metadata Score', enabled: true, category: 'small', order: 3},
|
||||
{id: 'topAuthors', name: 'Top 25 Authors', enabled: true, category: 'large', order: 4},
|
||||
{id: 'topCategories', name: 'Top 25 Categories', enabled: true, category: 'large', order: 5},
|
||||
{id: 'monthlyReadingPatterns', name: 'Monthly Reading Patterns', enabled: true, category: 'large', order: 6},
|
||||
{id: 'readingVelocityTimeline', name: 'Reading Velocity Timeline', enabled: true, category: 'large', order: 7},
|
||||
{id: 'readingProgress', name: 'Reading Progress', enabled: true, category: 'medium', order: 8},
|
||||
{id: 'externalRating', name: 'External Rating Distribution', enabled: true, category: 'medium', order: 9},
|
||||
{id: 'personalRating', name: 'Personal Rating Distribution', enabled: true, category: 'medium', order: 10},
|
||||
{id: 'pageCount', name: 'Page Count Distribution', enabled: true, category: 'medium', order: 11},
|
||||
{id: 'topBooksBySize', name: 'Top 20 Largest Books', enabled: true, category: 'large', order: 12},
|
||||
{id: 'topSeries', name: 'Top 20 Series', enabled: true, category: 'large', order: 13},
|
||||
{id: 'readingDNA', name: 'Reading DNA Profile', enabled: true, category: 'large', order: 14},
|
||||
{id: 'readingHabits', name: 'Reading Habits Analysis', enabled: true, category: 'large', order: 15},
|
||||
{id: 'publicationYear', name: 'Publication Year Timeline', enabled: true, category: 'full-width', order: 16},
|
||||
{id: 'finishedBooksTimeline', name: 'Books Finished Timeline', enabled: true, category: 'full-width', order: 17}
|
||||
];
|
||||
|
||||
private chartsConfigSubject = new BehaviorSubject<ChartConfig[]>(this.loadConfig());
|
||||
public chartsConfig$ = this.chartsConfigSubject.asObservable();
|
||||
|
||||
constructor() {
|
||||
this.initializeConfig();
|
||||
}
|
||||
|
||||
private initializeConfig(): void {
|
||||
const savedConfig = this.loadConfig();
|
||||
this.chartsConfigSubject.next(savedConfig);
|
||||
}
|
||||
|
||||
private loadConfig(): ChartConfig[] {
|
||||
try {
|
||||
const saved = localStorage.getItem(this.STORAGE_KEY);
|
||||
if (saved) {
|
||||
const savedConfig = JSON.parse(saved) as ChartConfig[];
|
||||
return this.mergeWithDefaults(savedConfig);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading chart config from localStorage:', error);
|
||||
}
|
||||
return [...this.defaultCharts];
|
||||
}
|
||||
|
||||
private mergeWithDefaults(savedConfig: ChartConfig[]): ChartConfig[] {
|
||||
const merged = [...this.defaultCharts];
|
||||
|
||||
savedConfig.forEach(saved => {
|
||||
const index = merged.findIndex(chart => chart.id === saved.id);
|
||||
if (index !== -1) {
|
||||
merged[index] = {
|
||||
...merged[index],
|
||||
enabled: saved.enabled,
|
||||
order: saved.order !== undefined ? saved.order : merged[index].order
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
return merged.sort((a, b) => a.order - b.order);
|
||||
}
|
||||
|
||||
private saveConfig(config: ChartConfig[]): void {
|
||||
try {
|
||||
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(config));
|
||||
} catch (error) {
|
||||
console.error('Error saving chart config to localStorage:', error);
|
||||
}
|
||||
}
|
||||
|
||||
public toggleChart(chartId: string): void {
|
||||
const currentConfig = this.chartsConfigSubject.value;
|
||||
const updatedConfig = currentConfig.map(chart =>
|
||||
chart.id === chartId ? {...chart, enabled: !chart.enabled} : chart
|
||||
);
|
||||
|
||||
this.chartsConfigSubject.next(updatedConfig);
|
||||
this.saveConfig(updatedConfig);
|
||||
}
|
||||
|
||||
public isChartEnabled(chartId: string): boolean {
|
||||
const config = this.chartsConfigSubject.value;
|
||||
const chart = config.find(c => c.id === chartId);
|
||||
return chart?.enabled ?? false;
|
||||
}
|
||||
|
||||
public enableAllCharts(): void {
|
||||
const updatedConfig = this.chartsConfigSubject.value.map(chart => ({...chart, enabled: true}));
|
||||
this.chartsConfigSubject.next(updatedConfig);
|
||||
this.saveConfig(updatedConfig);
|
||||
}
|
||||
|
||||
public disableAllCharts(): void {
|
||||
const updatedConfig = this.chartsConfigSubject.value.map(chart => ({...chart, enabled: false}));
|
||||
this.chartsConfigSubject.next(updatedConfig);
|
||||
this.saveConfig(updatedConfig);
|
||||
}
|
||||
|
||||
public getChartsByCategory(category: string): ChartConfig[] {
|
||||
return this.chartsConfigSubject.value.filter(chart => chart.category === category);
|
||||
}
|
||||
|
||||
public getEnabledChartsSorted(): ChartConfig[] {
|
||||
return this.chartsConfigSubject.value
|
||||
.filter(chart => chart.enabled)
|
||||
.sort((a, b) => a.order - b.order);
|
||||
}
|
||||
|
||||
public reorderCharts(fromIndex: number, toIndex: number): void {
|
||||
const currentConfig = [...this.chartsConfigSubject.value];
|
||||
const enabledCharts = currentConfig.filter(chart => chart.enabled).sort((a, b) => a.order - b.order);
|
||||
|
||||
if (fromIndex >= enabledCharts.length || toIndex >= enabledCharts.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Move the chart from fromIndex to toIndex
|
||||
const [movedChart] = enabledCharts.splice(fromIndex, 1);
|
||||
enabledCharts.splice(toIndex, 0, movedChart);
|
||||
|
||||
// Update order values for all enabled charts
|
||||
enabledCharts.forEach((chart, index) => {
|
||||
const configIndex = currentConfig.findIndex(c => c.id === chart.id);
|
||||
if (configIndex !== -1) {
|
||||
currentConfig[configIndex] = {...currentConfig[configIndex], order: index};
|
||||
}
|
||||
});
|
||||
|
||||
this.chartsConfigSubject.next(currentConfig);
|
||||
this.saveConfig(currentConfig);
|
||||
}
|
||||
|
||||
public resetOrder(): void {
|
||||
const currentConfig = this.chartsConfigSubject.value.map((chart, index) => ({
|
||||
...chart,
|
||||
order: this.defaultCharts.find(d => d.id === chart.id)?.order ?? index
|
||||
}));
|
||||
|
||||
this.chartsConfigSubject.next(currentConfig);
|
||||
this.saveConfig(currentConfig);
|
||||
}
|
||||
|
||||
public resetPositions(): void {
|
||||
const resetConfig = this.defaultCharts.map(defaultChart => {
|
||||
const currentChart = this.chartsConfigSubject.value.find(c => c.id === defaultChart.id);
|
||||
return {
|
||||
...defaultChart,
|
||||
enabled: currentChart?.enabled ?? defaultChart.enabled
|
||||
};
|
||||
});
|
||||
|
||||
this.chartsConfigSubject.next(resetConfig);
|
||||
this.saveConfig(resetConfig);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
import {inject, Injectable, OnDestroy} from '@angular/core';
|
||||
import {BehaviorSubject, EMPTY, Observable, Subject} from 'rxjs';
|
||||
import {map, takeUntil, catchError, filter, first, switchMap} from 'rxjs/operators';
|
||||
import {ChartConfiguration, ChartData, ChartType} from 'chart.js';
|
||||
|
||||
import {LibraryFilterService} from './library-filter.service';
|
||||
import {BookService} from '../../book/service/book.service';
|
||||
import {Book} from '../../book/model/book.model';
|
||||
|
||||
interface FinishedBooksStats {
|
||||
yearMonth: string;
|
||||
count: number;
|
||||
year: number;
|
||||
month: number;
|
||||
}
|
||||
|
||||
const CHART_COLORS = {
|
||||
primary: '#EF476F',
|
||||
primaryBackground: 'rgba(239, 71, 111, 0.1)',
|
||||
border: '#ffffff'
|
||||
} as const;
|
||||
|
||||
const CHART_DEFAULTS = {
|
||||
borderColor: CHART_COLORS.primary,
|
||||
backgroundColor: CHART_COLORS.primaryBackground,
|
||||
borderWidth: 2,
|
||||
pointBackgroundColor: CHART_COLORS.primary,
|
||||
pointBorderColor: CHART_COLORS.border,
|
||||
pointBorderWidth: 2,
|
||||
pointRadius: 4,
|
||||
pointHoverRadius: 6,
|
||||
fill: true,
|
||||
tension: 0.4
|
||||
} as const;
|
||||
|
||||
type FinishedBooksChartData = ChartData<'line', number[], string>;
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class FinishedBooksTimelineChartService implements OnDestroy {
|
||||
private readonly bookService = inject(BookService);
|
||||
private readonly libraryFilterService = inject(LibraryFilterService);
|
||||
private readonly destroy$ = new Subject<void>();
|
||||
|
||||
public readonly finishedBooksChartType = 'line' as const;
|
||||
|
||||
public readonly finishedBooksChartOptions: ChartConfiguration['options'] = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {display: false},
|
||||
tooltip: {
|
||||
enabled: true,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.9)',
|
||||
titleColor: '#ffffff',
|
||||
bodyColor: '#ffffff',
|
||||
borderColor: '#ffffff',
|
||||
borderWidth: 1,
|
||||
cornerRadius: 6,
|
||||
displayColors: true,
|
||||
padding: 12,
|
||||
titleFont: {size: 14, weight: 'bold'},
|
||||
bodyFont: {size: 13},
|
||||
position: 'nearest',
|
||||
callbacks: {
|
||||
title: (context) => {
|
||||
return context[0].label; // Already formatted as "Month Year"
|
||||
},
|
||||
label: this.formatTooltipLabel.bind(this)
|
||||
}
|
||||
},
|
||||
datalabels: {
|
||||
display: true,
|
||||
color: '#ffffff',
|
||||
font: {
|
||||
size: 10,
|
||||
weight: 'bold'
|
||||
},
|
||||
align: 'top',
|
||||
offset: 8,
|
||||
formatter: (value: number) => value.toString()
|
||||
}
|
||||
},
|
||||
interaction: {
|
||||
intersect: false,
|
||||
mode: 'point'
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
beginAtZero: true,
|
||||
ticks: {
|
||||
color: '#ffffff',
|
||||
font: {
|
||||
family: "'Inter', sans-serif",
|
||||
size: 11.5
|
||||
},
|
||||
maxRotation: 45,
|
||||
callback: function (value, index, values) {
|
||||
const totalLabels = values.length;
|
||||
const skipInterval = totalLabels > 24 ? Math.ceil(totalLabels / 12) : totalLabels > 12 ? 2 : 1;
|
||||
return index % skipInterval === 0 ? this.getLabelForValue(value as number) : '';
|
||||
}
|
||||
},
|
||||
grid: {color: 'rgba(255, 255, 255, 0.1)'},
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Month',
|
||||
color: '#ffffff',
|
||||
font: {
|
||||
family: "'Inter', sans-serif",
|
||||
size: 11.5
|
||||
}
|
||||
}
|
||||
},
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
ticks: {
|
||||
color: '#ffffff',
|
||||
font: {
|
||||
family: "'Inter', sans-serif",
|
||||
size: 11.5
|
||||
},
|
||||
stepSize: 1
|
||||
},
|
||||
grid: {color: 'rgba(255, 255, 255, 0.05)'},
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Books Finished',
|
||||
color: '#ffffff',
|
||||
font: {
|
||||
family: "'Inter', sans-serif",
|
||||
size: 11.5
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
private readonly finishedBooksChartDataSubject = new BehaviorSubject<FinishedBooksChartData>({
|
||||
labels: [],
|
||||
datasets: [{
|
||||
label: 'Books Finished',
|
||||
data: [],
|
||||
...CHART_DEFAULTS
|
||||
}]
|
||||
});
|
||||
|
||||
public readonly finishedBooksChartData$: Observable<FinishedBooksChartData> =
|
||||
this.finishedBooksChartDataSubject.asObservable();
|
||||
|
||||
constructor() {
|
||||
this.bookService.bookState$
|
||||
.pipe(
|
||||
filter(state => state.loaded),
|
||||
first(),
|
||||
switchMap(() =>
|
||||
this.libraryFilterService.selectedLibrary$.pipe(
|
||||
takeUntil(this.destroy$)
|
||||
)
|
||||
),
|
||||
catchError((error) => {
|
||||
console.error('Error processing finished books stats:', error);
|
||||
return EMPTY;
|
||||
})
|
||||
)
|
||||
.subscribe(() => {
|
||||
const stats = this.calculateFinishedBooksStats();
|
||||
this.updateChartData(stats);
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
}
|
||||
|
||||
private updateChartData(stats: FinishedBooksStats[]): void {
|
||||
try {
|
||||
const labels = stats.map(s => {
|
||||
const [year, month] = s.yearMonth.split('-');
|
||||
const monthName = new Date(parseInt(year), parseInt(month) - 1).toLocaleString('default', { month: 'short' });
|
||||
return `${monthName} ${year}`;
|
||||
});
|
||||
const dataValues = stats.map(s => s.count);
|
||||
|
||||
this.finishedBooksChartDataSubject.next({
|
||||
labels,
|
||||
datasets: [{
|
||||
label: 'Books Finished',
|
||||
data: dataValues,
|
||||
...CHART_DEFAULTS
|
||||
}]
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error updating chart data:', error);
|
||||
}
|
||||
}
|
||||
|
||||
private calculateFinishedBooksStats(): FinishedBooksStats[] {
|
||||
const currentState = this.bookService.getCurrentBookState();
|
||||
const selectedLibraryId = this.libraryFilterService.getCurrentSelectedLibrary();
|
||||
|
||||
if (!this.isValidBookState(currentState)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const filteredBooks = this.filterBooksByLibrary(currentState.books!, selectedLibraryId);
|
||||
return this.processFinishedBooksStats(filteredBooks);
|
||||
}
|
||||
|
||||
private isValidBookState(state: any): boolean {
|
||||
return state?.loaded && state?.books && Array.isArray(state.books) && state.books.length > 0;
|
||||
}
|
||||
|
||||
private filterBooksByLibrary(books: Book[], selectedLibraryId: string | number | null): Book[] {
|
||||
return selectedLibraryId
|
||||
? books.filter(book => book.libraryId === selectedLibraryId)
|
||||
: books;
|
||||
}
|
||||
|
||||
private processFinishedBooksStats(books: Book[]): FinishedBooksStats[] {
|
||||
const yearMonthMap = new Map<string, number>();
|
||||
|
||||
books.forEach(book => {
|
||||
if (book.dateFinished) {
|
||||
const finishedDate = new Date(book.dateFinished);
|
||||
const yearMonth = `${finishedDate.getFullYear()}-${(finishedDate.getMonth() + 1).toString().padStart(2, '0')}`;
|
||||
yearMonthMap.set(yearMonth, (yearMonthMap.get(yearMonth) || 0) + 1);
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(yearMonthMap.entries())
|
||||
.filter(([yearMonth, count]) => count > 0)
|
||||
.map(([yearMonth, count]) => ({
|
||||
yearMonth,
|
||||
count,
|
||||
year: parseInt(yearMonth.split('-')[0]),
|
||||
month: parseInt(yearMonth.split('-')[1])
|
||||
}))
|
||||
.sort((a, b) => a.yearMonth.localeCompare(b.yearMonth));
|
||||
}
|
||||
|
||||
private formatTooltipLabel(context: any): string {
|
||||
const value = context.parsed.y;
|
||||
return `${value} book${value === 1 ? '' : 's'} finished`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
import {inject, Injectable, OnDestroy} from '@angular/core';
|
||||
import {BehaviorSubject, EMPTY, Observable, Subject} from 'rxjs';
|
||||
import {map, takeUntil, catchError, filter, first, switchMap} from 'rxjs/operators';
|
||||
import {ChartConfiguration, ChartData} from 'chart.js';
|
||||
|
||||
import {LibraryFilterService} from './library-filter.service';
|
||||
import {BookService} from '../../book/service/book.service';
|
||||
import {Book} from '../../book/model/book.model';
|
||||
|
||||
interface LanguageStats {
|
||||
language: string;
|
||||
count: number;
|
||||
percentage: number;
|
||||
}
|
||||
|
||||
const CHART_COLORS = [
|
||||
'#4e79a7', '#f28e2c', '#e15759', '#76b7b2', '#59a14f',
|
||||
'#edc949', '#af7aa1', '#ff9da7', '#9c755f', '#bab0ab',
|
||||
'#17becf', '#bcbd22', '#1f77b4', '#ff7f0e', '#2ca02c'
|
||||
] as const;
|
||||
|
||||
const CHART_DEFAULTS = {
|
||||
borderColor: '#ffffff',
|
||||
borderWidth: 2,
|
||||
hoverBorderWidth: 3,
|
||||
hoverBorderColor: '#ffffff'
|
||||
} as const;
|
||||
|
||||
type LanguageChartData = ChartData<'doughnut', number[], string>;
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class LanguageDistributionChartService implements OnDestroy {
|
||||
private readonly bookService = inject(BookService);
|
||||
private readonly libraryFilterService = inject(LibraryFilterService);
|
||||
private readonly destroy$ = new Subject<void>();
|
||||
|
||||
public readonly languageChartType = 'doughnut' as const;
|
||||
|
||||
public readonly languageChartOptions: ChartConfiguration<'doughnut'>['options'] = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: true,
|
||||
position: 'bottom',
|
||||
labels: {
|
||||
padding: 12,
|
||||
usePointStyle: true,
|
||||
generateLabels: this.generateLegendLabels.bind(this)
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
enabled: true,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.9)',
|
||||
titleColor: '#ffffff',
|
||||
bodyColor: '#ffffff',
|
||||
borderColor: '#ffffff',
|
||||
borderWidth: 1,
|
||||
cornerRadius: 6,
|
||||
displayColors: true,
|
||||
padding: 12,
|
||||
titleFont: {size: 14, weight: 'bold'},
|
||||
bodyFont: {size: 12},
|
||||
position: 'nearest',
|
||||
callbacks: {
|
||||
title: (context) => context[0]?.label || '',
|
||||
label: this.formatTooltipLabel
|
||||
}
|
||||
}
|
||||
},
|
||||
interaction: {
|
||||
intersect: false,
|
||||
mode: 'point'
|
||||
},
|
||||
cutout: '45%'
|
||||
};
|
||||
|
||||
private readonly languageChartDataSubject = new BehaviorSubject<LanguageChartData>({
|
||||
labels: [],
|
||||
datasets: [{
|
||||
data: [],
|
||||
backgroundColor: [...CHART_COLORS],
|
||||
...CHART_DEFAULTS
|
||||
}]
|
||||
});
|
||||
|
||||
public readonly languageChartData$: Observable<LanguageChartData> = this.languageChartDataSubject.asObservable();
|
||||
|
||||
constructor() {
|
||||
this.bookService.bookState$
|
||||
.pipe(
|
||||
filter(state => state.loaded),
|
||||
first(),
|
||||
switchMap(() =>
|
||||
this.libraryFilterService.selectedLibrary$.pipe(
|
||||
takeUntil(this.destroy$)
|
||||
)
|
||||
),
|
||||
catchError((error) => {
|
||||
console.error('Error processing language stats:', error);
|
||||
return EMPTY;
|
||||
})
|
||||
)
|
||||
.subscribe(() => {
|
||||
const stats = this.calculateLanguageStats();
|
||||
this.updateChartData(stats);
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
}
|
||||
|
||||
private updateChartData(stats: LanguageStats[]): void {
|
||||
try {
|
||||
const topLanguages = stats.slice(0, 12); // Show top 12 languages
|
||||
const labels = topLanguages.map(s => s.language);
|
||||
const dataValues = topLanguages.map(s => s.count);
|
||||
const colors = this.getColorsForData(topLanguages.length);
|
||||
|
||||
this.languageChartDataSubject.next({
|
||||
labels,
|
||||
datasets: [{
|
||||
data: dataValues,
|
||||
backgroundColor: colors,
|
||||
...CHART_DEFAULTS
|
||||
}]
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error updating language chart data:', error);
|
||||
}
|
||||
}
|
||||
|
||||
private getColorsForData(dataLength: number): string[] {
|
||||
const colors = [...CHART_COLORS];
|
||||
while (colors.length < dataLength) {
|
||||
colors.push(...CHART_COLORS);
|
||||
}
|
||||
return colors.slice(0, dataLength);
|
||||
}
|
||||
|
||||
private calculateLanguageStats(): LanguageStats[] {
|
||||
const currentState = this.bookService.getCurrentBookState();
|
||||
const selectedLibraryId = this.libraryFilterService.getCurrentSelectedLibrary();
|
||||
|
||||
if (!this.isValidBookState(currentState)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const filteredBooks = this.filterBooksByLibrary(currentState.books!, String(selectedLibraryId));
|
||||
return this.processLanguageStats(filteredBooks);
|
||||
}
|
||||
|
||||
private isValidBookState(state: any): boolean {
|
||||
return state?.loaded && state?.books && Array.isArray(state.books) && state.books.length > 0;
|
||||
}
|
||||
|
||||
private filterBooksByLibrary(books: Book[], selectedLibraryId: string | null): Book[] {
|
||||
return selectedLibraryId && selectedLibraryId !== 'null'
|
||||
? books.filter(book => String(book.libraryId) === selectedLibraryId)
|
||||
: books;
|
||||
}
|
||||
|
||||
private processLanguageStats(books: Book[]): LanguageStats[] {
|
||||
if (books.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const languageMap = this.buildLanguageMap(books);
|
||||
return this.convertMapToStats(languageMap, books.length);
|
||||
}
|
||||
|
||||
private buildLanguageMap(books: Book[]): Map<string, number> {
|
||||
const languageMap = new Map<string, number>();
|
||||
|
||||
for (const book of books) {
|
||||
const language = book.metadata?.language;
|
||||
if (language && language.trim()) {
|
||||
const normalizedLanguage = this.normalizeLanguage(language.trim());
|
||||
languageMap.set(normalizedLanguage, (languageMap.get(normalizedLanguage) || 0) + 1);
|
||||
} else {
|
||||
languageMap.set('Unknown', (languageMap.get('Unknown') || 0) + 1);
|
||||
}
|
||||
}
|
||||
|
||||
return languageMap;
|
||||
}
|
||||
|
||||
private normalizeLanguage(language: string): string {
|
||||
const languageMap: Record<string, string> = {
|
||||
'en': 'English',
|
||||
'eng': 'English',
|
||||
'english': 'English',
|
||||
'es': 'Spanish',
|
||||
'spa': 'Spanish',
|
||||
'spanish': 'Spanish',
|
||||
'fr': 'French',
|
||||
'fre': 'French',
|
||||
'fra': 'French',
|
||||
'french': 'French',
|
||||
'de': 'German',
|
||||
'ger': 'German',
|
||||
'deu': 'German',
|
||||
'german': 'German',
|
||||
'it': 'Italian',
|
||||
'ita': 'Italian',
|
||||
'italian': 'Italian',
|
||||
'pt': 'Portuguese',
|
||||
'por': 'Portuguese',
|
||||
'portuguese': 'Portuguese',
|
||||
'ru': 'Russian',
|
||||
'rus': 'Russian',
|
||||
'russian': 'Russian',
|
||||
'ja': 'Japanese',
|
||||
'jpn': 'Japanese',
|
||||
'japanese': 'Japanese',
|
||||
'zh': 'Chinese',
|
||||
'chi': 'Chinese',
|
||||
'chinese': 'Chinese',
|
||||
'ko': 'Korean',
|
||||
'kor': 'Korean',
|
||||
'korean': 'Korean',
|
||||
'ar': 'Arabic',
|
||||
'ara': 'Arabic',
|
||||
'arabic': 'Arabic'
|
||||
};
|
||||
|
||||
const normalized = language.toLowerCase();
|
||||
return languageMap[normalized] || this.capitalizeFirst(language);
|
||||
}
|
||||
|
||||
private capitalizeFirst(str: string): string {
|
||||
return str.charAt(0).toUpperCase() + str.slice(1).toLowerCase();
|
||||
}
|
||||
|
||||
private convertMapToStats(languageMap: Map<string, number>, totalBooks: number): LanguageStats[] {
|
||||
return Array.from(languageMap.entries())
|
||||
.map(([language, count]) => ({
|
||||
language,
|
||||
count,
|
||||
percentage: Number(((count / totalBooks) * 100).toFixed(1))
|
||||
}))
|
||||
.sort((a, b) => b.count - a.count);
|
||||
}
|
||||
|
||||
private generateLegendLabels(chart: any) {
|
||||
const data = chart.data;
|
||||
if (!data.labels?.length || !data.datasets?.[0]?.data?.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const dataset = data.datasets[0];
|
||||
const dataValues = dataset.data as number[];
|
||||
|
||||
return data.labels.map((label: string, index: number) => {
|
||||
const isVisible = typeof chart.getDataVisibility === 'function'
|
||||
? chart.getDataVisibility(index)
|
||||
: !((chart.getDatasetMeta && chart.getDatasetMeta(0)?.data?.[index]?.hidden) || false);
|
||||
|
||||
return {
|
||||
text: `${label} (${dataValues[index]})`,
|
||||
fillStyle: (dataset.backgroundColor as string[])[index],
|
||||
strokeStyle: '#ffffff',
|
||||
lineWidth: 1,
|
||||
hidden: !isVisible,
|
||||
index,
|
||||
fontColor: '#ffffff'
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private formatTooltipLabel(context: any): string {
|
||||
const dataIndex = context.dataIndex;
|
||||
const dataset = context.dataset;
|
||||
const value = dataset.data[dataIndex] as number;
|
||||
const label = context.chart.data.labels?.[dataIndex] || 'Unknown';
|
||||
const total = (dataset.data as number[]).reduce((a: number, b: number) => a + b, 0);
|
||||
const percentage = ((value / total) * 100).toFixed(1);
|
||||
return `${label}: ${value} books (${percentage}%)`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import {inject, Injectable} from '@angular/core';
|
||||
import {combineLatest, map, Observable} from 'rxjs';
|
||||
import {BookService} from '../../book/service/book.service';
|
||||
import {LibraryFilterService} from './library-filter.service';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class LibrariesSummaryService {
|
||||
private bookService = inject(BookService);
|
||||
private libraryFilterService = inject(LibraryFilterService);
|
||||
|
||||
selectedLibrary$ = this.libraryFilterService.selectedLibrary$;
|
||||
|
||||
getBooksSummary(): Observable<{
|
||||
totalBooks: number;
|
||||
totalSizeKb: number;
|
||||
totalAuthors: number;
|
||||
totalSeries: number;
|
||||
totalPublishers: number;
|
||||
}> {
|
||||
return combineLatest([
|
||||
this.bookService.bookState$,
|
||||
this.selectedLibrary$
|
||||
]).pipe(
|
||||
map(([state, selectedLibraryId]) => {
|
||||
if (!state.loaded || !state.books || state.books.length === 0) {
|
||||
return {totalBooks: 0, totalSizeKb: 0, totalAuthors: 0, totalSeries: 0, totalPublishers: 0};
|
||||
}
|
||||
|
||||
const filteredBooks = selectedLibraryId
|
||||
? state.books.filter(book => book.libraryId === selectedLibraryId)
|
||||
: state.books;
|
||||
|
||||
const totalBooks = filteredBooks.length;
|
||||
const totalSizeKb = filteredBooks.reduce((sum, book) => sum + (book.fileSizeKb || 0), 0);
|
||||
|
||||
const authorSet = new Set<string>();
|
||||
const seriesSet = new Set<string>();
|
||||
const publisherSet = new Set<string>();
|
||||
|
||||
filteredBooks.forEach(book => {
|
||||
if (Array.isArray(book.metadata?.authors)) {
|
||||
book.metadata.authors.forEach(a => {
|
||||
const name = a?.trim();
|
||||
if (name) authorSet.add(name);
|
||||
});
|
||||
}
|
||||
|
||||
const seriesName = book.metadata?.seriesName?.trim();
|
||||
if (seriesName) seriesSet.add(seriesName);
|
||||
|
||||
const publisher = book.metadata?.publisher?.trim();
|
||||
if (publisher) publisherSet.add(publisher);
|
||||
});
|
||||
|
||||
return {
|
||||
totalBooks,
|
||||
totalSizeKb,
|
||||
totalAuthors: authorSet.size,
|
||||
totalSeries: seriesSet.size,
|
||||
totalPublishers: publisherSet.size
|
||||
};
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
getFormattedSize(): Observable<string> {
|
||||
return this.getBooksSummary().pipe(
|
||||
map(summary => this.formatSizeKb(summary.totalSizeKb))
|
||||
);
|
||||
}
|
||||
|
||||
private formatSizeKb(kb: number): string {
|
||||
if (!kb) return '0 KB';
|
||||
const kilo = 1024;
|
||||
const megaKb = kilo; // 1 MB = 1024 KB
|
||||
const gigaKb = kilo * megaKb; // 1 GB = 1024 * 1024 KB
|
||||
if (kb >= gigaKb) {
|
||||
return (kb / gigaKb).toFixed(2) + ' GB';
|
||||
}
|
||||
if (kb >= megaKb) {
|
||||
return (kb / megaKb).toFixed(2) + ' MB';
|
||||
}
|
||||
return kb + ' KB';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import {Injectable, inject} from '@angular/core';
|
||||
import {BehaviorSubject, combineLatest, map, Observable} from 'rxjs';
|
||||
import {BookService} from '../../book/service/book.service';
|
||||
import {LibraryService} from '../../book/service/library.service';
|
||||
|
||||
export interface LibraryOption {
|
||||
id: number | null;
|
||||
name: string;
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class LibraryFilterService {
|
||||
private selectedLibrarySubject = new BehaviorSubject<number | null>(null);
|
||||
|
||||
selectedLibrary$ = this.selectedLibrarySubject.asObservable();
|
||||
|
||||
getCurrentSelectedLibrary(): number | null {
|
||||
return this.selectedLibrarySubject.value;
|
||||
}
|
||||
|
||||
setSelectedLibrary(libraryId: number | null): void {
|
||||
this.selectedLibrarySubject.next(libraryId);
|
||||
}
|
||||
|
||||
private bookService = inject(BookService);
|
||||
private libraryService = inject(LibraryService);
|
||||
|
||||
getLibraryOptions(): Observable<LibraryOption[]> {
|
||||
return combineLatest([
|
||||
this.bookService.bookState$,
|
||||
this.libraryService.libraryState$
|
||||
]).pipe(
|
||||
map(([bookState, libraryState]) => {
|
||||
if (!bookState.loaded || !bookState.books || bookState.books.length === 0) {
|
||||
return [{id: null, name: 'All Libraries'}];
|
||||
}
|
||||
|
||||
if (!libraryState.loaded || !libraryState.libraries) {
|
||||
return [{id: null, name: 'All Libraries'}];
|
||||
}
|
||||
|
||||
const libraryMap = new Map<number, string>();
|
||||
bookState.books.forEach(book => {
|
||||
if (!libraryMap.has(book.libraryId)) {
|
||||
const library = libraryState.libraries?.find(lib => lib.id === book.libraryId);
|
||||
const libraryName = library?.name || `Library ${book.libraryId}`;
|
||||
libraryMap.set(book.libraryId, libraryName);
|
||||
}
|
||||
});
|
||||
|
||||
const options: LibraryOption[] = [
|
||||
{id: null, name: 'All Libraries'},
|
||||
...Array.from(libraryMap.entries()).map(([id, name]) => ({id, name}))
|
||||
];
|
||||
|
||||
return options.sort((a, b) => {
|
||||
if (a.id === null) return -1;
|
||||
if (b.id === null) return 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,492 @@
|
||||
import {inject, Injectable, OnDestroy} from '@angular/core';
|
||||
import {BehaviorSubject, EMPTY, Observable, Subject} from 'rxjs';
|
||||
import {map, takeUntil, catchError, filter, first, switchMap} from 'rxjs/operators';
|
||||
import {ChartConfiguration, ChartData} from 'chart.js';
|
||||
|
||||
import {LibraryFilterService} from './library-filter.service';
|
||||
import {BookService} from '../../book/service/book.service';
|
||||
import {Book, ReadStatus} from '../../book/model/book.model';
|
||||
|
||||
interface MonthlyPattern {
|
||||
month: string;
|
||||
booksStarted: number;
|
||||
booksFinished: number;
|
||||
booksAdded: number;
|
||||
averageProgress: number;
|
||||
totalReadingTime: number;
|
||||
genreDiversity: number;
|
||||
}
|
||||
|
||||
const CHART_COLORS = {
|
||||
booksStarted: '#3498db',
|
||||
booksFinished: '#2ecc71',
|
||||
booksAdded: '#f39c12',
|
||||
averageProgress: '#e74c3c',
|
||||
genreDiversity: '#9b59b6'
|
||||
} as const;
|
||||
|
||||
type MonthlyPatternsChartData = ChartData<'line', number[], string>;
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class MonthlyReadingPatternsChartService implements OnDestroy {
|
||||
private readonly bookService = inject(BookService);
|
||||
private readonly libraryFilterService = inject(LibraryFilterService);
|
||||
private readonly destroy$ = new Subject<void>();
|
||||
|
||||
public readonly monthlyPatternsChartType = 'line' as const;
|
||||
|
||||
public readonly monthlyPatternsChartOptions: ChartConfiguration<'line'>['options'] = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
scales: {
|
||||
x: {
|
||||
type: 'category',
|
||||
ticks: {
|
||||
color: '#ffffff',
|
||||
font: {
|
||||
family: "'Inter', sans-serif",
|
||||
size: 11
|
||||
}
|
||||
},
|
||||
grid: {
|
||||
color: 'rgba(255, 255, 255, 0.1)'
|
||||
},
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Month',
|
||||
color: '#ffffff',
|
||||
font: {
|
||||
family: "'Inter', sans-serif",
|
||||
size: 11.5
|
||||
}
|
||||
}
|
||||
},
|
||||
y: {
|
||||
type: 'linear',
|
||||
display: true,
|
||||
position: 'left',
|
||||
beginAtZero: true,
|
||||
ticks: {
|
||||
color: '#ffffff',
|
||||
font: {
|
||||
family: "'Inter', sans-serif",
|
||||
size: 11
|
||||
}
|
||||
},
|
||||
grid: {
|
||||
color: 'rgba(255, 255, 255, 0.1)'
|
||||
},
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Book Count',
|
||||
color: '#ffffff',
|
||||
font: {
|
||||
family: "'Inter', sans-serif",
|
||||
size: 11.5
|
||||
}
|
||||
}
|
||||
},
|
||||
y1: {
|
||||
type: 'linear',
|
||||
display: true,
|
||||
position: 'right',
|
||||
beginAtZero: true,
|
||||
max: 100,
|
||||
ticks: {
|
||||
color: '#ffffff',
|
||||
font: {
|
||||
family: "'Inter', sans-serif",
|
||||
size: 11
|
||||
},
|
||||
callback: function(value) {
|
||||
return value + '%';
|
||||
}
|
||||
},
|
||||
grid: {
|
||||
drawOnChartArea: false
|
||||
},
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Progress %',
|
||||
color: '#ffffff',
|
||||
font: {
|
||||
family: "'Inter', sans-serif",
|
||||
size: 11.5
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: true,
|
||||
position: 'top',
|
||||
labels: {
|
||||
color: '#ffffff',
|
||||
font: {
|
||||
family: "'Inter', sans-serif",
|
||||
size: 11.5
|
||||
},
|
||||
padding: 12,
|
||||
usePointStyle: true
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.9)',
|
||||
titleColor: '#ffffff',
|
||||
bodyColor: '#ffffff',
|
||||
borderColor: '#ffffff',
|
||||
borderWidth: 1,
|
||||
cornerRadius: 6,
|
||||
displayColors: true,
|
||||
padding: 12,
|
||||
titleFont: {size: 14, weight: 'bold'},
|
||||
bodyFont: {size: 12},
|
||||
callbacks: {
|
||||
title: (context) => context[0]?.label || '',
|
||||
label: this.formatTooltipLabel.bind(this)
|
||||
}
|
||||
}
|
||||
},
|
||||
interaction: {
|
||||
intersect: false,
|
||||
mode: 'index'
|
||||
},
|
||||
elements: {
|
||||
point: {
|
||||
radius: 4,
|
||||
hoverRadius: 6
|
||||
},
|
||||
line: {
|
||||
tension: 0.3,
|
||||
borderWidth: 2
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
private readonly monthlyPatternsChartDataSubject = new BehaviorSubject<MonthlyPatternsChartData>({
|
||||
labels: [],
|
||||
datasets: []
|
||||
});
|
||||
|
||||
public readonly monthlyPatternsChartData$: Observable<MonthlyPatternsChartData> = this.monthlyPatternsChartDataSubject.asObservable();
|
||||
|
||||
constructor() {
|
||||
this.bookService.bookState$
|
||||
.pipe(
|
||||
filter(state => state.loaded),
|
||||
first(),
|
||||
switchMap(() =>
|
||||
this.libraryFilterService.selectedLibrary$.pipe(
|
||||
takeUntil(this.destroy$)
|
||||
)
|
||||
),
|
||||
catchError((error) => {
|
||||
console.error('Error processing monthly patterns stats:', error);
|
||||
return EMPTY;
|
||||
})
|
||||
)
|
||||
.subscribe(() => {
|
||||
const stats = this.calculateMonthlyPatternsStats();
|
||||
this.updateChartData(stats);
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
}
|
||||
|
||||
private updateChartData(stats: MonthlyPattern[]): void {
|
||||
try {
|
||||
this.lastCalculatedStats = stats;
|
||||
const labels = stats.map(s => s.month);
|
||||
|
||||
const datasets = [
|
||||
{
|
||||
label: 'Books Started',
|
||||
data: stats.map(s => s.booksStarted),
|
||||
borderColor: CHART_COLORS.booksStarted,
|
||||
backgroundColor: CHART_COLORS.booksStarted + '20',
|
||||
yAxisID: 'y',
|
||||
tension: 0.3,
|
||||
fill: false,
|
||||
pointStyle: 'circle'
|
||||
},
|
||||
{
|
||||
label: 'Books Finished',
|
||||
data: stats.map(s => s.booksFinished),
|
||||
borderColor: CHART_COLORS.booksFinished,
|
||||
backgroundColor: CHART_COLORS.booksFinished + '20',
|
||||
yAxisID: 'y',
|
||||
tension: 0.3,
|
||||
fill: false,
|
||||
pointStyle: 'triangle'
|
||||
},
|
||||
{
|
||||
label: 'Books Added',
|
||||
data: stats.map(s => s.booksAdded),
|
||||
borderColor: CHART_COLORS.booksAdded,
|
||||
backgroundColor: CHART_COLORS.booksAdded + '20',
|
||||
yAxisID: 'y',
|
||||
tension: 0.3,
|
||||
fill: false,
|
||||
pointStyle: 'rect',
|
||||
borderDash: [3, 3]
|
||||
},
|
||||
{
|
||||
label: 'Avg Progress %',
|
||||
data: stats.map(s => s.averageProgress),
|
||||
borderColor: CHART_COLORS.averageProgress,
|
||||
backgroundColor: CHART_COLORS.averageProgress + '20',
|
||||
yAxisID: 'y1',
|
||||
tension: 0.3,
|
||||
fill: true,
|
||||
fillOpacity: 0.1,
|
||||
pointStyle: 'star'
|
||||
},
|
||||
{
|
||||
label: 'Genre Diversity',
|
||||
data: stats.map(s => s.genreDiversity),
|
||||
borderColor: CHART_COLORS.genreDiversity,
|
||||
backgroundColor: CHART_COLORS.genreDiversity + '20',
|
||||
yAxisID: 'y',
|
||||
tension: 0.3,
|
||||
fill: false,
|
||||
pointStyle: 'cross',
|
||||
borderDash: [5, 2, 2, 2]
|
||||
}
|
||||
];
|
||||
|
||||
this.monthlyPatternsChartDataSubject.next({
|
||||
labels,
|
||||
datasets
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error updating monthly patterns chart data:', error);
|
||||
}
|
||||
}
|
||||
|
||||
private calculateMonthlyPatternsStats(): MonthlyPattern[] {
|
||||
const currentState = this.bookService.getCurrentBookState();
|
||||
const selectedLibraryId = this.libraryFilterService.getCurrentSelectedLibrary();
|
||||
|
||||
if (!this.isValidBookState(currentState)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const filteredBooks = this.filterBooksByLibrary(currentState.books!, String(selectedLibraryId));
|
||||
return this.processMonthlyPatternsStats(filteredBooks);
|
||||
}
|
||||
|
||||
private isValidBookState(state: any): boolean {
|
||||
return state?.loaded && state?.books && Array.isArray(state.books) && state.books.length > 0;
|
||||
}
|
||||
|
||||
private filterBooksByLibrary(books: Book[], selectedLibraryId: string | null): Book[] {
|
||||
return selectedLibraryId && selectedLibraryId !== 'null'
|
||||
? books.filter(book => String(book.libraryId) === selectedLibraryId)
|
||||
: books;
|
||||
}
|
||||
|
||||
private processMonthlyPatternsStats(books: Book[]): MonthlyPattern[] {
|
||||
if (books.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Generate last 12 months
|
||||
const monthlyPatterns: MonthlyPattern[] = [];
|
||||
const currentDate = new Date();
|
||||
|
||||
for (let i = 11; i >= 0; i--) {
|
||||
const monthDate = new Date(currentDate.getFullYear(), currentDate.getMonth() - i, 1);
|
||||
const monthKey = this.formatMonthYear(monthDate);
|
||||
const displayMonth = this.formatDisplayMonth(monthKey);
|
||||
|
||||
const pattern = this.calculateMonthlyPattern(books, monthDate, displayMonth);
|
||||
monthlyPatterns.push(pattern);
|
||||
}
|
||||
|
||||
return monthlyPatterns;
|
||||
}
|
||||
|
||||
private calculateMonthlyPattern(books: Book[], monthDate: Date, displayMonth: string): MonthlyPattern {
|
||||
const year = monthDate.getFullYear();
|
||||
const month = monthDate.getMonth();
|
||||
const startOfMonth = new Date(year, month, 1);
|
||||
const endOfMonth = new Date(year, month + 1, 0, 23, 59, 59);
|
||||
|
||||
// Books started this month (estimated by reading status changes)
|
||||
const booksStarted = this.getBooksStartedInMonth(books, startOfMonth, endOfMonth);
|
||||
|
||||
// Books finished this month
|
||||
const booksFinished = books.filter(book => {
|
||||
if (!book.dateFinished) return false;
|
||||
const finishDate = new Date(book.dateFinished);
|
||||
return finishDate >= startOfMonth && finishDate <= endOfMonth;
|
||||
}).length;
|
||||
|
||||
// Books added this month
|
||||
const booksAdded = books.filter(book => {
|
||||
if (!book.addedOn) return false;
|
||||
const addedDate = new Date(book.addedOn);
|
||||
return addedDate >= startOfMonth && addedDate <= endOfMonth;
|
||||
}).length;
|
||||
|
||||
// Average progress of books being read
|
||||
const averageProgress = this.calculateAverageProgress(books, endOfMonth);
|
||||
|
||||
// Estimated total reading time (placeholder calculation)
|
||||
const totalReadingTime = this.estimateMonthlyReadingTime(books, startOfMonth, endOfMonth);
|
||||
|
||||
// Genre diversity (unique categories being read)
|
||||
const genreDiversity = this.calculateGenreDiversity(books, startOfMonth, endOfMonth);
|
||||
|
||||
return {
|
||||
month: displayMonth,
|
||||
booksStarted,
|
||||
booksFinished,
|
||||
booksAdded,
|
||||
averageProgress,
|
||||
totalReadingTime,
|
||||
genreDiversity
|
||||
};
|
||||
}
|
||||
|
||||
private getBooksStartedInMonth(books: Book[], startOfMonth: Date, endOfMonth: Date): number {
|
||||
// Estimate books started by looking at currently reading books and their last read time
|
||||
return books.filter(book => {
|
||||
if (book.readStatus !== ReadStatus.READING && book.readStatus !== ReadStatus.PARTIALLY_READ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (book.lastReadTime) {
|
||||
const lastReadDate = new Date(book.lastReadTime);
|
||||
return lastReadDate >= startOfMonth && lastReadDate <= endOfMonth;
|
||||
}
|
||||
|
||||
// Fallback: if added this month and currently reading, assume started this month
|
||||
if (book.addedOn) {
|
||||
const addedDate = new Date(book.addedOn);
|
||||
return addedDate >= startOfMonth && addedDate <= endOfMonth;
|
||||
}
|
||||
|
||||
return false;
|
||||
}).length;
|
||||
}
|
||||
|
||||
private calculateAverageProgress(books: Book[], asOfDate: Date): number {
|
||||
const booksInProgress = books.filter(book =>
|
||||
book.readStatus === ReadStatus.READING ||
|
||||
book.readStatus === ReadStatus.PARTIALLY_READ
|
||||
);
|
||||
|
||||
if (booksInProgress.length === 0) return 0;
|
||||
|
||||
const totalProgress = booksInProgress.reduce((sum, book) => {
|
||||
const progress = this.getBookProgress(book);
|
||||
return sum + progress;
|
||||
}, 0);
|
||||
|
||||
return Math.round(totalProgress / booksInProgress.length);
|
||||
}
|
||||
|
||||
private getBookProgress(book: Book): number {
|
||||
const epubProgress = book.epubProgress?.percentage || 0;
|
||||
const pdfProgress = book.pdfProgress?.percentage || 0;
|
||||
const cbxProgress = book.cbxProgress?.percentage || 0;
|
||||
const koreaderProgress = book.koreaderProgress?.percentage || 0;
|
||||
|
||||
return Math.max(epubProgress, pdfProgress, cbxProgress, koreaderProgress);
|
||||
}
|
||||
|
||||
private estimateMonthlyReadingTime(books: Book[], startOfMonth: Date, endOfMonth: Date): number {
|
||||
// Simplified estimation based on completed books and their page counts
|
||||
const booksFinishedThisMonth = books.filter(book => {
|
||||
if (!book.dateFinished) return false;
|
||||
const finishDate = new Date(book.dateFinished);
|
||||
return finishDate >= startOfMonth && finishDate <= endOfMonth;
|
||||
});
|
||||
|
||||
const totalPages = booksFinishedThisMonth.reduce((sum, book) =>
|
||||
sum + (book.metadata?.pageCount || 0), 0
|
||||
);
|
||||
|
||||
// Assume 1 page per minute reading speed
|
||||
return Math.round(totalPages);
|
||||
}
|
||||
|
||||
private calculateGenreDiversity(books: Book[], startOfMonth: Date, endOfMonth: Date): number {
|
||||
const relevantBooks = books.filter(book => {
|
||||
// Books finished, started, or being read during this month
|
||||
const wasFinished = book.dateFinished &&
|
||||
new Date(book.dateFinished) >= startOfMonth &&
|
||||
new Date(book.dateFinished) <= endOfMonth;
|
||||
|
||||
const isReading = book.readStatus === ReadStatus.READING ||
|
||||
book.readStatus === ReadStatus.PARTIALLY_READ;
|
||||
|
||||
return wasFinished || isReading;
|
||||
});
|
||||
|
||||
const uniqueGenres = new Set<string>();
|
||||
|
||||
relevantBooks.forEach(book => {
|
||||
if (book.metadata?.categories) {
|
||||
book.metadata.categories.forEach(category => {
|
||||
uniqueGenres.add(category.toLowerCase());
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return uniqueGenres.size;
|
||||
}
|
||||
|
||||
private formatMonthYear(date: Date): string {
|
||||
return date.getFullYear() + '-' + String(date.getMonth() + 1).padStart(2, '0');
|
||||
}
|
||||
|
||||
private formatDisplayMonth(monthKey: string): string {
|
||||
const [year, month] = monthKey.split('-');
|
||||
const monthNames = [
|
||||
'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
|
||||
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'
|
||||
];
|
||||
return `${monthNames[parseInt(month) - 1]} ${year}`;
|
||||
}
|
||||
|
||||
private formatTooltipLabel(context: any): string {
|
||||
const datasetLabel = context.dataset.label;
|
||||
const value = context.parsed.y;
|
||||
const dataIndex = context.dataIndex;
|
||||
const stats = this.getLastCalculatedStats();
|
||||
|
||||
if (!stats || dataIndex >= stats.length) {
|
||||
return `${datasetLabel}: ${value}`;
|
||||
}
|
||||
|
||||
const monthStats = stats[dataIndex];
|
||||
|
||||
switch (datasetLabel) {
|
||||
case 'Books Started':
|
||||
return `${value} books started | ${monthStats.genreDiversity} genres explored`;
|
||||
case 'Books Finished':
|
||||
return `${value} books completed | ${monthStats.totalReadingTime} est. minutes read`;
|
||||
case 'Books Added':
|
||||
return `${value} books added to library`;
|
||||
case 'Avg Progress %':
|
||||
return `${value}% average progress on current reads`;
|
||||
case 'Genre Diversity':
|
||||
return `${value} unique genres being read`;
|
||||
default:
|
||||
return `${datasetLabel}: ${value}`;
|
||||
}
|
||||
}
|
||||
|
||||
private lastCalculatedStats: MonthlyPattern[] = [];
|
||||
|
||||
private getLastCalculatedStats(): MonthlyPattern[] {
|
||||
return this.lastCalculatedStats;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
import {inject, Injectable, OnDestroy} from '@angular/core';
|
||||
import {BehaviorSubject, EMPTY, Observable, Subject} from 'rxjs';
|
||||
import {map, takeUntil, catchError, filter, first, switchMap} from 'rxjs/operators';
|
||||
import {ChartConfiguration, ChartData, ChartType} from 'chart.js';
|
||||
|
||||
import {LibraryFilterService} from './library-filter.service';
|
||||
import {BookService} from '../../book/service/book.service';
|
||||
import {Book} from '../../book/model/book.model';
|
||||
|
||||
interface PageCountStats {
|
||||
category: string;
|
||||
count: number;
|
||||
avgPages: number;
|
||||
minPages: number;
|
||||
maxPages: number;
|
||||
}
|
||||
|
||||
const CHART_COLORS = [
|
||||
'#81C784', '#4FC3F7', '#FFB74D', '#F06292', '#BA68C8'
|
||||
] as const;
|
||||
|
||||
const CHART_DEFAULTS = {
|
||||
borderColor: '#ffffff',
|
||||
borderWidth: 1,
|
||||
hoverBorderWidth: 2,
|
||||
hoverBorderColor: '#ffffff'
|
||||
} as const;
|
||||
|
||||
type PageCountChartData = ChartData<'bar', number[], string>;
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class PageCountChartService implements OnDestroy {
|
||||
private readonly bookService = inject(BookService);
|
||||
private readonly libraryFilterService = inject(LibraryFilterService);
|
||||
private readonly destroy$ = new Subject<void>();
|
||||
|
||||
public readonly pageCountChartType: ChartType = 'bar';
|
||||
|
||||
public readonly pageCountChartOptions: ChartConfiguration['options'] = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.9)',
|
||||
titleColor: '#ffffff',
|
||||
bodyColor: '#ffffff',
|
||||
borderColor: '#ffffff',
|
||||
borderWidth: 1,
|
||||
cornerRadius: 6,
|
||||
padding: 12,
|
||||
titleFont: {size: 14, weight: 'bold'},
|
||||
bodyFont: {size: 12},
|
||||
callbacks: {
|
||||
title: (context) => context[0].label,
|
||||
label: (context) => {
|
||||
const value = context.parsed.y;
|
||||
return `${value} book${value === 1 ? '' : 's'}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
ticks: {
|
||||
color: '#ffffff',
|
||||
font: {
|
||||
family: "'Inter', sans-serif",
|
||||
size: 11
|
||||
},
|
||||
maxRotation: 45
|
||||
},
|
||||
grid: {color: 'rgba(255, 255, 255, 0.1)'},
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Page Count Category',
|
||||
color: '#ffffff',
|
||||
font: {
|
||||
family: "'Inter', sans-serif",
|
||||
size: 11.5
|
||||
}
|
||||
}
|
||||
},
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
ticks: {
|
||||
color: '#ffffff',
|
||||
font: {
|
||||
family: "'Inter', sans-serif",
|
||||
size: 11
|
||||
},
|
||||
stepSize: 1
|
||||
},
|
||||
grid: {color: 'rgba(255, 255, 255, 0.05)'},
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Number of Books',
|
||||
color: '#ffffff',
|
||||
font: {
|
||||
family: "'Inter', sans-serif",
|
||||
size: 11.5
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
private readonly pageCountChartDataSubject = new BehaviorSubject<PageCountChartData>({
|
||||
labels: [],
|
||||
datasets: [{
|
||||
label: 'Books by Page Count',
|
||||
data: [],
|
||||
backgroundColor: [...CHART_COLORS],
|
||||
...CHART_DEFAULTS
|
||||
}]
|
||||
});
|
||||
|
||||
public readonly pageCountChartData$: Observable<PageCountChartData> =
|
||||
this.pageCountChartDataSubject.asObservable();
|
||||
|
||||
constructor() {
|
||||
this.bookService.bookState$
|
||||
.pipe(
|
||||
filter(state => state.loaded),
|
||||
first(),
|
||||
switchMap(() =>
|
||||
this.libraryFilterService.selectedLibrary$.pipe(
|
||||
takeUntil(this.destroy$)
|
||||
)
|
||||
),
|
||||
catchError((error) => {
|
||||
console.error('Error processing page count stats:', error);
|
||||
return EMPTY;
|
||||
})
|
||||
)
|
||||
.subscribe(() => {
|
||||
const stats = this.calculatePageCountStats();
|
||||
this.updateChartData(stats);
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
}
|
||||
|
||||
private updateChartData(stats: PageCountStats[]): void {
|
||||
try {
|
||||
const labels = stats.map(s => s.category);
|
||||
const dataValues = stats.map(s => s.count);
|
||||
|
||||
this.pageCountChartDataSubject.next({
|
||||
labels,
|
||||
datasets: [{
|
||||
label: 'Books by Page Count',
|
||||
data: dataValues,
|
||||
backgroundColor: [...CHART_COLORS],
|
||||
...CHART_DEFAULTS
|
||||
}]
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error updating chart data:', error);
|
||||
}
|
||||
}
|
||||
|
||||
private calculatePageCountStats(): PageCountStats[] {
|
||||
const currentState = this.bookService.getCurrentBookState();
|
||||
const selectedLibraryId = this.libraryFilterService.getCurrentSelectedLibrary();
|
||||
|
||||
if (!this.isValidBookState(currentState)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const filteredBooks = this.filterBooksByLibrary(currentState.books!, selectedLibraryId);
|
||||
return this.processPageCountStats(filteredBooks);
|
||||
}
|
||||
|
||||
private isValidBookState(state: any): boolean {
|
||||
return state?.loaded && state?.books && Array.isArray(state.books) && state.books.length > 0;
|
||||
}
|
||||
|
||||
private filterBooksByLibrary(books: Book[], selectedLibraryId: string | number | null): Book[] {
|
||||
return selectedLibraryId
|
||||
? books.filter(book => book.libraryId === selectedLibraryId)
|
||||
: books;
|
||||
}
|
||||
|
||||
private processPageCountStats(books: Book[]): PageCountStats[] {
|
||||
const categories = [
|
||||
{name: 'Short (< 200)', min: 0, max: 199},
|
||||
{name: 'Medium (200-400)', min: 200, max: 400},
|
||||
{name: 'Long (401-600)', min: 401, max: 600},
|
||||
{name: 'Very Long (601-800)', min: 601, max: 800},
|
||||
{name: 'Epic (> 800)', min: 801, max: 9999}
|
||||
];
|
||||
|
||||
return categories.map(category => {
|
||||
const booksInCategory = books.filter(book => {
|
||||
const pageCount = book.metadata?.pageCount;
|
||||
return pageCount && pageCount >= category.min && pageCount <= category.max;
|
||||
});
|
||||
|
||||
const pageCounts = booksInCategory
|
||||
.map(book => book.metadata?.pageCount || 0)
|
||||
.filter(count => count > 0);
|
||||
|
||||
return {
|
||||
category: category.name,
|
||||
count: booksInCategory.length,
|
||||
avgPages: pageCounts.length > 0 ? Math.round(pageCounts.reduce((a, b) => a + b, 0) / pageCounts.length) : 0,
|
||||
minPages: pageCounts.length > 0 ? Math.min(...pageCounts) : 0,
|
||||
maxPages: pageCounts.length > 0 ? Math.max(...pageCounts) : 0
|
||||
};
|
||||
}).filter(stat => stat.count > 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
import {inject, Injectable, OnDestroy} from '@angular/core';
|
||||
import {BehaviorSubject, EMPTY, Observable, Subject} from 'rxjs';
|
||||
import {map, takeUntil, catchError, filter, first, switchMap} from 'rxjs/operators';
|
||||
import {LibraryFilterService} from './library-filter.service';
|
||||
import {BookService} from '../../book/service/book.service';
|
||||
import {Book} from '../../book/model/book.model';
|
||||
import {ChartConfiguration, ChartData, ChartType} from 'chart.js';
|
||||
|
||||
interface RatingStats {
|
||||
ratingRange: string;
|
||||
count: number;
|
||||
averageRating: number;
|
||||
}
|
||||
|
||||
const CHART_COLORS = [
|
||||
'#DC2626', // Red (rating 1)
|
||||
'#EA580C', // Red-orange (rating 2)
|
||||
'#F59E0B', // Orange (rating 3)
|
||||
'#EAB308', // Yellow-orange (rating 4)
|
||||
'#FACC15', // Yellow (rating 5)
|
||||
'#BEF264', // Yellow-green (rating 6)
|
||||
'#65A30D', // Green (rating 7)
|
||||
'#16A34A', // Green (rating 8)
|
||||
'#059669', // Teal-green (rating 9)
|
||||
'#2563EB' // Blue (rating 10)
|
||||
] as const;
|
||||
|
||||
const CHART_DEFAULTS = {
|
||||
borderColor: '#ffffff',
|
||||
borderWidth: 1,
|
||||
hoverBorderWidth: 2,
|
||||
hoverBorderColor: '#ffffff'
|
||||
} as const;
|
||||
|
||||
const RATING_RANGES = [
|
||||
{range: '1', min: 1.0, max: 1.0},
|
||||
{range: '2', min: 2.0, max: 2.0},
|
||||
{range: '3', min: 3.0, max: 3.0},
|
||||
{range: '4', min: 4.0, max: 4.0},
|
||||
{range: '5', min: 5.0, max: 5.0},
|
||||
{range: '6', min: 6.0, max: 6.0},
|
||||
{range: '7', min: 7.0, max: 7.0},
|
||||
{range: '8', min: 8.0, max: 8.0},
|
||||
{range: '9', min: 9.0, max: 9.0},
|
||||
{range: '10', min: 10.0, max: 10.0},
|
||||
{range: 'No Rating', min: 0, max: 0}
|
||||
] as const;
|
||||
|
||||
type RatingChartData = ChartData<'bar', number[], string>;
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class PersonalRatingChartService implements OnDestroy {
|
||||
private readonly bookService = inject(BookService);
|
||||
private readonly libraryFilterService = inject(LibraryFilterService);
|
||||
private readonly destroy$ = new Subject<void>();
|
||||
|
||||
public readonly personalRatingChartType = 'bar' as const;
|
||||
|
||||
public readonly personalRatingChartOptions: ChartConfiguration['options'] = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {display: false},
|
||||
tooltip: {
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.8)',
|
||||
titleColor: '#ffffff',
|
||||
bodyColor: '#ffffff',
|
||||
borderColor: '#666666',
|
||||
borderWidth: 1,
|
||||
callbacks: {
|
||||
title: (context) => `Personal Rating Range: ${context[0].label}`,
|
||||
label: (context) => {
|
||||
const value = context.parsed.y;
|
||||
return `${value} book${value === 1 ? '' : 's'}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
ticks: {
|
||||
color: '#ffffff',
|
||||
font: {
|
||||
family: "'Inter', sans-serif",
|
||||
size: 11
|
||||
}
|
||||
},
|
||||
grid: {color: 'rgba(255, 255, 255, 0.1)'},
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Personal Rating Range',
|
||||
color: '#ffffff',
|
||||
font: {
|
||||
family: "'Inter', sans-serif",
|
||||
size: 11.5
|
||||
}
|
||||
}
|
||||
},
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
ticks: {
|
||||
color: '#ffffff',
|
||||
font: {
|
||||
family: "'Inter', sans-serif",
|
||||
size: 11
|
||||
},
|
||||
stepSize: 1
|
||||
},
|
||||
grid: {color: 'rgba(255, 255, 255, 0.05)'},
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Number of Books',
|
||||
color: '#ffffff',
|
||||
font: {
|
||||
family: "'Inter', sans-serif",
|
||||
size: 11.5
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
private readonly personalRatingChartDataSubject = new BehaviorSubject<RatingChartData>({
|
||||
labels: [],
|
||||
datasets: [{
|
||||
label: 'Books by Personal Rating',
|
||||
data: [],
|
||||
backgroundColor: [...CHART_COLORS],
|
||||
...CHART_DEFAULTS
|
||||
}]
|
||||
});
|
||||
|
||||
public readonly personalRatingChartData$: Observable<RatingChartData> =
|
||||
this.personalRatingChartDataSubject.asObservable();
|
||||
|
||||
constructor() {
|
||||
this.bookService.bookState$
|
||||
.pipe(
|
||||
filter(state => state.loaded),
|
||||
first(),
|
||||
switchMap(() =>
|
||||
this.libraryFilterService.selectedLibrary$.pipe(
|
||||
takeUntil(this.destroy$)
|
||||
)
|
||||
),
|
||||
catchError((error) => {
|
||||
console.error('Error processing personal rating stats:', error);
|
||||
return EMPTY;
|
||||
})
|
||||
)
|
||||
.subscribe(() => {
|
||||
const stats = this.calculatePersonalRatingStats();
|
||||
this.updateChartData(stats);
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
}
|
||||
|
||||
private updateChartData(stats: RatingStats[]): void {
|
||||
try {
|
||||
const labels = stats.map(s => s.ratingRange);
|
||||
const dataValues = stats.map(s => s.count);
|
||||
const colors = stats.map((_, index) => CHART_COLORS[index % CHART_COLORS.length]);
|
||||
|
||||
this.personalRatingChartDataSubject.next({
|
||||
labels,
|
||||
datasets: [{
|
||||
label: 'Books by Personal Rating',
|
||||
data: dataValues,
|
||||
backgroundColor: colors,
|
||||
...CHART_DEFAULTS
|
||||
}]
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error updating personal rating chart data:', error);
|
||||
}
|
||||
}
|
||||
|
||||
private calculatePersonalRatingStats(): RatingStats[] {
|
||||
const currentState = this.bookService.getCurrentBookState();
|
||||
const selectedLibraryId = this.libraryFilterService.getCurrentSelectedLibrary();
|
||||
|
||||
if (!this.isValidBookState(currentState)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const filteredBooks = this.filterBooksByLibrary(currentState.books!, selectedLibraryId);
|
||||
return this.processPersonalRatingStats(filteredBooks);
|
||||
}
|
||||
|
||||
private isValidBookState(state: any): boolean {
|
||||
return state?.loaded && state?.books && Array.isArray(state.books) && state.books.length > 0;
|
||||
}
|
||||
|
||||
private filterBooksByLibrary(books: Book[], selectedLibraryId: string | number | null): Book[] {
|
||||
return selectedLibraryId
|
||||
? books.filter(book => book.libraryId === selectedLibraryId)
|
||||
: books;
|
||||
}
|
||||
|
||||
private processPersonalRatingStats(books: Book[]): RatingStats[] {
|
||||
const rangeCounts = new Map<string, { count: number, totalRating: number }>();
|
||||
RATING_RANGES.forEach(range => rangeCounts.set(range.range, {count: 0, totalRating: 0}));
|
||||
|
||||
books.forEach(book => {
|
||||
const personalRating = book.metadata?.personalRating;
|
||||
|
||||
if (!personalRating || personalRating === 0) {
|
||||
const noRatingData = rangeCounts.get('No Rating')!;
|
||||
noRatingData.count++;
|
||||
} else {
|
||||
for (const range of RATING_RANGES) {
|
||||
if (range.range !== 'No Rating' && personalRating >= range.min && personalRating <= range.max) {
|
||||
const rangeData = rangeCounts.get(range.range)!;
|
||||
rangeData.count++;
|
||||
rangeData.totalRating += personalRating;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return RATING_RANGES.map(range => {
|
||||
const data = rangeCounts.get(range.range)!;
|
||||
return {
|
||||
ratingRange: range.range,
|
||||
count: data.count,
|
||||
averageRating: data.count > 0 ? data.totalRating / data.count : 0
|
||||
};
|
||||
}).filter(stat => stat.ratingRange !== 'No Rating');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
import {inject, Injectable, OnDestroy} from '@angular/core';
|
||||
import {BehaviorSubject, EMPTY, Observable, Subject} from 'rxjs';
|
||||
import {map, takeUntil, catchError, filter, first, switchMap} from 'rxjs/operators';
|
||||
import {ChartConfiguration, ChartData, ChartType} from 'chart.js';
|
||||
|
||||
import {LibraryFilterService} from './library-filter.service';
|
||||
import {BookService} from '../../book/service/book.service';
|
||||
import {Book} from '../../book/model/book.model';
|
||||
|
||||
interface PublicationYearStats {
|
||||
year: string;
|
||||
count: number;
|
||||
decade: string;
|
||||
}
|
||||
|
||||
const CHART_COLORS = {
|
||||
primary: '#4ECDC4',
|
||||
primaryBackground: 'rgba(78, 205, 196, 0.1)',
|
||||
border: '#ffffff'
|
||||
} as const;
|
||||
|
||||
const CHART_DEFAULTS = {
|
||||
borderColor: CHART_COLORS.primary,
|
||||
backgroundColor: CHART_COLORS.primaryBackground,
|
||||
borderWidth: 2,
|
||||
pointBackgroundColor: CHART_COLORS.primary,
|
||||
pointBorderColor: CHART_COLORS.border,
|
||||
pointBorderWidth: 2,
|
||||
pointRadius: 4,
|
||||
pointHoverRadius: 6,
|
||||
fill: true,
|
||||
tension: 0.4
|
||||
} as const;
|
||||
|
||||
type YearChartData = ChartData<'line', number[], string>;
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class PublicationYearChartService implements OnDestroy {
|
||||
private readonly bookService = inject(BookService);
|
||||
private readonly libraryFilterService = inject(LibraryFilterService);
|
||||
private readonly destroy$ = new Subject<void>();
|
||||
|
||||
public readonly yearChartType = 'line' as const;
|
||||
|
||||
public readonly yearChartOptions: ChartConfiguration['options'] = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {display: false},
|
||||
tooltip: {
|
||||
enabled: true,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.9)',
|
||||
titleColor: '#ffffff',
|
||||
bodyColor: '#ffffff',
|
||||
borderColor: '#ffffff',
|
||||
borderWidth: 1,
|
||||
cornerRadius: 6,
|
||||
displayColors: true,
|
||||
padding: 12,
|
||||
titleFont: {size: 14, weight: 'bold'},
|
||||
bodyFont: {size: 13},
|
||||
position: 'nearest',
|
||||
callbacks: {
|
||||
title: (context) => `Year ${context[0].label}`,
|
||||
label: this.formatTooltipLabel.bind(this)
|
||||
}
|
||||
},
|
||||
datalabels: {
|
||||
display: true,
|
||||
color: '#ffffff',
|
||||
font: {
|
||||
size: 10,
|
||||
weight: 'bold'
|
||||
},
|
||||
align: 'top',
|
||||
offset: 8,
|
||||
formatter: (value: number) => value.toString()
|
||||
}
|
||||
},
|
||||
interaction: {
|
||||
intersect: false,
|
||||
mode: 'point'
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
beginAtZero: true,
|
||||
ticks: {
|
||||
color: '#ffffff',
|
||||
font: {
|
||||
family: "'Inter', sans-serif",
|
||||
size: 11.5
|
||||
},
|
||||
maxRotation: 45,
|
||||
callback: function (value, index, values) {
|
||||
// Show every 5th year to avoid crowding
|
||||
return index % 5 === 0 ? this.getLabelForValue(value as number) : '';
|
||||
}
|
||||
},
|
||||
grid: {color: 'rgba(255, 255, 255, 0.1)'},
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Publication Year',
|
||||
color: '#ffffff',
|
||||
font: {
|
||||
family: "'Inter', sans-serif",
|
||||
size: 11.5
|
||||
}
|
||||
}
|
||||
},
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
ticks: {
|
||||
color: '#ffffff',
|
||||
font: {
|
||||
family: "'Inter', sans-serif",
|
||||
size: 11.5
|
||||
},
|
||||
stepSize: 1
|
||||
},
|
||||
grid: {color: 'rgba(255, 255, 255, 0.05)'},
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Number of Books',
|
||||
color: '#ffffff',
|
||||
font: {
|
||||
family: "'Inter', sans-serif",
|
||||
size: 11.5
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
private readonly yearChartDataSubject = new BehaviorSubject<YearChartData>({
|
||||
labels: [],
|
||||
datasets: [{
|
||||
label: 'Books Published',
|
||||
data: [],
|
||||
...CHART_DEFAULTS
|
||||
}]
|
||||
});
|
||||
|
||||
public readonly yearChartData$: Observable<YearChartData> =
|
||||
this.yearChartDataSubject.asObservable();
|
||||
|
||||
constructor() {
|
||||
this.bookService.bookState$
|
||||
.pipe(
|
||||
filter(state => state.loaded),
|
||||
first(),
|
||||
switchMap(() =>
|
||||
this.libraryFilterService.selectedLibrary$.pipe(
|
||||
takeUntil(this.destroy$)
|
||||
)
|
||||
),
|
||||
catchError((error) => {
|
||||
console.error('Error processing publication year stats:', error);
|
||||
return EMPTY;
|
||||
})
|
||||
)
|
||||
.subscribe(() => {
|
||||
const stats = this.calculatePublicationYearStats();
|
||||
this.updateChartData(stats);
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
}
|
||||
|
||||
private updateChartData(stats: PublicationYearStats[]): void {
|
||||
try {
|
||||
const labels = stats.map(s => s.year);
|
||||
const dataValues = stats.map(s => s.count);
|
||||
|
||||
this.yearChartDataSubject.next({
|
||||
labels,
|
||||
datasets: [{
|
||||
label: 'Books Published',
|
||||
data: dataValues,
|
||||
...CHART_DEFAULTS
|
||||
}]
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error updating chart data:', error);
|
||||
}
|
||||
}
|
||||
|
||||
private calculatePublicationYearStats(): PublicationYearStats[] {
|
||||
const currentState = this.bookService.getCurrentBookState();
|
||||
const selectedLibraryId = this.libraryFilterService.getCurrentSelectedLibrary();
|
||||
|
||||
if (!this.isValidBookState(currentState)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const filteredBooks = this.filterBooksByLibrary(currentState.books!, selectedLibraryId);
|
||||
return this.processPublicationYearStats(filteredBooks);
|
||||
}
|
||||
|
||||
public updateFromStats(stats: PublicationYearStats[]): void {
|
||||
this.updateChartData(stats);
|
||||
}
|
||||
|
||||
private isValidBookState(state: any): boolean {
|
||||
return state?.loaded && state?.books && Array.isArray(state.books) && state.books.length > 0;
|
||||
}
|
||||
|
||||
private filterBooksByLibrary(books: Book[], selectedLibraryId: string | number | null): Book[] {
|
||||
return selectedLibraryId
|
||||
? books.filter(book => book.libraryId === selectedLibraryId)
|
||||
: books;
|
||||
}
|
||||
|
||||
private processPublicationYearStats(books: Book[]): PublicationYearStats[] {
|
||||
const yearMap = new Map<string, number>();
|
||||
|
||||
books.forEach(book => {
|
||||
if (book.metadata?.publishedDate) {
|
||||
const year = this.extractYear(book.metadata.publishedDate);
|
||||
if (year && year >= 1800 && year <= new Date().getFullYear()) {
|
||||
const yearStr = year.toString();
|
||||
yearMap.set(yearStr, (yearMap.get(yearStr) || 0) + 1);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Only return years that have books (no 0 entries)
|
||||
return Array.from(yearMap.entries())
|
||||
.filter(([year, count]) => count > 0)
|
||||
.map(([year, count]) => ({
|
||||
year,
|
||||
count,
|
||||
decade: `${Math.floor(parseInt(year) / 10) * 10}s`
|
||||
}))
|
||||
.sort((a, b) => parseInt(a.year) - parseInt(b.year));
|
||||
}
|
||||
|
||||
private extractYear(dateString: string): number | null {
|
||||
const yearMatch = dateString.match(/(\d{4})/);
|
||||
return yearMatch ? parseInt(yearMatch[1]) : null;
|
||||
}
|
||||
|
||||
private formatTooltipLabel(context: any): string {
|
||||
const value = context.parsed.y;
|
||||
return `${value} book${value === 1 ? '' : 's'} published`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
import {inject, Injectable, OnDestroy} from '@angular/core';
|
||||
import {BehaviorSubject, EMPTY, Observable, Subject} from 'rxjs';
|
||||
import {map, takeUntil, catchError, filter, first, switchMap} from 'rxjs/operators';
|
||||
import {ChartConfiguration, ChartData, ChartType} from 'chart.js';
|
||||
|
||||
import {LibraryFilterService} from './library-filter.service';
|
||||
import {BookService} from '../../book/service/book.service';
|
||||
import {Book, ReadStatus} from '../../book/model/book.model';
|
||||
|
||||
interface ReadingStatusStats {
|
||||
status: string;
|
||||
count: number;
|
||||
percentage: number;
|
||||
}
|
||||
|
||||
const CHART_COLORS = [
|
||||
'#28a745', '#17a2b8', '#ffc107', '#6f42c1',
|
||||
'#fd7e14', '#6c757d', '#dc3545', '#343a40', '#e9ecef'
|
||||
] as const;
|
||||
|
||||
const CHART_DEFAULTS = {
|
||||
borderColor: '#ffffff',
|
||||
borderWidth: 2,
|
||||
hoverBorderWidth: 3,
|
||||
hoverBorderColor: '#ffffff'
|
||||
} as const;
|
||||
|
||||
type StatusChartData = ChartData<'doughnut', number[], string>;
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class ReadStatusChartService implements OnDestroy {
|
||||
private readonly bookService = inject(BookService);
|
||||
private readonly libraryFilterService = inject(LibraryFilterService);
|
||||
private readonly destroy$ = new Subject<void>();
|
||||
|
||||
public readonly statusChartType: ChartType = 'doughnut';
|
||||
|
||||
public readonly statusChartOptions: ChartConfiguration['options'] = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: true,
|
||||
position: 'bottom',
|
||||
labels: {
|
||||
padding: 15,
|
||||
usePointStyle: true,
|
||||
generateLabels: this.generateLegendLabels.bind(this)
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
enabled: true,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.9)',
|
||||
titleColor: '#ffffff',
|
||||
bodyColor: '#ffffff',
|
||||
borderColor: '#ffffff',
|
||||
borderWidth: 1,
|
||||
cornerRadius: 6,
|
||||
displayColors: true,
|
||||
padding: 12,
|
||||
titleFont: {size: 14, weight: 'bold'},
|
||||
bodyFont: {size: 13},
|
||||
position: 'nearest',
|
||||
callbacks: {
|
||||
title: (context) => context[0]?.label || '',
|
||||
label: this.formatTooltipLabel
|
||||
}
|
||||
}
|
||||
},
|
||||
interaction: {
|
||||
intersect: false,
|
||||
mode: 'point'
|
||||
}
|
||||
};
|
||||
|
||||
private readonly statusChartDataSubject = new BehaviorSubject<StatusChartData>({
|
||||
labels: [],
|
||||
datasets: [{
|
||||
data: [],
|
||||
backgroundColor: [...CHART_COLORS],
|
||||
...CHART_DEFAULTS
|
||||
}]
|
||||
});
|
||||
|
||||
public readonly statusChartData$: Observable<StatusChartData> = this.statusChartDataSubject.asObservable();
|
||||
|
||||
constructor() {
|
||||
this.bookService.bookState$
|
||||
.pipe(
|
||||
filter(state => state.loaded),
|
||||
first(),
|
||||
switchMap(() =>
|
||||
this.libraryFilterService.selectedLibrary$.pipe(
|
||||
takeUntil(this.destroy$)
|
||||
)
|
||||
),
|
||||
catchError((error) => {
|
||||
console.error('Error processing reading status stats:', error);
|
||||
return EMPTY;
|
||||
})
|
||||
)
|
||||
.subscribe(() => {
|
||||
const stats = this.calculateReadingStatusStats();
|
||||
this.updateChartData(stats);
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
}
|
||||
|
||||
private updateChartData(stats: ReadingStatusStats[]): void {
|
||||
try {
|
||||
const labels = stats.map(s => s.status);
|
||||
const dataValues = stats.map(s => s.count);
|
||||
const colors = this.getColorsForData(stats.length);
|
||||
|
||||
this.statusChartDataSubject.next({
|
||||
labels,
|
||||
datasets: [{
|
||||
data: dataValues,
|
||||
backgroundColor: colors,
|
||||
...CHART_DEFAULTS
|
||||
}]
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error updating chart data:', error);
|
||||
}
|
||||
}
|
||||
|
||||
private getColorsForData(dataLength: number): string[] {
|
||||
const colors = [...CHART_COLORS];
|
||||
while (colors.length < dataLength) {
|
||||
colors.push(...CHART_COLORS);
|
||||
}
|
||||
return colors.slice(0, dataLength);
|
||||
}
|
||||
|
||||
private calculateReadingStatusStats(): ReadingStatusStats[] {
|
||||
const currentState = this.bookService.getCurrentBookState();
|
||||
const selectedLibraryId = this.libraryFilterService.getCurrentSelectedLibrary();
|
||||
|
||||
if (!this.isValidBookState(currentState)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const filteredBooks = this.filterBooksByLibrary(currentState.books!, String(selectedLibraryId));
|
||||
return this.processReadingStatusStats(filteredBooks);
|
||||
}
|
||||
|
||||
private isValidBookState(state: any): boolean {
|
||||
return state?.loaded && state?.books && Array.isArray(state.books) && state.books.length > 0;
|
||||
}
|
||||
|
||||
private filterBooksByLibrary(books: Book[], selectedLibraryId: string | null): Book[] {
|
||||
return selectedLibraryId && selectedLibraryId !== 'null'
|
||||
? books.filter(book => String(book.libraryId) === selectedLibraryId)
|
||||
: books;
|
||||
}
|
||||
|
||||
private processReadingStatusStats(books: Book[]): ReadingStatusStats[] {
|
||||
if (books.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const statusMap = this.buildStatusMap(books);
|
||||
return this.convertMapToStats(statusMap, books.length);
|
||||
}
|
||||
|
||||
private buildStatusMap(books: Book[]): Map<ReadStatus, number> {
|
||||
const statusMap = new Map<ReadStatus, number>();
|
||||
|
||||
for (const book of books) {
|
||||
const rawStatus = book.readStatus;
|
||||
const status: ReadStatus = Object.values(ReadStatus).includes(rawStatus as ReadStatus)
|
||||
? (rawStatus as ReadStatus)
|
||||
: ReadStatus.UNSET;
|
||||
|
||||
statusMap.set(status, (statusMap.get(status) || 0) + 1);
|
||||
}
|
||||
|
||||
return statusMap;
|
||||
}
|
||||
|
||||
private convertMapToStats(statusMap: Map<ReadStatus, number>, totalBooks: number): ReadingStatusStats[] {
|
||||
return Array.from(statusMap.entries())
|
||||
.map(([status, count]) => ({
|
||||
status: this.formatReadStatus(status),
|
||||
count,
|
||||
percentage: Number(((count / totalBooks) * 100).toFixed(1))
|
||||
}))
|
||||
.sort((a, b) => b.count - a.count);
|
||||
}
|
||||
|
||||
private formatReadStatus(status: ReadStatus | null | undefined): string {
|
||||
const STATUS_MAPPING: Record<string, string> = {
|
||||
[ReadStatus.UNREAD]: 'Unread',
|
||||
[ReadStatus.READING]: 'Currently Reading',
|
||||
[ReadStatus.RE_READING]: 'Re-reading',
|
||||
[ReadStatus.READ]: 'Read',
|
||||
[ReadStatus.PARTIALLY_READ]: 'Partially Read',
|
||||
[ReadStatus.PAUSED]: 'Paused',
|
||||
[ReadStatus.WONT_READ]: "Won't Read",
|
||||
[ReadStatus.ABANDONED]: 'Abandoned',
|
||||
[ReadStatus.UNSET]: 'No Status'
|
||||
};
|
||||
|
||||
if (!status) return 'No Status';
|
||||
return STATUS_MAPPING[status] ?? 'No Status';
|
||||
}
|
||||
|
||||
private generateLegendLabels(chart: any) {
|
||||
const data = chart.data;
|
||||
if (!data.labels?.length || !data.datasets?.[0]?.data?.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const dataset = data.datasets[0];
|
||||
const dataValues = dataset.data as number[];
|
||||
|
||||
return data.labels.map((label: string, index: number) => {
|
||||
const isVisible = typeof chart.getDataVisibility === 'function'
|
||||
? chart.getDataVisibility(index)
|
||||
: !((chart.getDatasetMeta && chart.getDatasetMeta(0)?.data?.[index]?.hidden) || false);
|
||||
|
||||
return {
|
||||
text: `${label} (${dataValues[index]})`,
|
||||
fillStyle: (dataset.backgroundColor as string[])[index],
|
||||
strokeStyle: '#ffffff',
|
||||
lineWidth: 1,
|
||||
hidden: !isVisible,
|
||||
index,
|
||||
fontColor: '#ffffff'
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private formatTooltipLabel(context: any): string {
|
||||
const dataIndex = context.dataIndex;
|
||||
const dataset = context.dataset;
|
||||
const value = dataset.data[dataIndex] as number;
|
||||
const label = context.chart.data.labels?.[dataIndex] || 'Unknown';
|
||||
const total = (dataset.data as number[]).reduce((a: number, b: number) => a + b, 0);
|
||||
const percentage = ((value / total) * 100).toFixed(1);
|
||||
return `${label}: ${value} books (${percentage}%)`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
import {inject, Injectable, OnDestroy} from '@angular/core';
|
||||
import {BehaviorSubject, EMPTY, Observable, Subject} from 'rxjs';
|
||||
import {catchError, filter, first, map, switchMap, takeUntil} from 'rxjs/operators';
|
||||
import {LibraryFilterService} from './library-filter.service';
|
||||
import {BookService} from '../../book/service/book.service';
|
||||
import {Book, ReadStatus} from '../../book/model/book.model';
|
||||
import {ChartConfiguration, ChartData} from 'chart.js';
|
||||
|
||||
interface CompletionStats {
|
||||
category: string;
|
||||
readStatusCounts: Record<ReadStatus, number>;
|
||||
total: number;
|
||||
}
|
||||
|
||||
const READ_STATUS_COLORS: Record<ReadStatus, string> = {
|
||||
[ReadStatus.READ]: '#2ecc71',
|
||||
[ReadStatus.READING]: '#f39c12',
|
||||
[ReadStatus.RE_READING]: '#9b59b6',
|
||||
[ReadStatus.PARTIALLY_READ]: '#e67e22',
|
||||
[ReadStatus.PAUSED]: '#34495e',
|
||||
[ReadStatus.UNREAD]: '#4169e1',
|
||||
[ReadStatus.WONT_READ]: '#95a5a6',
|
||||
[ReadStatus.ABANDONED]: '#e74c3c',
|
||||
[ReadStatus.UNSET]: '#3498db'
|
||||
};
|
||||
|
||||
const CHART_DEFAULTS = {
|
||||
borderColor: '#ffffff',
|
||||
hoverBorderWidth: 1,
|
||||
hoverBorderColor: '#ffffff'
|
||||
} as const;
|
||||
|
||||
type CompletionChartData = ChartData<'bar', number[], string>;
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class ReadingCompletionChartService implements OnDestroy {
|
||||
private readonly bookService = inject(BookService);
|
||||
private readonly libraryFilterService = inject(LibraryFilterService);
|
||||
private readonly destroy$ = new Subject<void>();
|
||||
|
||||
public readonly completionChartType = 'bar' as const;
|
||||
|
||||
public readonly completionChartOptions: ChartConfiguration<'bar'>['options'] = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
scales: {
|
||||
x: {
|
||||
stacked: true,
|
||||
ticks: {
|
||||
color: '#ffffff',
|
||||
font: {size: 10},
|
||||
maxRotation: 45,
|
||||
minRotation: 0
|
||||
},
|
||||
grid: {
|
||||
color: 'rgba(255, 255, 255, 0.1)'
|
||||
},
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Categories',
|
||||
color: '#ffffff',
|
||||
font: {
|
||||
family: "'Inter', sans-serif",
|
||||
size: 11
|
||||
},
|
||||
}
|
||||
},
|
||||
y: {
|
||||
stacked: true,
|
||||
beginAtZero: true,
|
||||
ticks: {
|
||||
color: '#ffffff',
|
||||
font: {
|
||||
family: "'Inter', sans-serif",
|
||||
size: 11
|
||||
},
|
||||
stepSize: 1,
|
||||
maxTicksLimit: 25
|
||||
},
|
||||
grid: {
|
||||
color: 'rgba(255, 255, 255, 0.05)'
|
||||
},
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Number of Books',
|
||||
color: '#ffffff',
|
||||
font: {
|
||||
family: "'Inter', sans-serif",
|
||||
size: 11.5
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: true,
|
||||
position: 'top',
|
||||
labels: {
|
||||
color: '#ffffff',
|
||||
font: {
|
||||
family: "'Inter', sans-serif",
|
||||
size: 10
|
||||
},
|
||||
padding: 15,
|
||||
boxWidth: 12
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.9)',
|
||||
titleColor: '#ffffff',
|
||||
bodyColor: '#ffffff',
|
||||
borderColor: '#ffffff',
|
||||
borderWidth: 1,
|
||||
cornerRadius: 6,
|
||||
displayColors: true,
|
||||
padding: 12,
|
||||
titleFont: {size: 14, weight: 'bold'},
|
||||
bodyFont: {size: 12},
|
||||
callbacks: {
|
||||
title: (context) => {
|
||||
const dataIndex = context[0].dataIndex;
|
||||
const stats = this.getLastCalculatedStats();
|
||||
return stats[dataIndex]?.category || 'Unknown Category';
|
||||
},
|
||||
label: this.formatTooltipLabel.bind(this)
|
||||
}
|
||||
}
|
||||
},
|
||||
interaction: {
|
||||
intersect: false,
|
||||
mode: 'index'
|
||||
}
|
||||
};
|
||||
|
||||
private readonly completionChartDataSubject = new BehaviorSubject<CompletionChartData>({
|
||||
labels: [],
|
||||
datasets: Object.values(ReadStatus).map(status => ({
|
||||
label: this.formatReadStatusLabel(status),
|
||||
data: [],
|
||||
backgroundColor: READ_STATUS_COLORS[status],
|
||||
...CHART_DEFAULTS
|
||||
}))
|
||||
});
|
||||
|
||||
public readonly completionChartData$: Observable<CompletionChartData> =
|
||||
this.completionChartDataSubject.asObservable();
|
||||
|
||||
private lastCalculatedStats: CompletionStats[] = [];
|
||||
|
||||
constructor() {
|
||||
this.bookService.bookState$
|
||||
.pipe(
|
||||
filter(state => state.loaded),
|
||||
first(),
|
||||
switchMap(() =>
|
||||
this.libraryFilterService.selectedLibrary$.pipe(
|
||||
takeUntil(this.destroy$)
|
||||
)
|
||||
),
|
||||
catchError((error) => {
|
||||
console.error('Error processing completion stats:', error);
|
||||
return EMPTY;
|
||||
})
|
||||
)
|
||||
.subscribe(() => {
|
||||
const stats = this.calculateCompletionStats();
|
||||
this.updateChartData(stats);
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
}
|
||||
|
||||
private updateChartData(stats: CompletionStats[]): void {
|
||||
try {
|
||||
this.lastCalculatedStats = stats;
|
||||
const topCategories = stats
|
||||
.sort((a, b) => b.total - a.total)
|
||||
.slice(0, 25);
|
||||
|
||||
const labels = topCategories.map(s => {
|
||||
return s.category.length > 20
|
||||
? s.category.substring(0, 15) + '..'
|
||||
: s.category;
|
||||
});
|
||||
|
||||
const datasets = Object.values(ReadStatus).map(status => ({
|
||||
label: this.formatReadStatusLabel(status),
|
||||
data: topCategories.map(s => s.readStatusCounts[status] || 0),
|
||||
backgroundColor: READ_STATUS_COLORS[status],
|
||||
...CHART_DEFAULTS
|
||||
}));
|
||||
|
||||
this.completionChartDataSubject.next({
|
||||
labels,
|
||||
datasets
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error updating completion chart data:', error);
|
||||
}
|
||||
}
|
||||
|
||||
private calculateCompletionStats(): CompletionStats[] {
|
||||
const currentState = this.bookService.getCurrentBookState();
|
||||
const selectedLibraryId = this.libraryFilterService.getCurrentSelectedLibrary();
|
||||
|
||||
if (!this.isValidBookState(currentState)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const filteredBooks = this.filterBooksByLibrary(currentState.books!, selectedLibraryId);
|
||||
return this.processCompletionStats(filteredBooks);
|
||||
}
|
||||
|
||||
private isValidBookState(state: any): boolean {
|
||||
return state?.loaded && state?.books && Array.isArray(state.books) && state.books.length > 0;
|
||||
}
|
||||
|
||||
private filterBooksByLibrary(books: Book[], selectedLibraryId: string | number | null): Book[] {
|
||||
return selectedLibraryId
|
||||
? books.filter(book => book.libraryId === selectedLibraryId)
|
||||
: books;
|
||||
}
|
||||
|
||||
private processCompletionStats(books: Book[]): CompletionStats[] {
|
||||
const categoryMap = new Map<string, {
|
||||
readStatusCounts: Record<ReadStatus, number>;
|
||||
}>();
|
||||
|
||||
books.forEach(book => {
|
||||
const categories = book.metadata?.categories || ['Uncategorized'];
|
||||
|
||||
categories.forEach(category => {
|
||||
if (!categoryMap.has(category)) {
|
||||
categoryMap.set(category, {
|
||||
readStatusCounts: Object.values(ReadStatus).reduce((acc, status) => {
|
||||
acc[status] = 0;
|
||||
return acc;
|
||||
}, {} as Record<ReadStatus, number>)
|
||||
});
|
||||
}
|
||||
|
||||
const stats = categoryMap.get(category)!;
|
||||
const rawStatus = book.readStatus;
|
||||
const readStatus: ReadStatus = Object.values(ReadStatus).includes(rawStatus as ReadStatus)
|
||||
? (rawStatus as ReadStatus)
|
||||
: ReadStatus.UNSET;
|
||||
stats.readStatusCounts[readStatus]++;
|
||||
});
|
||||
});
|
||||
|
||||
return Array.from(categoryMap.entries()).map(([category, stats]) => {
|
||||
const total = Object.values(stats.readStatusCounts).reduce((sum, count) => sum + count, 0);
|
||||
return {
|
||||
category,
|
||||
readStatusCounts: stats.readStatusCounts,
|
||||
total
|
||||
};
|
||||
}).filter(stat => stat.total > 0);
|
||||
}
|
||||
|
||||
private formatReadStatusLabel(status: ReadStatus): string {
|
||||
return status.split('_').map(word =>
|
||||
word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()
|
||||
).join(' ');
|
||||
}
|
||||
|
||||
private formatTooltipLabel(context: any): string {
|
||||
const dataIndex = context.dataIndex;
|
||||
const stats = this.getLastCalculatedStats();
|
||||
|
||||
if (!stats || dataIndex >= stats.length) {
|
||||
return `${context.parsed.y} books`;
|
||||
}
|
||||
|
||||
const category = stats[dataIndex];
|
||||
const value = context.parsed.y;
|
||||
const datasetLabel = context.dataset.label;
|
||||
|
||||
return `${datasetLabel}: ${value}`;
|
||||
}
|
||||
|
||||
private getLastCalculatedStats(): CompletionStats[] {
|
||||
return this.lastCalculatedStats;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,573 @@
|
||||
import {inject, Injectable, OnDestroy} from '@angular/core';
|
||||
import {BehaviorSubject, EMPTY, Observable, Subject, of} from 'rxjs';
|
||||
import {map, takeUntil, catchError, filter, first, switchMap} from 'rxjs/operators';
|
||||
import {ChartConfiguration, ChartData} from 'chart.js';
|
||||
|
||||
import {LibraryFilterService} from './library-filter.service';
|
||||
import {BookService} from '../../book/service/book.service';
|
||||
import {Book, ReadStatus} from '../../book/model/book.model';
|
||||
|
||||
interface ReadingDNAProfile {
|
||||
adventurous: number; // Genre diversity and experimental reading
|
||||
perfectionist: number; // Completion rate and quality focus
|
||||
intellectual: number; // Non-fiction preference and complex books
|
||||
emotional: number; // Fiction preference and rating engagement
|
||||
patient: number; // Long book preference and series completion
|
||||
social: number; // Popular books and mainstream choices
|
||||
nostalgic: number; // Older books and classic literature
|
||||
ambitious: number; // Reading volume and challenging material
|
||||
}
|
||||
|
||||
interface PersonalityInsight {
|
||||
trait: string;
|
||||
score: number;
|
||||
description: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
type ReadingDNAChartData = ChartData<'radar', number[], string>;
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class ReadingDNAChartService implements OnDestroy {
|
||||
private readonly bookService = inject(BookService);
|
||||
private readonly libraryFilterService = inject(LibraryFilterService);
|
||||
private readonly destroy$ = new Subject<void>();
|
||||
|
||||
public readonly readingDNAChartType = 'radar' as const;
|
||||
|
||||
public readonly readingDNAChartOptions: ChartConfiguration<'radar'>['options'] = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
scales: {
|
||||
r: {
|
||||
beginAtZero: true,
|
||||
min: 0,
|
||||
max: 100,
|
||||
ticks: {
|
||||
stepSize: 20,
|
||||
color: 'rgba(255, 255, 255, 0.6)',
|
||||
font: {
|
||||
family: "'Inter', sans-serif",
|
||||
size: 12
|
||||
},
|
||||
backdropColor: 'transparent',
|
||||
showLabelBackdrop: false
|
||||
},
|
||||
grid: {
|
||||
color: 'rgba(255, 255, 255, 0.2)',
|
||||
circular: true
|
||||
},
|
||||
angleLines: {
|
||||
color: 'rgba(255, 255, 255, 0.3)'
|
||||
},
|
||||
pointLabels: {
|
||||
color: '#ffffff',
|
||||
font: {
|
||||
family: "'Inter', sans-serif",
|
||||
size: 12
|
||||
},
|
||||
padding: 25,
|
||||
callback: function (label: string) {
|
||||
const icons: Record<string, string> = {
|
||||
'Adventurous': '🌟',
|
||||
'Perfectionist': '💎',
|
||||
'Intellectual': '🧠',
|
||||
'Emotional': '💖',
|
||||
'Patient': '🕰️',
|
||||
'Social': '👥',
|
||||
'Nostalgic': '📚',
|
||||
'Ambitious': '🚀'
|
||||
};
|
||||
return [icons[label] || '', label];
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false
|
||||
},
|
||||
tooltip: {
|
||||
enabled: true,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.95)',
|
||||
titleColor: '#ffffff',
|
||||
bodyColor: '#ffffff',
|
||||
borderColor: '#4fc3f7',
|
||||
borderWidth: 2,
|
||||
cornerRadius: 8,
|
||||
padding: 16,
|
||||
titleFont: {size: 14, weight: 'bold'},
|
||||
bodyFont: {size: 12},
|
||||
callbacks: {
|
||||
title: (context) => {
|
||||
const label = context[0]?.label || '';
|
||||
return `${label} Personality`;
|
||||
},
|
||||
label: (context) => {
|
||||
const score = context.parsed.r;
|
||||
const insights = this.getPersonalityInsights();
|
||||
const insight = insights.find(i => i.trait === context.label);
|
||||
|
||||
return [
|
||||
`Score: ${score.toFixed(1)}/100`,
|
||||
'',
|
||||
insight ? insight.description : 'Your reading personality trait'
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
interaction: {
|
||||
intersect: false,
|
||||
mode: 'point'
|
||||
},
|
||||
elements: {
|
||||
line: {
|
||||
borderWidth: 3,
|
||||
tension: 0.1
|
||||
},
|
||||
point: {
|
||||
radius: 5,
|
||||
hoverRadius: 8,
|
||||
borderWidth: 3,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.8)'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
private readonly readingDNAChartDataSubject = new BehaviorSubject<ReadingDNAChartData>({
|
||||
labels: [
|
||||
'Adventurous', 'Perfectionist', 'Intellectual', 'Emotional',
|
||||
'Patient', 'Social', 'Nostalgic', 'Ambitious'
|
||||
],
|
||||
datasets: []
|
||||
});
|
||||
|
||||
public readonly readingDNAChartData$: Observable<ReadingDNAChartData> = this.readingDNAChartDataSubject.asObservable();
|
||||
private lastCalculatedInsights: PersonalityInsight[] = [];
|
||||
|
||||
constructor() {
|
||||
this.bookService.bookState$
|
||||
.pipe(
|
||||
filter(state => state.loaded),
|
||||
first(),
|
||||
switchMap(() =>
|
||||
this.libraryFilterService.selectedLibrary$.pipe(
|
||||
takeUntil(this.destroy$)
|
||||
)
|
||||
),
|
||||
catchError((error) => {
|
||||
console.error('Error processing reading DNA data:', error);
|
||||
return EMPTY;
|
||||
})
|
||||
)
|
||||
.subscribe(() => {
|
||||
const profile = this.calculateReadingDNAData();
|
||||
this.updateChartData(profile);
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
}
|
||||
|
||||
private updateChartData(profile: ReadingDNAProfile | null): void {
|
||||
try {
|
||||
if (!profile) {
|
||||
this.readingDNAChartDataSubject.next({
|
||||
labels: [],
|
||||
datasets: []
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const data = [
|
||||
profile.adventurous,
|
||||
profile.perfectionist,
|
||||
profile.intellectual,
|
||||
profile.emotional,
|
||||
profile.patient,
|
||||
profile.social,
|
||||
profile.nostalgic,
|
||||
profile.ambitious
|
||||
];
|
||||
|
||||
// Create gradient effect colors
|
||||
const gradientColors = [
|
||||
'#ff6b9d', '#45aaf2', '#96f7d2', '#feca57',
|
||||
'#ff9ff3', '#54a0ff', '#5f27cd', '#00d2d3'
|
||||
];
|
||||
|
||||
this.readingDNAChartDataSubject.next({
|
||||
labels: [
|
||||
'Adventurous', 'Perfectionist', 'Intellectual', 'Emotional',
|
||||
'Patient', 'Social', 'Nostalgic', 'Ambitious'
|
||||
],
|
||||
datasets: [{
|
||||
label: 'Reading DNA Profile',
|
||||
data,
|
||||
backgroundColor: 'rgba(79, 195, 247, 0.2)',
|
||||
borderColor: '#4fc3f7',
|
||||
borderWidth: 3,
|
||||
pointBackgroundColor: gradientColors,
|
||||
pointBorderColor: '#ffffff',
|
||||
pointBorderWidth: 3,
|
||||
pointRadius: 5,
|
||||
pointHoverRadius: 8,
|
||||
fill: true
|
||||
}]
|
||||
});
|
||||
|
||||
// Store insights for tooltip
|
||||
this.lastCalculatedInsights = this.convertToPersonalityInsights(profile);
|
||||
} catch (error) {
|
||||
console.error('Error updating reading DNA chart data:', error);
|
||||
}
|
||||
}
|
||||
|
||||
private calculateReadingDNAData(): ReadingDNAProfile | null {
|
||||
const currentState = this.bookService.getCurrentBookState();
|
||||
const selectedLibraryId = this.libraryFilterService.getCurrentSelectedLibrary();
|
||||
|
||||
if (!this.isValidBookState(currentState)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const filteredBooks = this.filterBooksByLibrary(currentState.books!, String(selectedLibraryId));
|
||||
return this.analyzeReadingDNA(filteredBooks);
|
||||
}
|
||||
|
||||
private isValidBookState(state: any): boolean {
|
||||
return state?.loaded && state?.books && Array.isArray(state.books) && state.books.length > 0;
|
||||
}
|
||||
|
||||
private filterBooksByLibrary(books: Book[], selectedLibraryId: string | null): Book[] {
|
||||
return selectedLibraryId && selectedLibraryId !== 'null'
|
||||
? books.filter(book => String(book.libraryId) === selectedLibraryId)
|
||||
: books;
|
||||
}
|
||||
|
||||
private analyzeReadingDNA(books: Book[]): ReadingDNAProfile {
|
||||
if (books.length === 0) {
|
||||
return this.getDefaultProfile();
|
||||
}
|
||||
|
||||
return {
|
||||
adventurous: this.calculateAdventurousScore(books),
|
||||
perfectionist: this.calculatePerfectionistScore(books),
|
||||
intellectual: this.calculateIntellectualScore(books),
|
||||
emotional: this.calculateEmotionalScore(books),
|
||||
patient: this.calculatePatienceScore(books),
|
||||
social: this.calculateSocialScore(books),
|
||||
nostalgic: this.calculateNostalgicScore(books),
|
||||
ambitious: this.calculateAmbitiousScore(books)
|
||||
};
|
||||
}
|
||||
|
||||
private calculateAdventurousScore(books: Book[]): number {
|
||||
// Genre diversity + experimental choices + different formats
|
||||
const genres = new Set<string>();
|
||||
const languages = new Set<string>();
|
||||
const formats = new Set<string>();
|
||||
|
||||
books.forEach(book => {
|
||||
book.metadata?.categories?.forEach(cat => genres.add(cat.toLowerCase()));
|
||||
if (book.metadata?.language) languages.add(book.metadata.language);
|
||||
formats.add(book.bookType);
|
||||
});
|
||||
|
||||
const genreScore = Math.min(60, genres.size * 4); // Max 60 for 15+ genres
|
||||
const languageScore = Math.min(20, languages.size * 10); // Max 20 for 2+ languages
|
||||
const formatScore = Math.min(20, formats.size * 7); // Max 20 for 3+ formats
|
||||
|
||||
return genreScore + languageScore + formatScore;
|
||||
}
|
||||
|
||||
private calculatePerfectionistScore(books: Book[]): number {
|
||||
const completedBooks = books.filter(b => b.readStatus === ReadStatus.READ);
|
||||
const completionRate = completedBooks.length / books.length;
|
||||
|
||||
// High-quality book preference
|
||||
const qualityBooks = books.filter(book => {
|
||||
const metadata = book.metadata;
|
||||
if (!metadata) return false;
|
||||
return (metadata.goodreadsRating && metadata.goodreadsRating >= 4.0) ||
|
||||
(metadata.amazonRating && metadata.amazonRating >= 4.0) ||
|
||||
(metadata.personalRating && metadata.personalRating >= 4);
|
||||
});
|
||||
|
||||
const qualityRate = qualityBooks.length / books.length;
|
||||
const completionScore = completionRate * 60; // Max 60
|
||||
const qualityScore = qualityRate * 40; // Max 40
|
||||
|
||||
return Math.min(100, completionScore + qualityScore);
|
||||
}
|
||||
|
||||
private calculateIntellectualScore(books: Book[]): number {
|
||||
const intellectualGenres = [
|
||||
'philosophy', 'science', 'history', 'biography', 'politics',
|
||||
'psychology', 'sociology', 'economics', 'technology', 'mathematics',
|
||||
'physics', 'chemistry', 'medicine', 'law', 'education',
|
||||
'anthropology', 'archaeology', 'astronomy', 'biology', 'geology',
|
||||
'linguistics', 'neuroscience', 'quantum physics', 'engineering',
|
||||
'computer science', 'artificial intelligence', 'data science',
|
||||
'research', 'academic', 'scholarly', 'theoretical', 'scientific',
|
||||
'analytical', 'critical thinking', 'logic', 'rhetoric',
|
||||
'cultural studies', 'international relations', 'diplomacy',
|
||||
'public policy', 'governance', 'constitutional law', 'ethics',
|
||||
'moral philosophy', 'epistemology', 'metaphysics', 'theology',
|
||||
'religious studies', 'comparative religion', 'apologetics'
|
||||
];
|
||||
|
||||
const intellectualBooks = books.filter(book => {
|
||||
if (!book.metadata?.categories) return false;
|
||||
return book.metadata.categories.some(cat =>
|
||||
intellectualGenres.some(genre => cat.toLowerCase().includes(genre))
|
||||
);
|
||||
});
|
||||
|
||||
// Long books indicate patience for complex material
|
||||
const longBooks = books.filter(book =>
|
||||
book.metadata?.pageCount && book.metadata.pageCount > 400
|
||||
);
|
||||
|
||||
const intellectualRate = intellectualBooks.length / books.length;
|
||||
const longBookRate = longBooks.length / books.length;
|
||||
|
||||
return Math.min(100, (intellectualRate * 70) + (longBookRate * 30));
|
||||
}
|
||||
|
||||
private calculateEmotionalScore(books: Book[]): number {
|
||||
const emotionalGenres = [
|
||||
'fiction', 'romance', 'drama', 'literary', 'contemporary',
|
||||
'memoir', 'poetry', 'young adult', 'coming of age', 'family',
|
||||
'love story', 'relationships', 'emotional', 'heartbreak',
|
||||
'healing', 'self-help', 'personal development', 'inspirational',
|
||||
'motivational', 'spiritual', 'mindfulness', 'meditation',
|
||||
'grief', 'loss', 'trauma', 'recovery', 'therapy',
|
||||
'women\'s fiction', 'chick lit', 'new adult', 'teen',
|
||||
'childhood', 'parenting', 'motherhood', 'fatherhood',
|
||||
'friendship', 'betrayal', 'forgiveness', 'redemption',
|
||||
'slice of life', 'domestic fiction', 'family saga',
|
||||
'generational saga', 'multicultural', 'immigrant stories',
|
||||
'lgbtq+', 'queer fiction', 'feminist', 'gender studies',
|
||||
'social issues', 'mental health', 'addiction', 'wellness',
|
||||
'autobiography', 'personal narrative', 'diary', 'journal'
|
||||
];
|
||||
|
||||
const emotionalBooks = books.filter(book => {
|
||||
if (!book.metadata?.categories) return false;
|
||||
return book.metadata.categories.some(cat =>
|
||||
emotionalGenres.some(genre => cat.toLowerCase().includes(genre))
|
||||
);
|
||||
});
|
||||
|
||||
// Personal rating engagement shows emotional connection
|
||||
const personallyRatedBooks = books.filter(book => book.metadata?.personalRating);
|
||||
|
||||
const emotionalRate = emotionalBooks.length / books.length;
|
||||
const ratingEngagement = personallyRatedBooks.length / books.length;
|
||||
|
||||
return Math.min(100, (emotionalRate * 60) + (ratingEngagement * 40));
|
||||
}
|
||||
|
||||
private calculatePatienceScore(books: Book[]): number {
|
||||
// Long books + series completion + slow reading pace
|
||||
const longBooks = books.filter(book =>
|
||||
book.metadata?.pageCount && book.metadata.pageCount > 500
|
||||
);
|
||||
|
||||
const seriesBooks = books.filter(book =>
|
||||
book.metadata?.seriesName && book.metadata?.seriesNumber
|
||||
);
|
||||
|
||||
// Books with high progress indicate patience to work through them
|
||||
const progressBooks = books.filter(book => {
|
||||
const progress = Math.max(
|
||||
book.epubProgress?.percentage || 0,
|
||||
book.pdfProgress?.percentage || 0,
|
||||
book.cbxProgress?.percentage || 0,
|
||||
book.koreaderProgress?.percentage || 0
|
||||
);
|
||||
return progress > 50;
|
||||
});
|
||||
|
||||
const longBookRate = longBooks.length / books.length;
|
||||
const seriesRate = seriesBooks.length / books.length;
|
||||
const progressRate = progressBooks.length / books.length;
|
||||
|
||||
return Math.min(100, (longBookRate * 40) + (seriesRate * 35) + (progressRate * 25));
|
||||
}
|
||||
|
||||
private calculateSocialScore(books: Book[]): number {
|
||||
// Popular books (high ratings, many reviews) + mainstream genres
|
||||
const popularBooks = books.filter(book => {
|
||||
const metadata = book.metadata;
|
||||
if (!metadata) return false;
|
||||
return (metadata.goodreadsReviewCount && metadata.goodreadsReviewCount > 1000) ||
|
||||
(metadata.amazonReviewCount && metadata.amazonReviewCount > 500);
|
||||
});
|
||||
|
||||
const mainstreamGenres = [
|
||||
'thriller', 'mystery', 'romance', 'fantasy', 'science fiction',
|
||||
'horror', 'adventure', 'bestseller', 'contemporary', 'popular',
|
||||
'crime', 'detective', 'suspense', 'action', 'espionage',
|
||||
'spy', 'police procedural', 'cozy mystery', 'psychological thriller',
|
||||
'domestic thriller', 'legal thriller', 'medical thriller',
|
||||
'urban fantasy', 'paranormal', 'supernatural', 'magic',
|
||||
'dystopian', 'post-apocalyptic', 'cyberpunk', 'space opera',
|
||||
'military science fiction', 'hard science fiction', 'steampunk',
|
||||
'alternate history', 'time travel', 'vampire', 'werewolf',
|
||||
'zombie', 'ghost', 'gothic', 'dark fantasy', 'epic fantasy',
|
||||
'sword and sorcery', 'high fantasy', 'historical romance',
|
||||
'regency romance', 'western', 'sports', 'celebrity',
|
||||
'entertainment', 'pop culture', 'reality tv', 'social media',
|
||||
'true crime', 'celebrity biography', 'gossip', 'lifestyle',
|
||||
'fashion', 'beauty', 'cooking', 'travel', 'humor',
|
||||
'comedy', 'satire', 'graphic novel', 'manga', 'comic'
|
||||
];
|
||||
|
||||
const mainstreamBooks = books.filter(book => {
|
||||
if (!book.metadata?.categories) return false;
|
||||
return book.metadata.categories.some(cat =>
|
||||
mainstreamGenres.some(genre => cat.toLowerCase().includes(genre))
|
||||
);
|
||||
});
|
||||
|
||||
const popularRate = popularBooks.length / books.length;
|
||||
const mainstreamRate = mainstreamBooks.length / books.length;
|
||||
|
||||
return Math.min(100, (popularRate * 50) + (mainstreamRate * 50));
|
||||
}
|
||||
|
||||
private calculateNostalgicScore(books: Book[]): number {
|
||||
const currentYear = new Date().getFullYear();
|
||||
const classicThreshold = currentYear - 30; // Books older than 30 years
|
||||
|
||||
const oldBooks = books.filter(book => {
|
||||
if (!book.metadata?.publishedDate) return false;
|
||||
const pubYear = new Date(book.metadata.publishedDate).getFullYear();
|
||||
return pubYear < classicThreshold;
|
||||
});
|
||||
|
||||
const classicGenres = [
|
||||
'classic', 'literature', 'historical', 'vintage', 'traditional',
|
||||
'heritage', 'timeless', 'canonical', 'masterpiece', 'landmark',
|
||||
'seminal', 'influential', 'groundbreaking', 'pioneering',
|
||||
'classical literature', 'world literature', 'nobel prize',
|
||||
'pulitzer prize', 'booker prize', 'national book award',
|
||||
'literary fiction', 'modernist', 'post-modernist', 'realist',
|
||||
'naturalist', 'romantic', 'victorian', 'edwardian',
|
||||
'renaissance', 'enlightenment', 'ancient', 'medieval',
|
||||
'colonial', 'antebellum', 'gilded age', 'jazz age',
|
||||
'lost generation', 'beat generation', 'harlem renaissance',
|
||||
'golden age', 'silver age', 'folk tales', 'fairy tales',
|
||||
'mythology', 'legends', 'folklore', 'oral tradition',
|
||||
'epic poetry', 'sonnets', 'ballads', 'odes',
|
||||
'dramatic works', 'shakespearean', 'greek tragedy',
|
||||
'roman literature', 'biblical', 'religious classics',
|
||||
'philosophical classics', 'historical classics'
|
||||
];
|
||||
|
||||
const oldBookRate = oldBooks.length / books.length;
|
||||
const classicRate = classicGenres.length / books.length;
|
||||
|
||||
return Math.min(100, (oldBookRate * 60) + (classicRate * 40));
|
||||
}
|
||||
|
||||
private calculateAmbitiousScore(books: Book[]): number {
|
||||
// Volume + challenging material + completion of difficult books
|
||||
const totalBooks = books.length;
|
||||
const volumeScore = Math.min(40, totalBooks * 2); // Max 40 for 20+ books
|
||||
|
||||
const challengingBooks = books.filter(book =>
|
||||
book.metadata?.pageCount && book.metadata.pageCount > 600
|
||||
);
|
||||
|
||||
const completedChallenging = challengingBooks.filter(book =>
|
||||
book.readStatus === ReadStatus.READ
|
||||
);
|
||||
|
||||
const challengingRate = challengingBooks.length / books.length;
|
||||
const completionRate = challengingBooks.length > 0 ?
|
||||
completedChallenging.length / challengingBooks.length : 0;
|
||||
|
||||
const challengingScore = challengingRate * 35; // Max 35
|
||||
const completionBonus = completionRate * 25; // Max 25
|
||||
|
||||
return Math.min(100, volumeScore + challengingScore + completionBonus);
|
||||
}
|
||||
|
||||
private getDefaultProfile(): ReadingDNAProfile {
|
||||
return {
|
||||
adventurous: 50,
|
||||
perfectionist: 50,
|
||||
intellectual: 50,
|
||||
emotional: 50,
|
||||
patient: 50,
|
||||
social: 50,
|
||||
nostalgic: 50,
|
||||
ambitious: 50
|
||||
};
|
||||
}
|
||||
|
||||
private convertToPersonalityInsights(profile: ReadingDNAProfile): PersonalityInsight[] {
|
||||
return [
|
||||
{
|
||||
trait: 'Adventurous',
|
||||
score: profile.adventurous,
|
||||
description: 'You explore diverse genres and experimental content',
|
||||
color: '#ff6b9d'
|
||||
},
|
||||
{
|
||||
trait: 'Perfectionist',
|
||||
score: profile.perfectionist,
|
||||
description: 'You prefer high-quality books and finish what you start',
|
||||
color: '#45aaf2'
|
||||
},
|
||||
{
|
||||
trait: 'Intellectual',
|
||||
score: profile.intellectual,
|
||||
description: 'You gravitate toward complex, educational material',
|
||||
color: '#96f7d2'
|
||||
},
|
||||
{
|
||||
trait: 'Emotional',
|
||||
score: profile.emotional,
|
||||
description: 'You connect emotionally with fiction and personal stories',
|
||||
color: '#feca57'
|
||||
},
|
||||
{
|
||||
trait: 'Patient',
|
||||
score: profile.patient,
|
||||
description: 'You tackle long books and complete series',
|
||||
color: '#ff9ff3'
|
||||
},
|
||||
{
|
||||
trait: 'Social',
|
||||
score: profile.social,
|
||||
description: 'You enjoy popular, widely-discussed books',
|
||||
color: '#54a0ff'
|
||||
},
|
||||
{
|
||||
trait: 'Nostalgic',
|
||||
score: profile.nostalgic,
|
||||
description: 'You appreciate classic literature and older works',
|
||||
color: '#5f27cd'
|
||||
},
|
||||
{
|
||||
trait: 'Ambitious',
|
||||
score: profile.ambitious,
|
||||
description: 'You challenge yourself with volume and difficulty',
|
||||
color: '#00d2d3'
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
public getPersonalityInsights(): PersonalityInsight[] {
|
||||
return this.lastCalculatedInsights;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,611 @@
|
||||
import {inject, Injectable, OnDestroy} from '@angular/core';
|
||||
import {BehaviorSubject, EMPTY, Observable, Subject, of} from 'rxjs';
|
||||
import {map, takeUntil, catchError, filter, first, switchMap} from 'rxjs/operators';
|
||||
import {ChartConfiguration, ChartData} from 'chart.js';
|
||||
|
||||
import {LibraryFilterService} from './library-filter.service';
|
||||
import {BookService} from '../../book/service/book.service';
|
||||
import {Book, ReadStatus} from '../../book/model/book.model';
|
||||
|
||||
interface ReadingHabitsProfile {
|
||||
consistency: number; // Regular reading patterns vs sporadic
|
||||
multitasking: number; // Multiple books at once
|
||||
completionism: number; // Finishing vs abandoning books
|
||||
exploration: number; // Trying new vs sticking to familiar
|
||||
organization: number; // Series order, metadata attention
|
||||
intensity: number; // Reading session length preferences
|
||||
methodology: number; // Systematic vs random book selection
|
||||
momentum: number; // Reading streaks and continuity
|
||||
}
|
||||
|
||||
interface HabitInsight {
|
||||
habit: string;
|
||||
score: number;
|
||||
description: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
type ReadingHabitsChartData = ChartData<'radar', number[], string>;
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class ReadingHabitsChartService implements OnDestroy {
|
||||
private readonly bookService = inject(BookService);
|
||||
private readonly libraryFilterService = inject(LibraryFilterService);
|
||||
private readonly destroy$ = new Subject<void>();
|
||||
|
||||
public readonly readingHabitsChartType = 'radar' as const;
|
||||
|
||||
public readonly readingHabitsChartOptions: ChartConfiguration<'radar'>['options'] = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
scales: {
|
||||
r: {
|
||||
beginAtZero: true,
|
||||
min: 0,
|
||||
max: 100,
|
||||
ticks: {
|
||||
stepSize: 20,
|
||||
color: 'rgba(255, 255, 255, 0.6)',
|
||||
font: {
|
||||
family: "'Inter', sans-serif",
|
||||
size: 12
|
||||
},
|
||||
backdropColor: 'transparent',
|
||||
showLabelBackdrop: false
|
||||
},
|
||||
grid: {
|
||||
color: 'rgba(255, 255, 255, 0.2)',
|
||||
circular: true
|
||||
},
|
||||
angleLines: {
|
||||
color: 'rgba(255, 255, 255, 0.3)'
|
||||
},
|
||||
pointLabels: {
|
||||
color: '#ffffff',
|
||||
font: {
|
||||
family: "'Inter', sans-serif",
|
||||
size: 12
|
||||
},
|
||||
padding: 25,
|
||||
callback: function (label: string) {
|
||||
const icons: Record<string, string> = {
|
||||
'Consistency': '📅',
|
||||
'Multitasking': '📚',
|
||||
'Completionism': '✅',
|
||||
'Exploration': '🔍',
|
||||
'Organization': '📋',
|
||||
'Intensity': '⚡',
|
||||
'Methodology': '🎯',
|
||||
'Momentum': '🔥'
|
||||
};
|
||||
return [icons[label] || '', label];
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false
|
||||
},
|
||||
tooltip: {
|
||||
enabled: true,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.95)',
|
||||
titleColor: '#ffffff',
|
||||
bodyColor: '#ffffff',
|
||||
borderColor: '#9c27b0',
|
||||
borderWidth: 2,
|
||||
cornerRadius: 8,
|
||||
padding: 16,
|
||||
titleFont: {size: 14, weight: 'bold'},
|
||||
bodyFont: {size: 12},
|
||||
callbacks: {
|
||||
title: (context) => {
|
||||
const label = context[0]?.label || '';
|
||||
return `${label} Habit`;
|
||||
},
|
||||
label: (context) => {
|
||||
const score = context.parsed.r;
|
||||
const insights = this.getHabitInsights();
|
||||
const insight = insights.find(i => i.habit === context.label);
|
||||
|
||||
return [
|
||||
`Score: ${score.toFixed(1)}/100`,
|
||||
'',
|
||||
insight ? insight.description : 'Your reading habit pattern'
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
interaction: {
|
||||
intersect: false,
|
||||
mode: 'point'
|
||||
},
|
||||
elements: {
|
||||
line: {
|
||||
borderWidth: 3,
|
||||
tension: 0.1
|
||||
},
|
||||
point: {
|
||||
radius: 5,
|
||||
hoverRadius: 8,
|
||||
borderWidth: 3,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.8)'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
private readonly readingHabitsChartDataSubject = new BehaviorSubject<ReadingHabitsChartData>({
|
||||
labels: [
|
||||
'Consistency', 'Multitasking', 'Completionism', 'Exploration',
|
||||
'Organization', 'Intensity', 'Methodology', 'Momentum'
|
||||
],
|
||||
datasets: []
|
||||
});
|
||||
|
||||
public readonly readingHabitsChartData$: Observable<ReadingHabitsChartData> = this.readingHabitsChartDataSubject.asObservable();
|
||||
private lastCalculatedInsights: HabitInsight[] = [];
|
||||
|
||||
constructor() {
|
||||
this.bookService.bookState$
|
||||
.pipe(
|
||||
filter(state => state.loaded),
|
||||
first(),
|
||||
switchMap(() =>
|
||||
this.libraryFilterService.selectedLibrary$.pipe(
|
||||
takeUntil(this.destroy$)
|
||||
)
|
||||
),
|
||||
catchError((error) => {
|
||||
console.error('Error processing reading habits data:', error);
|
||||
return EMPTY;
|
||||
})
|
||||
)
|
||||
.subscribe(() => {
|
||||
const profile = this.calculateReadingHabitsData();
|
||||
this.updateChartData(profile);
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
}
|
||||
|
||||
private updateChartData(profile: ReadingHabitsProfile | null): void {
|
||||
try {
|
||||
if (!profile) {
|
||||
this.readingHabitsChartDataSubject.next({
|
||||
labels: [],
|
||||
datasets: []
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const data = [
|
||||
profile.consistency,
|
||||
profile.multitasking,
|
||||
profile.completionism,
|
||||
profile.exploration,
|
||||
profile.organization,
|
||||
profile.intensity,
|
||||
profile.methodology,
|
||||
profile.momentum
|
||||
];
|
||||
|
||||
// Purple gradient colors for habits theme
|
||||
const habitColors = [
|
||||
'#9c27b0', '#e91e63', '#ff5722', '#ff9800',
|
||||
'#ffc107', '#4caf50', '#2196f3', '#673ab7'
|
||||
];
|
||||
|
||||
this.readingHabitsChartDataSubject.next({
|
||||
labels: [
|
||||
'Consistency', 'Multitasking', 'Completionism', 'Exploration',
|
||||
'Organization', 'Intensity', 'Methodology', 'Momentum'
|
||||
],
|
||||
datasets: [{
|
||||
label: 'Reading Habits Profile',
|
||||
data,
|
||||
backgroundColor: 'rgba(156, 39, 176, 0.2)',
|
||||
borderColor: '#9c27b0',
|
||||
borderWidth: 3,
|
||||
pointBackgroundColor: habitColors,
|
||||
pointBorderColor: '#ffffff',
|
||||
pointBorderWidth: 3,
|
||||
pointRadius: 5,
|
||||
pointHoverRadius: 8,
|
||||
fill: true
|
||||
}]
|
||||
});
|
||||
|
||||
// Store insights for tooltip
|
||||
this.lastCalculatedInsights = this.convertToHabitInsights(profile);
|
||||
} catch (error) {
|
||||
console.error('Error updating reading habits chart data:', error);
|
||||
}
|
||||
}
|
||||
|
||||
private calculateReadingHabitsData(): ReadingHabitsProfile | null {
|
||||
const currentState = this.bookService.getCurrentBookState();
|
||||
const selectedLibraryId = this.libraryFilterService.getCurrentSelectedLibrary();
|
||||
|
||||
if (!this.isValidBookState(currentState)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const filteredBooks = this.filterBooksByLibrary(currentState.books!, String(selectedLibraryId));
|
||||
return this.analyzeReadingHabits(filteredBooks);
|
||||
}
|
||||
|
||||
private isValidBookState(state: any): boolean {
|
||||
return state?.loaded && state?.books && Array.isArray(state.books) && state.books.length > 0;
|
||||
}
|
||||
|
||||
private filterBooksByLibrary(books: Book[], selectedLibraryId: string | null): Book[] {
|
||||
return selectedLibraryId && selectedLibraryId !== 'null'
|
||||
? books.filter(book => String(book.libraryId) === selectedLibraryId)
|
||||
: books;
|
||||
}
|
||||
|
||||
private analyzeReadingHabits(books: Book[]): ReadingHabitsProfile {
|
||||
if (books.length === 0) {
|
||||
return this.getDefaultProfile();
|
||||
}
|
||||
|
||||
return {
|
||||
consistency: this.calculateConsistencyScore(books),
|
||||
multitasking: this.calculateMultitaskingScore(books),
|
||||
completionism: this.calculateCompletionismScore(books),
|
||||
exploration: this.calculateExplorationScore(books),
|
||||
organization: this.calculateOrganizationScore(books),
|
||||
intensity: this.calculateIntensityScore(books),
|
||||
methodology: this.calculateMethodologyScore(books),
|
||||
momentum: this.calculateMomentumScore(books)
|
||||
};
|
||||
}
|
||||
|
||||
private calculateConsistencyScore(books: Book[]): number {
|
||||
// Look for consistent reading patterns through completion dates and progress
|
||||
const booksWithDates = books.filter(book => book.dateFinished || book.addedOn);
|
||||
if (booksWithDates.length === 0) return 30;
|
||||
|
||||
// Books completed in sequence suggest consistent reading habits
|
||||
const completedBooks = books.filter(book => book.readStatus === ReadStatus.READ && book.dateFinished);
|
||||
if (completedBooks.length < 2) return 25;
|
||||
|
||||
// Calculate time intervals between completed books
|
||||
const sortedByCompletion = completedBooks
|
||||
.sort((a, b) => new Date(a.dateFinished!).getTime() - new Date(b.dateFinished!).getTime());
|
||||
|
||||
let consistencyScore = 50;
|
||||
|
||||
// Books in progress suggest active reading habit
|
||||
const inProgress = books.filter(book =>
|
||||
book.readStatus === ReadStatus.READING || book.readStatus === ReadStatus.RE_READING
|
||||
);
|
||||
const progressRate = inProgress.length / books.length;
|
||||
consistencyScore += progressRate * 30;
|
||||
|
||||
// Regular completion pattern
|
||||
if (sortedByCompletion.length >= 3) {
|
||||
consistencyScore += 20;
|
||||
}
|
||||
|
||||
return Math.min(100, consistencyScore);
|
||||
}
|
||||
|
||||
private calculateMultitaskingScore(books: Book[]): number {
|
||||
// Multiple books in reading status indicates multitasking
|
||||
const currentlyReading = books.filter(book => book.readStatus === ReadStatus.READING);
|
||||
const reReading = books.filter(book => book.readStatus === ReadStatus.RE_READING);
|
||||
const activeBooks = currentlyReading.length + reReading.length;
|
||||
|
||||
// Books with partial progress suggest juggling multiple reads
|
||||
const booksWithProgress = books.filter(book => {
|
||||
const progress = Math.max(
|
||||
book.epubProgress?.percentage || 0,
|
||||
book.pdfProgress?.percentage || 0,
|
||||
book.cbxProgress?.percentage || 0,
|
||||
book.koreaderProgress?.percentage || 0
|
||||
);
|
||||
return progress > 0 && progress < 100;
|
||||
});
|
||||
|
||||
const multitaskingScore = Math.min(60, activeBooks * 15); // Max 60 for 4+ concurrent books
|
||||
const progressScore = Math.min(40, (booksWithProgress.length / books.length) * 80); // Max 40
|
||||
|
||||
return Math.min(100, multitaskingScore + progressScore);
|
||||
}
|
||||
|
||||
private calculateCompletionismScore(books: Book[]): number {
|
||||
// High completion rate vs abandonment
|
||||
const completed = books.filter(book => book.readStatus === ReadStatus.READ);
|
||||
const abandoned = books.filter(book => book.readStatus === ReadStatus.ABANDONED);
|
||||
const unfinished = books.filter(book => book.readStatus === ReadStatus.UNREAD || book.readStatus === ReadStatus.UNSET);
|
||||
|
||||
const completionRate = completed.length / (books.length - unfinished.length);
|
||||
const abandonmentRate = abandoned.length / books.length;
|
||||
|
||||
const completionScore = completionRate * 70; // Max 70
|
||||
const abandonmentPenalty = abandonmentRate * 30; // Max 30 penalty
|
||||
|
||||
return Math.max(0, Math.min(100, completionScore - abandonmentPenalty + 30));
|
||||
}
|
||||
|
||||
private calculateExplorationScore(books: Book[]): number {
|
||||
// Trying new authors vs sticking to favorites
|
||||
const authors = new Set<string>();
|
||||
const authorCounts = new Map<string, number>();
|
||||
|
||||
books.forEach(book => {
|
||||
book.metadata?.authors?.forEach(author => {
|
||||
const authorName = author.toLowerCase();
|
||||
authors.add(authorName);
|
||||
authorCounts.set(authorName, (authorCounts.get(authorName) || 0) + 1);
|
||||
});
|
||||
});
|
||||
|
||||
// Calculate author diversity
|
||||
const authorDiversityScore = Math.min(50, authors.size * 2); // Max 50
|
||||
|
||||
// Penalty for too many books by same author (indicates less exploration)
|
||||
const maxBooksPerAuthor = Math.max(...Array.from(authorCounts.values()));
|
||||
const concentrationPenalty = Math.max(0, (maxBooksPerAuthor - 3) * 5); // Penalty after 3 books per author
|
||||
|
||||
// Different publication years indicate temporal exploration
|
||||
const years = new Set<number>();
|
||||
books.forEach(book => {
|
||||
if (book.metadata?.publishedDate) {
|
||||
years.add(new Date(book.metadata.publishedDate).getFullYear());
|
||||
}
|
||||
});
|
||||
const temporalScore = Math.min(30, years.size * 2); // Max 30
|
||||
|
||||
// Different languages indicate linguistic exploration
|
||||
const languages = new Set<string>();
|
||||
books.forEach(book => {
|
||||
if (book.metadata?.language) languages.add(book.metadata.language);
|
||||
});
|
||||
const languageScore = Math.min(20, (languages.size - 1) * 10); // Max 20
|
||||
|
||||
return Math.max(10, Math.min(100, authorDiversityScore + temporalScore + languageScore - concentrationPenalty));
|
||||
}
|
||||
|
||||
private calculateOrganizationScore(books: Book[]): number {
|
||||
// Series reading in order, metadata completion
|
||||
const seriesBooks = books.filter(book => book.metadata?.seriesName && book.metadata?.seriesNumber);
|
||||
const seriesScore = (seriesBooks.length / books.length) * 40; // Max 40
|
||||
|
||||
// Books with comprehensive metadata suggest organized approach
|
||||
const wellOrganizedBooks = books.filter(book => {
|
||||
const metadata = book.metadata;
|
||||
if (!metadata) return false;
|
||||
|
||||
const hasBasicInfo = metadata.title && metadata.authors && metadata.authors.length > 0;
|
||||
const hasDetailedInfo = metadata.publishedDate || metadata.publisher || metadata.isbn10;
|
||||
const hasCategories = metadata.categories && metadata.categories.length > 0;
|
||||
|
||||
return hasBasicInfo && (hasDetailedInfo || hasCategories);
|
||||
});
|
||||
|
||||
const metadataScore = (wellOrganizedBooks.length / books.length) * 35; // Max 35
|
||||
|
||||
// Personal ratings suggest systematic tracking
|
||||
const ratedBooks = books.filter(book => book.metadata?.personalRating);
|
||||
const ratingScore = (ratedBooks.length / books.length) * 25; // Max 25
|
||||
|
||||
return Math.min(100, seriesScore + metadataScore + ratingScore);
|
||||
}
|
||||
|
||||
private calculateIntensityScore(books: Book[]): number {
|
||||
// Preference for longer books suggests intensive reading sessions
|
||||
const booksWithPages = books.filter(book => book.metadata?.pageCount && book.metadata.pageCount > 0);
|
||||
if (booksWithPages.length === 0) return 40;
|
||||
|
||||
const averagePages = booksWithPages.reduce((sum, book) => sum + (book.metadata?.pageCount || 0), 0) / booksWithPages.length;
|
||||
const intensityFromLength = Math.min(50, averagePages / 8); // Max 50 for 400+ page average
|
||||
|
||||
// High progress percentages on multiple books suggest intensive reading
|
||||
const highProgressBooks = books.filter(book => {
|
||||
const progress = Math.max(
|
||||
book.epubProgress?.percentage || 0,
|
||||
book.pdfProgress?.percentage || 0,
|
||||
book.cbxProgress?.percentage || 0,
|
||||
book.koreaderProgress?.percentage || 0
|
||||
);
|
||||
return progress > 75;
|
||||
});
|
||||
|
||||
const progressScore = (highProgressBooks.length / books.length) * 30; // Max 30
|
||||
|
||||
// Series completion suggests sustained intensive reading
|
||||
const completedSeriesBooks = books.filter(book =>
|
||||
book.metadata?.seriesName && book.readStatus === ReadStatus.READ
|
||||
);
|
||||
const seriesIntensityScore = (completedSeriesBooks.length / books.length) * 20; // Max 20
|
||||
|
||||
return Math.min(100, intensityFromLength + progressScore + seriesIntensityScore);
|
||||
}
|
||||
|
||||
private calculateMethodologyScore(books: Book[]): number {
|
||||
// Series reading in order vs random selection
|
||||
const seriesBooks = books.filter(book => book.metadata?.seriesName);
|
||||
const seriesGroups = new Map<string, Book[]>();
|
||||
|
||||
seriesBooks.forEach(book => {
|
||||
const seriesName = book.metadata!.seriesName!.toLowerCase();
|
||||
if (!seriesGroups.has(seriesName)) {
|
||||
seriesGroups.set(seriesName, []);
|
||||
}
|
||||
seriesGroups.get(seriesName)!.push(book);
|
||||
});
|
||||
|
||||
let systematicSeriesScore = 0;
|
||||
seriesGroups.forEach(books => {
|
||||
if (books.length > 1) {
|
||||
const orderedBooks = books.filter(book => book.metadata?.seriesNumber).sort((a, b) =>
|
||||
(a.metadata?.seriesNumber || 0) - (b.metadata?.seriesNumber || 0)
|
||||
);
|
||||
if (orderedBooks.length >= 2) {
|
||||
systematicSeriesScore += 20;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Author exploration in systematic way (multiple books per favored author)
|
||||
const authorBooks = new Map<string, Book[]>();
|
||||
books.forEach(book => {
|
||||
book.metadata?.authors?.forEach(author => {
|
||||
const authorName = author.toLowerCase();
|
||||
if (!authorBooks.has(authorName)) {
|
||||
authorBooks.set(authorName, []);
|
||||
}
|
||||
authorBooks.get(authorName)!.push(book);
|
||||
});
|
||||
});
|
||||
|
||||
const systematicAuthors = Array.from(authorBooks.values()).filter(books => books.length >= 2).length;
|
||||
const authorMethodologyScore = Math.min(30, systematicAuthors * 5); // Max 30
|
||||
|
||||
// Genre consistency suggests methodical selection
|
||||
const categoryBooks = new Map<string, number>();
|
||||
books.forEach(book => {
|
||||
book.metadata?.categories?.forEach(category => {
|
||||
const cat = category.toLowerCase();
|
||||
categoryBooks.set(cat, (categoryBooks.get(cat) || 0) + 1);
|
||||
});
|
||||
});
|
||||
|
||||
const majorCategories = Array.from(categoryBooks.values()).filter(count => count >= 3).length;
|
||||
const categoryMethodologyScore = Math.min(25, majorCategories * 8); // Max 25
|
||||
|
||||
const baseMethodologyScore = books.length >= 10 ? 15 : Math.max(5, books.length); // Base score
|
||||
|
||||
return Math.min(100, systematicSeriesScore + authorMethodologyScore + categoryMethodologyScore + baseMethodologyScore);
|
||||
}
|
||||
|
||||
private calculateMomentumScore(books: Book[]): number {
|
||||
// Reading streaks and continuity
|
||||
const completedBooks = books.filter(book => book.readStatus === ReadStatus.READ && book.dateFinished);
|
||||
|
||||
if (completedBooks.length === 0) {
|
||||
// Score based on current activity
|
||||
const activeBooks = books.filter(book =>
|
||||
book.readStatus === ReadStatus.READING || book.readStatus === ReadStatus.RE_READING
|
||||
);
|
||||
return Math.min(40, activeBooks.length * 10);
|
||||
}
|
||||
|
||||
// Sort by completion date
|
||||
const sortedBooks = completedBooks.sort((a, b) =>
|
||||
new Date(a.dateFinished!).getTime() - new Date(b.dateFinished!).getTime()
|
||||
);
|
||||
|
||||
let momentumScore = 20; // Base score
|
||||
|
||||
// Recent completion activity (last 6 months)
|
||||
const sixMonthsAgo = new Date();
|
||||
sixMonthsAgo.setMonth(sixMonthsAgo.getMonth() - 6);
|
||||
|
||||
const recentCompletions = sortedBooks.filter(book =>
|
||||
new Date(book.dateFinished!) > sixMonthsAgo
|
||||
);
|
||||
|
||||
momentumScore += Math.min(40, recentCompletions.length * 5); // Max 40
|
||||
|
||||
// Currently reading books add to momentum
|
||||
const currentlyReading = books.filter(book =>
|
||||
book.readStatus === ReadStatus.READING || book.readStatus === ReadStatus.RE_READING
|
||||
);
|
||||
|
||||
momentumScore += Math.min(25, currentlyReading.length * 8); // Max 25
|
||||
|
||||
// High progress books indicate active momentum
|
||||
const highProgressBooks = books.filter(book => {
|
||||
const progress = Math.max(
|
||||
book.epubProgress?.percentage || 0,
|
||||
book.pdfProgress?.percentage || 0,
|
||||
book.cbxProgress?.percentage || 0,
|
||||
book.koreaderProgress?.percentage || 0
|
||||
);
|
||||
return progress > 50 && progress < 100;
|
||||
});
|
||||
|
||||
momentumScore += Math.min(15, highProgressBooks.length * 3); // Max 15
|
||||
|
||||
return Math.min(100, momentumScore);
|
||||
}
|
||||
|
||||
private getDefaultProfile(): ReadingHabitsProfile {
|
||||
return {
|
||||
consistency: 40,
|
||||
multitasking: 30,
|
||||
completionism: 50,
|
||||
exploration: 45,
|
||||
organization: 35,
|
||||
intensity: 40,
|
||||
methodology: 35,
|
||||
momentum: 30
|
||||
};
|
||||
}
|
||||
|
||||
private convertToHabitInsights(profile: ReadingHabitsProfile): HabitInsight[] {
|
||||
return [
|
||||
{
|
||||
habit: 'Consistency',
|
||||
score: profile.consistency,
|
||||
description: 'You maintain regular reading patterns and schedules',
|
||||
color: '#9c27b0'
|
||||
},
|
||||
{
|
||||
habit: 'Multitasking',
|
||||
score: profile.multitasking,
|
||||
description: 'You juggle multiple books simultaneously',
|
||||
color: '#e91e63'
|
||||
},
|
||||
{
|
||||
habit: 'Completionism',
|
||||
score: profile.completionism,
|
||||
description: 'You finish books rather than abandon them',
|
||||
color: '#ff5722'
|
||||
},
|
||||
{
|
||||
habit: 'Exploration',
|
||||
score: profile.exploration,
|
||||
description: 'You actively seek out new authors and genres',
|
||||
color: '#ff9800'
|
||||
},
|
||||
{
|
||||
habit: 'Organization',
|
||||
score: profile.organization,
|
||||
description: 'You maintain systematic book tracking and metadata',
|
||||
color: '#ffc107'
|
||||
},
|
||||
{
|
||||
habit: 'Intensity',
|
||||
score: profile.intensity,
|
||||
description: 'You prefer longer, immersive reading sessions',
|
||||
color: '#4caf50'
|
||||
},
|
||||
{
|
||||
habit: 'Methodology',
|
||||
score: profile.methodology,
|
||||
description: 'You follow systematic approaches to book selection',
|
||||
color: '#2196f3'
|
||||
},
|
||||
{
|
||||
habit: 'Momentum',
|
||||
score: profile.momentum,
|
||||
description: 'You maintain active reading streaks and continuity',
|
||||
color: '#673ab7'
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
public getHabitInsights(): HabitInsight[] {
|
||||
return this.lastCalculatedInsights;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
import {inject, Injectable, OnDestroy} from '@angular/core';
|
||||
import {BehaviorSubject, EMPTY, Observable, Subject} from 'rxjs';
|
||||
import {map, takeUntil, catchError, filter, first, switchMap} from 'rxjs/operators';
|
||||
import {ChartConfiguration, ChartData, ChartType} from 'chart.js';
|
||||
|
||||
import {LibraryFilterService} from './library-filter.service';
|
||||
import {BookService} from '../../book/service/book.service';
|
||||
import {Book} from '../../book/model/book.model';
|
||||
|
||||
interface ReadingProgressStats {
|
||||
progressRange: string;
|
||||
count: number;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const CHART_COLORS = [
|
||||
'#6c757d', '#ffc107', '#fd7e14', '#17a2b8', '#6f42c1', '#28a745'
|
||||
] as const;
|
||||
|
||||
const CHART_DEFAULTS = {
|
||||
borderColor: '#ffffff',
|
||||
borderWidth: 1,
|
||||
hoverBorderWidth: 2,
|
||||
hoverBorderColor: '#ffffff'
|
||||
} as const;
|
||||
|
||||
const PROGRESS_RANGES = [
|
||||
{range: '0%', min: 0, max: 0, desc: 'Not Started'},
|
||||
{range: '1-25%', min: 0.1, max: 25, desc: 'Just Started'},
|
||||
{range: '26-50%', min: 26, max: 50, desc: 'Getting Into It'},
|
||||
{range: '51-75%', min: 51, max: 75, desc: 'Halfway Through'},
|
||||
{range: '76-99%', min: 76, max: 99, desc: 'Almost Finished'},
|
||||
{range: '100%', min: 100, max: 100, desc: 'Completed'}
|
||||
] as const;
|
||||
|
||||
type ProgressChartData = ChartData<'bar', number[], string>;
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class ReadingProgressChartService implements OnDestroy {
|
||||
private readonly bookService = inject(BookService);
|
||||
private readonly libraryFilterService = inject(LibraryFilterService);
|
||||
private readonly destroy$ = new Subject<void>();
|
||||
|
||||
public readonly progressChartType: ChartType = 'bar';
|
||||
|
||||
public readonly progressChartOptions: ChartConfiguration['options'] = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {display: false},
|
||||
tooltip: {
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.8)',
|
||||
titleColor: '#ffffff',
|
||||
bodyColor: '#ffffff',
|
||||
borderColor: '#666666',
|
||||
borderWidth: 1,
|
||||
callbacks: {
|
||||
title: (context) => context[0].label,
|
||||
label: this.formatTooltipLabel
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
ticks: {
|
||||
color: '#ffffff',
|
||||
font: {
|
||||
family: "'Inter', sans-serif",
|
||||
size: 11
|
||||
}
|
||||
},
|
||||
grid: {color: 'rgba(255, 255, 255, 0.1)'},
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Progress Range',
|
||||
color: '#ffffff',
|
||||
font: {
|
||||
family: "'Inter', sans-serif",
|
||||
size: 11.5
|
||||
}
|
||||
}
|
||||
},
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
ticks: {
|
||||
color: '#ffffff', font: {
|
||||
family: "'Inter', sans-serif",
|
||||
size: 11
|
||||
}, stepSize: 1
|
||||
},
|
||||
grid: {color: 'rgba(255, 255, 255, 0.05)'},
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Number of Books',
|
||||
color: '#ffffff',
|
||||
font: {
|
||||
family: "'Inter', sans-serif",
|
||||
size: 11.5
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
private readonly progressChartDataSubject = new BehaviorSubject<ProgressChartData>({
|
||||
labels: [],
|
||||
datasets: [{
|
||||
label: 'Books by Progress',
|
||||
data: [],
|
||||
backgroundColor: [...CHART_COLORS],
|
||||
...CHART_DEFAULTS
|
||||
}]
|
||||
});
|
||||
|
||||
public readonly progressChartData$: Observable<ProgressChartData> =
|
||||
this.progressChartDataSubject.asObservable();
|
||||
|
||||
constructor() {
|
||||
this.bookService.bookState$
|
||||
.pipe(
|
||||
filter(state => state.loaded),
|
||||
first(),
|
||||
switchMap(() =>
|
||||
this.libraryFilterService.selectedLibrary$.pipe(
|
||||
takeUntil(this.destroy$)
|
||||
)
|
||||
),
|
||||
catchError((error) => {
|
||||
console.error('Error processing reading progress stats:', error);
|
||||
return EMPTY;
|
||||
})
|
||||
)
|
||||
.subscribe(() => {
|
||||
const stats = this.calculateReadingProgressStats();
|
||||
this.updateChartData(stats);
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
}
|
||||
|
||||
private updateChartData(stats: ReadingProgressStats[]): void {
|
||||
try {
|
||||
const labels = stats.map(s => s.progressRange);
|
||||
const dataValues = stats.map(s => s.count);
|
||||
|
||||
this.progressChartDataSubject.next({
|
||||
labels,
|
||||
datasets: [{
|
||||
label: 'Books by Progress',
|
||||
data: dataValues,
|
||||
backgroundColor: [...CHART_COLORS],
|
||||
...CHART_DEFAULTS
|
||||
}]
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error updating chart data:', error);
|
||||
}
|
||||
}
|
||||
|
||||
private calculateReadingProgressStats(): ReadingProgressStats[] {
|
||||
const currentState = this.bookService.getCurrentBookState();
|
||||
const selectedLibraryId = this.libraryFilterService.getCurrentSelectedLibrary();
|
||||
|
||||
if (!this.isValidBookState(currentState)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const filteredBooks = this.filterBooksByLibrary(currentState.books!, String(selectedLibraryId));
|
||||
return this.processReadingProgressStats(filteredBooks);
|
||||
}
|
||||
|
||||
public updateFromStats(stats: ReadingProgressStats[]): void {
|
||||
this.updateChartData(stats);
|
||||
}
|
||||
|
||||
private isValidBookState(state: any): boolean {
|
||||
return state?.loaded && state?.books && Array.isArray(state.books) && state.books.length > 0;
|
||||
}
|
||||
|
||||
private filterBooksByLibrary(books: Book[], selectedLibraryId: string | null): Book[] {
|
||||
return selectedLibraryId && selectedLibraryId !== 'null'
|
||||
? books.filter(book => String(book.libraryId) === selectedLibraryId)
|
||||
: books;
|
||||
}
|
||||
|
||||
private processReadingProgressStats(books: Book[]): ReadingProgressStats[] {
|
||||
if (books.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const rangeCounts = this.buildProgressMap(books);
|
||||
return this.convertMapToStats(rangeCounts);
|
||||
}
|
||||
|
||||
private buildProgressMap(books: Book[]): Map<string, number> {
|
||||
const rangeCounts = new Map<string, number>();
|
||||
PROGRESS_RANGES.forEach(range => rangeCounts.set(range.range, 0));
|
||||
|
||||
for (const book of books) {
|
||||
const progress = this.getBookProgress(book);
|
||||
|
||||
for (const range of PROGRESS_RANGES) {
|
||||
if (progress >= range.min && progress <= range.max) {
|
||||
rangeCounts.set(range.range, (rangeCounts.get(range.range) || 0) + 1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return rangeCounts;
|
||||
}
|
||||
|
||||
private convertMapToStats(rangeCounts: Map<string, number>): ReadingProgressStats[] {
|
||||
return PROGRESS_RANGES.map(range => ({
|
||||
progressRange: range.range,
|
||||
count: rangeCounts.get(range.range) || 0,
|
||||
description: range.desc
|
||||
}));
|
||||
}
|
||||
|
||||
private getBookProgress(book: Book): number {
|
||||
if (book.pdfProgress?.percentage) return book.pdfProgress.percentage;
|
||||
if (book.epubProgress?.percentage) return book.epubProgress.percentage;
|
||||
if (book.cbxProgress?.percentage) return book.cbxProgress.percentage;
|
||||
if (book.koreaderProgress?.percentage) return book.koreaderProgress.percentage;
|
||||
return 0;
|
||||
}
|
||||
|
||||
private formatTooltipLabel(context: any): string {
|
||||
const value = context.parsed.y;
|
||||
const label = context.label;
|
||||
const rangeInfo = PROGRESS_RANGES.find(r => r.range === label);
|
||||
const description = rangeInfo ? ` (${rangeInfo.desc})` : '';
|
||||
return `${value} book${value === 1 ? '' : 's'}${description}`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,379 @@
|
||||
import {inject, Injectable, OnDestroy} from '@angular/core';
|
||||
import {BehaviorSubject, combineLatest, Observable, Subject} from 'rxjs';
|
||||
import {map, takeUntil, catchError} from 'rxjs/operators';
|
||||
import {ChartConfiguration, ChartData} from 'chart.js';
|
||||
|
||||
import {LibraryFilterService} from './library-filter.service';
|
||||
import {BookService} from '../../book/service/book.service';
|
||||
import {Book, ReadStatus} from '../../book/model/book.model';
|
||||
|
||||
interface ReadingVelocityStats {
|
||||
category: string;
|
||||
count: number;
|
||||
averagePages: number;
|
||||
averageRating: number;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const CHART_COLORS = [
|
||||
'#ff6b9d', '#45aaf2', '#96f7d2', '#feca57', '#ff9ff3',
|
||||
'#54a0ff', '#5f27cd', '#00d2d3', '#ff9f43', '#10ac84',
|
||||
'#ee5a6f', '#60a3bc', '#130f40', '#30336b', '#535c68'
|
||||
] as const;
|
||||
|
||||
const CHART_DEFAULTS = {
|
||||
borderColor: '#ffffff',
|
||||
borderWidth: 2,
|
||||
hoverBorderWidth: 3,
|
||||
hoverBorderColor: '#ffffff'
|
||||
} as const;
|
||||
|
||||
type VelocityChartData = ChartData<'polarArea', number[], string>;
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class ReadingVelocityChartService implements OnDestroy {
|
||||
private readonly bookService = inject(BookService);
|
||||
private readonly libraryFilterService = inject(LibraryFilterService);
|
||||
private readonly destroy$ = new Subject<void>();
|
||||
|
||||
public readonly velocityChartType = 'polarArea' as const;
|
||||
|
||||
public readonly velocityChartOptions: ChartConfiguration<'polarArea'>['options'] = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
scales: {
|
||||
r: {
|
||||
beginAtZero: true,
|
||||
ticks: {
|
||||
color: '#ffffff',
|
||||
font: {size: 10},
|
||||
stepSize: 1,
|
||||
backdropColor: 'transparent'
|
||||
},
|
||||
grid: {
|
||||
color: 'rgba(255, 255, 255, 0.15)'
|
||||
},
|
||||
angleLines: {
|
||||
color: 'rgba(255, 255, 255, 0.15)'
|
||||
},
|
||||
pointLabels: {
|
||||
color: '#ffffff',
|
||||
font: {size: 10}
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: true,
|
||||
position: 'bottom',
|
||||
labels: {
|
||||
color: '#ffffff',
|
||||
font: {size: 10},
|
||||
padding: 10,
|
||||
usePointStyle: true,
|
||||
generateLabels: this.generateLegendLabels.bind(this)
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
enabled: true,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.9)',
|
||||
titleColor: '#ffffff',
|
||||
bodyColor: '#ffffff',
|
||||
borderColor: '#ffffff',
|
||||
borderWidth: 1,
|
||||
cornerRadius: 6,
|
||||
displayColors: true,
|
||||
padding: 12,
|
||||
titleFont: {size: 14, weight: 'bold'},
|
||||
bodyFont: {size: 12},
|
||||
position: 'nearest',
|
||||
callbacks: {
|
||||
title: (context) => context[0]?.label || '',
|
||||
label: this.formatTooltipLabel.bind(this)
|
||||
}
|
||||
}
|
||||
},
|
||||
interaction: {
|
||||
intersect: false,
|
||||
mode: 'point'
|
||||
}
|
||||
};
|
||||
|
||||
private readonly velocityChartDataSubject = new BehaviorSubject<VelocityChartData>({
|
||||
labels: [],
|
||||
datasets: [{
|
||||
data: [],
|
||||
backgroundColor: [...CHART_COLORS],
|
||||
...CHART_DEFAULTS
|
||||
}]
|
||||
});
|
||||
|
||||
public readonly velocityChartData$: Observable<VelocityChartData> = this.velocityChartDataSubject.asObservable();
|
||||
|
||||
constructor() {
|
||||
this.initializeChartDataSubscription();
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
}
|
||||
|
||||
private initializeChartDataSubscription(): void {
|
||||
this.getReadingVelocityStats()
|
||||
.pipe(
|
||||
takeUntil(this.destroy$),
|
||||
catchError((error) => {
|
||||
console.error('Error processing reading velocity stats:', error);
|
||||
return [];
|
||||
})
|
||||
)
|
||||
.subscribe((stats) => this.updateChartData(stats));
|
||||
}
|
||||
|
||||
private updateChartData(stats: ReadingVelocityStats[]): void {
|
||||
try {
|
||||
this.lastCalculatedStats = stats;
|
||||
const labels = stats.map(s => s.category);
|
||||
const dataValues = stats.map(s => s.count);
|
||||
const colors = this.getColorsForData(stats.length);
|
||||
|
||||
this.velocityChartDataSubject.next({
|
||||
labels,
|
||||
datasets: [{
|
||||
data: dataValues,
|
||||
backgroundColor: colors,
|
||||
...CHART_DEFAULTS
|
||||
}]
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error updating velocity chart data:', error);
|
||||
}
|
||||
}
|
||||
|
||||
private getColorsForData(dataLength: number): string[] {
|
||||
const colors = [...CHART_COLORS];
|
||||
while (colors.length < dataLength) {
|
||||
colors.push(...CHART_COLORS);
|
||||
}
|
||||
return colors.slice(0, dataLength);
|
||||
}
|
||||
|
||||
public getReadingVelocityStats(): Observable<ReadingVelocityStats[]> {
|
||||
return combineLatest([
|
||||
this.bookService.bookState$,
|
||||
this.libraryFilterService.selectedLibrary$
|
||||
]).pipe(
|
||||
map(([state, selectedLibraryId]) => {
|
||||
if (!this.isValidBookState(state)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const filteredBooks = this.filterBooksByLibrary(state.books!, String(selectedLibraryId));
|
||||
return this.processReadingVelocityStats(filteredBooks);
|
||||
}),
|
||||
catchError((error) => {
|
||||
console.error('Error getting reading velocity stats:', error);
|
||||
return [];
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private isValidBookState(state: any): boolean {
|
||||
return state?.loaded && state?.books && Array.isArray(state.books) && state.books.length > 0;
|
||||
}
|
||||
|
||||
private filterBooksByLibrary(books: Book[], selectedLibraryId: string | null): Book[] {
|
||||
return selectedLibraryId && selectedLibraryId !== 'null'
|
||||
? books.filter(book => String(book.libraryId) === selectedLibraryId)
|
||||
: books;
|
||||
}
|
||||
|
||||
private processReadingVelocityStats(books: Book[]): ReadingVelocityStats[] {
|
||||
if (books.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const velocityCategories = this.categorizeByReadingVelocity(books);
|
||||
return this.convertToVelocityStats(velocityCategories);
|
||||
}
|
||||
|
||||
private categorizeByReadingVelocity(books: Book[]): Map<string, Book[]> {
|
||||
const categories = new Map<string, Book[]>();
|
||||
|
||||
// Initialize categories
|
||||
categories.set('Speed Readers', []);
|
||||
categories.set('Consistent Readers', []);
|
||||
categories.set('Selective Readers', []);
|
||||
categories.set('Exploratory Readers', []);
|
||||
categories.set('Deep Readers', []);
|
||||
categories.set('Casual Readers', []);
|
||||
categories.set('Quality Seekers', []);
|
||||
|
||||
const readBooks = books.filter(book =>
|
||||
book.readStatus === ReadStatus.READ &&
|
||||
book.metadata?.pageCount &&
|
||||
book.metadata.pageCount > 0
|
||||
);
|
||||
|
||||
const completedBooks = readBooks.length;
|
||||
const totalBooks = books.length;
|
||||
const completionRate = completedBooks / totalBooks;
|
||||
|
||||
const averagePageCount = readBooks.reduce((sum, book) => sum + (book.metadata?.pageCount || 0), 0) / readBooks.length || 0;
|
||||
const averageRating = this.calculateAverageRating(readBooks);
|
||||
|
||||
for (const book of books) {
|
||||
const pageCount = book.metadata?.pageCount || 0;
|
||||
const hasHighRating = this.hasHighQualityRating(book);
|
||||
const isCompleted = book.readStatus === ReadStatus.READ;
|
||||
const isCurrentlyReading = book.readStatus === ReadStatus.READING;
|
||||
const progress = this.getReadingProgress(book);
|
||||
|
||||
// Speed Readers: High completion rate + prefer shorter books
|
||||
if (completionRate > 0.6 && pageCount > 0 && pageCount < averagePageCount * 0.8) {
|
||||
categories.get('Speed Readers')!.push(book);
|
||||
}
|
||||
// Deep Readers: Prefer longer, high-quality books
|
||||
else if (pageCount > averagePageCount * 1.5 && hasHighRating) {
|
||||
categories.get('Deep Readers')!.push(book);
|
||||
}
|
||||
// Quality Seekers: Focus on highly-rated books regardless of length
|
||||
else if (hasHighRating && (isCompleted || progress > 0.5)) {
|
||||
categories.get('Quality Seekers')!.push(book);
|
||||
}
|
||||
// Exploratory Readers: Wide variety of books, many started but not finished
|
||||
else if (!isCompleted && (progress > 0.1 && progress < 0.8)) {
|
||||
categories.get('Exploratory Readers')!.push(book);
|
||||
}
|
||||
// Consistent Readers: Steady reading pattern, average book length
|
||||
else if (isCompleted && pageCount > averagePageCount * 0.8 && pageCount < averagePageCount * 1.2) {
|
||||
categories.get('Consistent Readers')!.push(book);
|
||||
}
|
||||
// Selective Readers: Few books but high completion rate
|
||||
else if (completionRate > 0.4 && totalBooks < 50) {
|
||||
categories.get('Selective Readers')!.push(book);
|
||||
}
|
||||
// Casual Readers: Everything else
|
||||
else {
|
||||
categories.get('Casual Readers')!.push(book);
|
||||
}
|
||||
}
|
||||
|
||||
return categories;
|
||||
}
|
||||
|
||||
private hasHighQualityRating(book: Book): boolean {
|
||||
const metadata = book.metadata;
|
||||
if (!metadata) return false;
|
||||
|
||||
const goodreadsRating = metadata.goodreadsRating || 0;
|
||||
const amazonRating = metadata.amazonRating || 0;
|
||||
const personalRating = metadata.personalRating || 0;
|
||||
|
||||
return goodreadsRating >= 4.0 || amazonRating >= 4.0 || personalRating >= 4;
|
||||
}
|
||||
|
||||
private getReadingProgress(book: Book): number {
|
||||
if (book.readStatus === ReadStatus.READ) return 1.0;
|
||||
|
||||
const epubProgress = book.epubProgress?.percentage || 0;
|
||||
const pdfProgress = book.pdfProgress?.percentage || 0;
|
||||
const cbxProgress = book.cbxProgress?.percentage || 0;
|
||||
const koreaderProgress = book.koreaderProgress?.percentage || 0;
|
||||
|
||||
return Math.max(epubProgress, pdfProgress, cbxProgress, koreaderProgress) / 100;
|
||||
}
|
||||
|
||||
private calculateAverageRating(books: Book[]): number {
|
||||
const ratingsBooks = books.filter(book => {
|
||||
const metadata = book.metadata;
|
||||
return metadata && (metadata.goodreadsRating || metadata.amazonRating || metadata.personalRating);
|
||||
});
|
||||
|
||||
if (ratingsBooks.length === 0) return 0;
|
||||
|
||||
const totalRating = ratingsBooks.reduce((sum, book) => {
|
||||
const metadata = book.metadata!;
|
||||
const rating = metadata.goodreadsRating || metadata.amazonRating || metadata.personalRating || 0;
|
||||
return sum + rating;
|
||||
}, 0);
|
||||
|
||||
return totalRating / ratingsBooks.length;
|
||||
}
|
||||
|
||||
private convertToVelocityStats(categoriesMap: Map<string, Book[]>): ReadingVelocityStats[] {
|
||||
const descriptions: Record<string, string> = {
|
||||
'Speed Readers': 'High completion rate with shorter books',
|
||||
'Consistent Readers': 'Steady reading pattern with average-length books',
|
||||
'Selective Readers': 'Few books but high completion rate',
|
||||
'Exploratory Readers': 'Wide variety, many started but not finished',
|
||||
'Deep Readers': 'Prefer longer, high-quality books',
|
||||
'Casual Readers': 'Mixed reading patterns',
|
||||
'Quality Seekers': 'Focus on highly-rated books'
|
||||
};
|
||||
|
||||
return Array.from(categoriesMap.entries())
|
||||
.filter(([_, books]) => books.length > 0)
|
||||
.map(([category, books]) => {
|
||||
const completedBooks = books.filter(book => book.readStatus === ReadStatus.READ);
|
||||
const averagePages = books.reduce((sum, book) => sum + (book.metadata?.pageCount || 0), 0) / books.length;
|
||||
const averageRating = this.calculateAverageRating(books);
|
||||
|
||||
return {
|
||||
category,
|
||||
count: books.length,
|
||||
averagePages: Math.round(averagePages),
|
||||
averageRating: Number(averageRating.toFixed(1)),
|
||||
description: descriptions[category] || 'Mixed reading patterns'
|
||||
};
|
||||
})
|
||||
.sort((a, b) => b.count - a.count);
|
||||
}
|
||||
|
||||
private generateLegendLabels(chart: any) {
|
||||
const data = chart.data;
|
||||
if (!data.labels?.length || !data.datasets?.[0]?.data?.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const dataset = data.datasets[0];
|
||||
const dataValues = dataset.data as number[];
|
||||
|
||||
return data.labels.map((label: string, index: number) => {
|
||||
const isVisible = typeof chart.getDataVisibility === 'function'
|
||||
? chart.getDataVisibility(index)
|
||||
: !((chart.getDatasetMeta && chart.getDatasetMeta(0)?.data?.[index]?.hidden) || false);
|
||||
|
||||
return {
|
||||
text: `${label} (${dataValues[index]})`,
|
||||
fillStyle: (dataset.backgroundColor as string[])[index],
|
||||
strokeStyle: '#ffffff',
|
||||
lineWidth: 1,
|
||||
hidden: !isVisible,
|
||||
index,
|
||||
fontColor: '#ffffff'
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private formatTooltipLabel(context: any): string {
|
||||
const dataIndex = context.dataIndex;
|
||||
const velocityStats = this.getLastCalculatedStats();
|
||||
|
||||
if (!velocityStats || dataIndex >= velocityStats.length) {
|
||||
return `${context.parsed} books`;
|
||||
}
|
||||
|
||||
const stats = velocityStats[dataIndex];
|
||||
return `${stats.count} books | Avg. ${stats.averagePages} pages | Avg. rating: ${stats.averageRating}/5 | ${stats.description}`;
|
||||
}
|
||||
|
||||
private lastCalculatedStats: ReadingVelocityStats[] = [];
|
||||
|
||||
private getLastCalculatedStats(): ReadingVelocityStats[] {
|
||||
return this.lastCalculatedStats;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,388 @@
|
||||
import {inject, Injectable, OnDestroy} from '@angular/core';
|
||||
import {BehaviorSubject, EMPTY, Observable, Subject} from 'rxjs';
|
||||
import {map, takeUntil, catchError, filter, first, switchMap} from 'rxjs/operators';
|
||||
import {ChartConfiguration, ChartData} from 'chart.js';
|
||||
|
||||
import {LibraryFilterService} from './library-filter.service';
|
||||
import {BookService} from '../../book/service/book.service';
|
||||
import {Book, ReadStatus} from '../../book/model/book.model';
|
||||
|
||||
interface VelocityTimelineData {
|
||||
month: string;
|
||||
booksCompleted: number;
|
||||
totalPages: number;
|
||||
averagePages: number;
|
||||
averageRating: number;
|
||||
avgPagesPerDay: number;
|
||||
readingVelocity: number; // Books per month
|
||||
}
|
||||
|
||||
const CHART_COLORS = {
|
||||
booksCompleted: '#3498db',
|
||||
avgPagesPerDay: '#e74c3c',
|
||||
averageRating: '#f39c12',
|
||||
readingVelocity: '#2ecc71'
|
||||
} as const;
|
||||
|
||||
type VelocityTimelineChartData = ChartData<'line', number[], string>;
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class ReadingVelocityTimelineChartService implements OnDestroy {
|
||||
private readonly bookService = inject(BookService);
|
||||
private readonly libraryFilterService = inject(LibraryFilterService);
|
||||
private readonly destroy$ = new Subject<void>();
|
||||
|
||||
public readonly velocityTimelineChartType = 'line' as const;
|
||||
|
||||
public readonly velocityTimelineChartOptions: ChartConfiguration<'line'>['options'] = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
scales: {
|
||||
x: {
|
||||
type: 'category',
|
||||
ticks: {
|
||||
color: '#ffffff',
|
||||
font: {
|
||||
family: "'Inter', sans-serif",
|
||||
size: 11
|
||||
},
|
||||
maxRotation: 45
|
||||
},
|
||||
grid: {
|
||||
color: 'rgba(255, 255, 255, 0.1)'
|
||||
},
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Month',
|
||||
color: '#ffffff',
|
||||
font: {
|
||||
family: "'Inter', sans-serif",
|
||||
size: 11.5
|
||||
}
|
||||
}
|
||||
},
|
||||
y: {
|
||||
type: 'linear',
|
||||
display: true,
|
||||
position: 'left',
|
||||
beginAtZero: true,
|
||||
ticks: {
|
||||
color: '#ffffff',
|
||||
font: {
|
||||
family: "'Inter', sans-serif",
|
||||
size: 11
|
||||
}
|
||||
},
|
||||
grid: {
|
||||
color: 'rgba(255, 255, 255, 0.1)'
|
||||
},
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Books Completed',
|
||||
color: '#ffffff',
|
||||
font: {
|
||||
family: "'Inter', sans-serif",
|
||||
size: 11.5
|
||||
}
|
||||
}
|
||||
},
|
||||
y1: {
|
||||
type: 'linear',
|
||||
display: true,
|
||||
position: 'right',
|
||||
beginAtZero: true,
|
||||
ticks: {
|
||||
color: '#ffffff',
|
||||
font: {
|
||||
family: "'Inter', sans-serif",
|
||||
size: 11
|
||||
}
|
||||
},
|
||||
grid: {
|
||||
drawOnChartArea: false
|
||||
},
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Pages per Day',
|
||||
color: '#ffffff',
|
||||
font: {
|
||||
family: "'Inter', sans-serif",
|
||||
size: 11.5
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: true,
|
||||
position: 'top',
|
||||
labels: {
|
||||
color: '#ffffff',
|
||||
font: {
|
||||
family: "'Inter', sans-serif",
|
||||
size: 11.5
|
||||
},
|
||||
padding: 15,
|
||||
usePointStyle: true
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.9)',
|
||||
titleColor: '#ffffff',
|
||||
bodyColor: '#ffffff',
|
||||
borderColor: '#ffffff',
|
||||
borderWidth: 1,
|
||||
cornerRadius: 6,
|
||||
displayColors: true,
|
||||
padding: 12,
|
||||
titleFont: { size: 14, weight: 'bold' },
|
||||
bodyFont: { size: 12 },
|
||||
callbacks: {
|
||||
title: (context) => context[0]?.label || '',
|
||||
label: this.formatTooltipLabel.bind(this)
|
||||
}
|
||||
}
|
||||
},
|
||||
interaction: {
|
||||
intersect: false,
|
||||
mode: 'index'
|
||||
},
|
||||
elements: {
|
||||
point: {
|
||||
radius: 4,
|
||||
hoverRadius: 6
|
||||
},
|
||||
line: {
|
||||
tension: 0.2,
|
||||
borderWidth: 2
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
private readonly velocityTimelineChartDataSubject = new BehaviorSubject<VelocityTimelineChartData>({
|
||||
labels: [],
|
||||
datasets: []
|
||||
});
|
||||
|
||||
public readonly velocityTimelineChartData$: Observable<VelocityTimelineChartData> = this.velocityTimelineChartDataSubject.asObservable();
|
||||
|
||||
constructor() {
|
||||
this.bookService.bookState$
|
||||
.pipe(
|
||||
filter(state => state.loaded),
|
||||
first(),
|
||||
switchMap(() =>
|
||||
this.libraryFilterService.selectedLibrary$.pipe(
|
||||
takeUntil(this.destroy$)
|
||||
)
|
||||
),
|
||||
catchError((error) => {
|
||||
console.error('Error processing velocity timeline stats:', error);
|
||||
return EMPTY;
|
||||
})
|
||||
)
|
||||
.subscribe(() => {
|
||||
const stats = this.calculateVelocityTimelineStats();
|
||||
this.updateChartData(stats);
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
}
|
||||
|
||||
private updateChartData(stats: VelocityTimelineData[]): void {
|
||||
try {
|
||||
this.lastCalculatedStats = stats;
|
||||
const labels = stats.map(s => s.month);
|
||||
|
||||
const datasets = [
|
||||
{
|
||||
label: 'Books Completed',
|
||||
data: stats.map(s => s.booksCompleted),
|
||||
borderColor: CHART_COLORS.booksCompleted,
|
||||
backgroundColor: CHART_COLORS.booksCompleted + '20',
|
||||
yAxisID: 'y',
|
||||
tension: 0.2,
|
||||
fill: false
|
||||
},
|
||||
{
|
||||
label: 'Avg Pages/Day',
|
||||
data: stats.map(s => s.avgPagesPerDay),
|
||||
borderColor: CHART_COLORS.avgPagesPerDay,
|
||||
backgroundColor: CHART_COLORS.avgPagesPerDay + '20',
|
||||
yAxisID: 'y1',
|
||||
tension: 0.2,
|
||||
fill: false
|
||||
},
|
||||
{
|
||||
label: 'Avg Rating (×2)',
|
||||
data: stats.map(s => s.averageRating * 2), // Scale for visibility
|
||||
borderColor: CHART_COLORS.averageRating,
|
||||
backgroundColor: CHART_COLORS.averageRating + '20',
|
||||
yAxisID: 'y',
|
||||
tension: 0.2,
|
||||
fill: false,
|
||||
borderDash: [5, 5]
|
||||
},
|
||||
{
|
||||
label: 'Reading Velocity',
|
||||
data: stats.map(s => s.readingVelocity),
|
||||
borderColor: CHART_COLORS.readingVelocity,
|
||||
backgroundColor: CHART_COLORS.readingVelocity + '20',
|
||||
yAxisID: 'y',
|
||||
tension: 0.2,
|
||||
fill: true,
|
||||
fillOpacity: 0.1
|
||||
}
|
||||
];
|
||||
|
||||
this.velocityTimelineChartDataSubject.next({
|
||||
labels,
|
||||
datasets
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error updating velocity timeline chart data:', error);
|
||||
}
|
||||
}
|
||||
|
||||
private calculateVelocityTimelineStats(): VelocityTimelineData[] {
|
||||
const currentState = this.bookService.getCurrentBookState();
|
||||
const selectedLibraryId = this.libraryFilterService.getCurrentSelectedLibrary();
|
||||
|
||||
if (!this.isValidBookState(currentState)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const filteredBooks = this.filterBooksByLibrary(currentState.books!, String(selectedLibraryId));
|
||||
return this.processVelocityTimelineStats(filteredBooks);
|
||||
}
|
||||
|
||||
private isValidBookState(state: any): boolean {
|
||||
return state?.loaded && state?.books && Array.isArray(state.books) && state.books.length > 0;
|
||||
}
|
||||
|
||||
private filterBooksByLibrary(books: Book[], selectedLibraryId: string | null): Book[] {
|
||||
return selectedLibraryId && selectedLibraryId !== 'null'
|
||||
? books.filter(book => String(book.libraryId) === selectedLibraryId)
|
||||
: books;
|
||||
}
|
||||
|
||||
private processVelocityTimelineStats(books: Book[]): VelocityTimelineData[] {
|
||||
if (books.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Filter completed books with finish dates
|
||||
const completedBooks = books.filter(book =>
|
||||
book.readStatus === ReadStatus.READ &&
|
||||
book.dateFinished
|
||||
);
|
||||
|
||||
if (completedBooks.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Group books by month-year
|
||||
const monthlyData = new Map<string, Book[]>();
|
||||
|
||||
for (const book of completedBooks) {
|
||||
const finishDate = new Date(book.dateFinished!);
|
||||
if (isNaN(finishDate.getTime())) continue;
|
||||
|
||||
const monthKey = this.formatMonthYear(finishDate);
|
||||
if (!monthlyData.has(monthKey)) {
|
||||
monthlyData.set(monthKey, []);
|
||||
}
|
||||
monthlyData.get(monthKey)!.push(book);
|
||||
}
|
||||
|
||||
// Convert to timeline data and sort chronologically
|
||||
const timelineData = Array.from(monthlyData.entries())
|
||||
.map(([monthKey, monthBooks]) => this.calculateMonthlyMetrics(monthKey, monthBooks))
|
||||
.sort((a, b) => new Date(a.month + '-01').getTime() - new Date(b.month + '-01').getTime())
|
||||
.slice(-24); // Last 24 months
|
||||
|
||||
return timelineData;
|
||||
}
|
||||
|
||||
private formatMonthYear(date: Date): string {
|
||||
return date.getFullYear() + '-' + String(date.getMonth() + 1).padStart(2, '0');
|
||||
}
|
||||
|
||||
private calculateMonthlyMetrics(monthKey: string, books: Book[]): VelocityTimelineData {
|
||||
const totalPages = books.reduce((sum, book) => sum + (book.metadata?.pageCount || 0), 0);
|
||||
const averagePages = books.length > 0 ? Math.round(totalPages / books.length) : 0;
|
||||
|
||||
// Calculate average rating
|
||||
const ratedBooks = books.filter(book => book.metadata?.personalRating || book.metadata?.goodreadsRating);
|
||||
const totalRating = ratedBooks.reduce((sum, book) => {
|
||||
const rating = book.metadata?.personalRating || book.metadata?.goodreadsRating || 0;
|
||||
return sum + rating;
|
||||
}, 0);
|
||||
const averageRating = ratedBooks.length > 0 ? Number((totalRating / ratedBooks.length).toFixed(1)) : 0;
|
||||
|
||||
// Calculate days in month for pages per day calculation
|
||||
const [year, month] = monthKey.split('-').map(Number);
|
||||
const daysInMonth = new Date(year, month, 0).getDate();
|
||||
const avgPagesPerDay = Math.round(totalPages / daysInMonth);
|
||||
|
||||
// Reading velocity is books per month
|
||||
const readingVelocity = books.length;
|
||||
|
||||
return {
|
||||
month: this.formatDisplayMonth(monthKey),
|
||||
booksCompleted: books.length,
|
||||
totalPages,
|
||||
averagePages,
|
||||
averageRating,
|
||||
avgPagesPerDay,
|
||||
readingVelocity
|
||||
};
|
||||
}
|
||||
|
||||
private formatDisplayMonth(monthKey: string): string {
|
||||
const [year, month] = monthKey.split('-');
|
||||
const monthNames = [
|
||||
'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
|
||||
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'
|
||||
];
|
||||
return `${monthNames[parseInt(month) - 1]} ${year}`;
|
||||
}
|
||||
|
||||
private formatTooltipLabel(context: any): string {
|
||||
const datasetLabel = context.dataset.label;
|
||||
const value = context.parsed.y;
|
||||
const dataIndex = context.dataIndex;
|
||||
const stats = this.getLastCalculatedStats();
|
||||
|
||||
if (!stats || dataIndex >= stats.length) {
|
||||
return `${datasetLabel}: ${value}`;
|
||||
}
|
||||
|
||||
const monthStats = stats[dataIndex];
|
||||
|
||||
switch (datasetLabel) {
|
||||
case 'Books Completed':
|
||||
return `${value} books completed | ${monthStats.totalPages} total pages`;
|
||||
case 'Avg Pages/Day':
|
||||
return `${value} pages/day | ${monthStats.averagePages} avg pages/book`;
|
||||
case 'Avg Rating (×2)':
|
||||
const actualRating = value / 2;
|
||||
return `${actualRating.toFixed(1)}/5 avg rating | ${monthStats.booksCompleted} books rated`;
|
||||
case 'Reading Velocity':
|
||||
return `${value} books/month | ${monthStats.avgPagesPerDay} pages/day velocity`;
|
||||
default:
|
||||
return `${datasetLabel}: ${value}`;
|
||||
}
|
||||
}
|
||||
|
||||
private lastCalculatedStats: VelocityTimelineData[] = [];
|
||||
|
||||
private getLastCalculatedStats(): VelocityTimelineData[] {
|
||||
return this.lastCalculatedStats;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
import {inject, Injectable, OnDestroy} from '@angular/core';
|
||||
import {BehaviorSubject, combineLatest, Observable, Subject} from 'rxjs';
|
||||
import {map, takeUntil, catchError} from 'rxjs/operators';
|
||||
import {ChartConfiguration, ChartData} from 'chart.js';
|
||||
|
||||
import {LibraryFilterService} from './library-filter.service';
|
||||
import {BookService} from '../../book/service/book.service';
|
||||
import {Book, ReadStatus} from '../../book/model/book.model';
|
||||
|
||||
interface SeriesCompletionStats {
|
||||
seriesName: string;
|
||||
totalBooks: number;
|
||||
ownedBooks: number;
|
||||
readBooks: number;
|
||||
completionPercentage: number;
|
||||
collectionPercentage: number;
|
||||
isComplete: boolean;
|
||||
averageRating: number;
|
||||
}
|
||||
|
||||
const CHART_COLORS = [
|
||||
'#2ecc71', '#3498db', '#e74c3c', '#f39c12', '#9b59b6',
|
||||
'#1abc9c', '#34495e', '#e67e22', '#95a5a6', '#27ae60',
|
||||
'#2980b9', '#c0392b', '#d35400', '#8e44ad', '#16a085'
|
||||
] as const;
|
||||
|
||||
const CHART_DEFAULTS = {
|
||||
borderColor: '#ffffff',
|
||||
borderWidth: 1,
|
||||
hoverBorderWidth: 2,
|
||||
hoverBorderColor: '#ffffff'
|
||||
} as const;
|
||||
|
||||
type SeriesCompletionChartData = ChartData<'bar', number[], string>;
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class SeriesCompletionProgressChartService implements OnDestroy {
|
||||
private readonly bookService = inject(BookService);
|
||||
private readonly libraryFilterService = inject(LibraryFilterService);
|
||||
private readonly destroy$ = new Subject<void>();
|
||||
|
||||
public readonly seriesCompletionChartType = 'bar' as const;
|
||||
|
||||
public readonly seriesCompletionChartOptions: ChartConfiguration<'bar'>['options'] = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
scales: {
|
||||
x: {
|
||||
ticks: {
|
||||
color: '#ffffff',
|
||||
font: { size: 10 },
|
||||
maxRotation: 45
|
||||
},
|
||||
grid: {
|
||||
color: 'rgba(255, 255, 255, 0.1)'
|
||||
},
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Series',
|
||||
color: '#ffffff',
|
||||
font: { size: 12 }
|
||||
}
|
||||
},
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
max: 100,
|
||||
ticks: {
|
||||
color: '#ffffff',
|
||||
font: { size: 10 },
|
||||
callback: function(value) {
|
||||
return value + '%';
|
||||
}
|
||||
},
|
||||
grid: {
|
||||
color: 'rgba(255, 255, 255, 0.1)'
|
||||
},
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Completion %',
|
||||
color: '#ffffff',
|
||||
font: { size: 12 }
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: true,
|
||||
position: 'top',
|
||||
labels: {
|
||||
color: '#ffffff',
|
||||
font: { size: 11 },
|
||||
padding: 15,
|
||||
usePointStyle: true
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.9)',
|
||||
titleColor: '#ffffff',
|
||||
bodyColor: '#ffffff',
|
||||
borderColor: '#ffffff',
|
||||
borderWidth: 1,
|
||||
cornerRadius: 6,
|
||||
displayColors: true,
|
||||
padding: 12,
|
||||
titleFont: { size: 14, weight: 'bold' },
|
||||
bodyFont: { size: 12 },
|
||||
callbacks: {
|
||||
title: (context) => context[0]?.label || '',
|
||||
label: this.formatTooltipLabel.bind(this)
|
||||
}
|
||||
}
|
||||
},
|
||||
interaction: {
|
||||
intersect: false,
|
||||
mode: 'index'
|
||||
}
|
||||
};
|
||||
|
||||
private readonly seriesCompletionChartDataSubject = new BehaviorSubject<SeriesCompletionChartData>({
|
||||
labels: [],
|
||||
datasets: []
|
||||
});
|
||||
|
||||
public readonly seriesCompletionChartData$: Observable<SeriesCompletionChartData> = this.seriesCompletionChartDataSubject.asObservable();
|
||||
|
||||
constructor() {
|
||||
this.initializeChartDataSubscription();
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
}
|
||||
|
||||
private initializeChartDataSubscription(): void {
|
||||
this.getSeriesCompletionStats()
|
||||
.pipe(
|
||||
takeUntil(this.destroy$),
|
||||
catchError((error) => {
|
||||
console.error('Error processing series completion stats:', error);
|
||||
return [];
|
||||
})
|
||||
)
|
||||
.subscribe((stats) => this.updateChartData(stats));
|
||||
}
|
||||
|
||||
private updateChartData(stats: SeriesCompletionStats[]): void {
|
||||
try {
|
||||
this.lastCalculatedStats = stats;
|
||||
const labels = stats.map(s => this.truncateSeriesName(s.seriesName, 20));
|
||||
|
||||
const datasets = [
|
||||
{
|
||||
label: 'Reading Progress',
|
||||
data: stats.map(s => s.completionPercentage),
|
||||
backgroundColor: '#2ecc71',
|
||||
...CHART_DEFAULTS
|
||||
},
|
||||
{
|
||||
label: 'Collection Progress',
|
||||
data: stats.map(s => s.collectionPercentage),
|
||||
backgroundColor: '#3498db',
|
||||
...CHART_DEFAULTS
|
||||
}
|
||||
];
|
||||
|
||||
this.seriesCompletionChartDataSubject.next({
|
||||
labels,
|
||||
datasets
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error updating series completion chart data:', error);
|
||||
}
|
||||
}
|
||||
|
||||
private truncateSeriesName(name: string, maxLength: number): string {
|
||||
if (name.length <= maxLength) return name;
|
||||
return name.substring(0, maxLength - 3) + '...';
|
||||
}
|
||||
|
||||
public getSeriesCompletionStats(): Observable<SeriesCompletionStats[]> {
|
||||
return combineLatest([
|
||||
this.bookService.bookState$,
|
||||
this.libraryFilterService.selectedLibrary$
|
||||
]).pipe(
|
||||
map(([state, selectedLibraryId]) => {
|
||||
if (!this.isValidBookState(state)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const filteredBooks = this.filterBooksByLibrary(state.books!, String(selectedLibraryId));
|
||||
return this.processSeriesCompletionStats(filteredBooks);
|
||||
}),
|
||||
catchError((error) => {
|
||||
console.error('Error getting series completion stats:', error);
|
||||
return [];
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private isValidBookState(state: any): boolean {
|
||||
return state?.loaded && state?.books && Array.isArray(state.books) && state.books.length > 0;
|
||||
}
|
||||
|
||||
private filterBooksByLibrary(books: Book[], selectedLibraryId: string | null): Book[] {
|
||||
return selectedLibraryId && selectedLibraryId !== 'null'
|
||||
? books.filter(book => String(book.libraryId) === selectedLibraryId)
|
||||
: books;
|
||||
}
|
||||
|
||||
private processSeriesCompletionStats(books: Book[]): SeriesCompletionStats[] {
|
||||
if (books.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Group books by series
|
||||
const seriesMap = new Map<string, Book[]>();
|
||||
|
||||
books.forEach(book => {
|
||||
const seriesName = book.metadata?.seriesName;
|
||||
if (seriesName && seriesName.trim()) {
|
||||
if (!seriesMap.has(seriesName)) {
|
||||
seriesMap.set(seriesName, []);
|
||||
}
|
||||
seriesMap.get(seriesName)!.push(book);
|
||||
}
|
||||
});
|
||||
|
||||
// Calculate completion stats for each series
|
||||
const seriesStats: SeriesCompletionStats[] = [];
|
||||
|
||||
for (const [seriesName, seriesBooks] of seriesMap) {
|
||||
const stats = this.calculateSeriesStats(seriesName, seriesBooks);
|
||||
if (stats.totalBooks > 1) { // Only include actual series (more than 1 book)
|
||||
seriesStats.push(stats);
|
||||
}
|
||||
}
|
||||
|
||||
return seriesStats
|
||||
.sort((a, b) => {
|
||||
// Sort by completion percentage, then by collection percentage
|
||||
if (a.completionPercentage !== b.completionPercentage) {
|
||||
return b.completionPercentage - a.completionPercentage;
|
||||
}
|
||||
return b.collectionPercentage - a.collectionPercentage;
|
||||
})
|
||||
.slice(0, 15); // Top 15 series
|
||||
}
|
||||
|
||||
private calculateSeriesStats(seriesName: string, books: Book[]): SeriesCompletionStats {
|
||||
// Determine total books in series
|
||||
const seriesTotals = books
|
||||
.map(book => book.metadata?.seriesTotal)
|
||||
.filter((total): total is number => total != null && total > 0);
|
||||
|
||||
const totalBooks = seriesTotals.length > 0
|
||||
? Math.max(...seriesTotals)
|
||||
: books.length; // Fallback to owned books count
|
||||
|
||||
const ownedBooks = books.length;
|
||||
const readBooks = books.filter(book => book.readStatus === ReadStatus.READ).length;
|
||||
|
||||
// Calculate completion and collection percentages
|
||||
const completionPercentage = totalBooks > 0
|
||||
? Math.round((readBooks / totalBooks) * 100)
|
||||
: 0;
|
||||
|
||||
const collectionPercentage = totalBooks > 0
|
||||
? Math.round((ownedBooks / totalBooks) * 100)
|
||||
: 100;
|
||||
|
||||
const isComplete = ownedBooks >= totalBooks;
|
||||
|
||||
// Calculate average rating
|
||||
const ratedBooks = books.filter(book => {
|
||||
const rating = book.metadata?.personalRating || book.metadata?.goodreadsRating;
|
||||
return rating && rating > 0;
|
||||
});
|
||||
|
||||
const averageRating = ratedBooks.length > 0
|
||||
? Number((ratedBooks.reduce((sum, book) => {
|
||||
const rating = book.metadata?.personalRating || book.metadata?.goodreadsRating || 0;
|
||||
return sum + rating;
|
||||
}, 0) / ratedBooks.length).toFixed(1))
|
||||
: 0;
|
||||
|
||||
return {
|
||||
seriesName,
|
||||
totalBooks,
|
||||
ownedBooks,
|
||||
readBooks,
|
||||
completionPercentage,
|
||||
collectionPercentage,
|
||||
isComplete,
|
||||
averageRating
|
||||
};
|
||||
}
|
||||
|
||||
private formatTooltipLabel(context: any): string {
|
||||
const dataIndex = context.dataIndex;
|
||||
const datasetLabel = context.dataset.label;
|
||||
const value = context.parsed.y;
|
||||
const stats = this.getLastCalculatedStats();
|
||||
|
||||
if (!stats || dataIndex >= stats.length) {
|
||||
return `${datasetLabel}: ${value}%`;
|
||||
}
|
||||
|
||||
const series = stats[dataIndex];
|
||||
|
||||
if (datasetLabel === 'Reading Progress') {
|
||||
return `${value}% read (${series.readBooks}/${series.totalBooks} books) | Avg rating: ${series.averageRating}/5`;
|
||||
} else if (datasetLabel === 'Collection Progress') {
|
||||
return `${value}% collected (${series.ownedBooks}/${series.totalBooks} books) | ${series.isComplete ? 'Complete!' : 'Incomplete'}`;
|
||||
}
|
||||
|
||||
return `${datasetLabel}: ${value}%`;
|
||||
}
|
||||
|
||||
private lastCalculatedStats: SeriesCompletionStats[] = [];
|
||||
|
||||
private getLastCalculatedStats(): SeriesCompletionStats[] {
|
||||
return this.lastCalculatedStats;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
import {inject, Injectable, OnDestroy} from '@angular/core';
|
||||
import {BehaviorSubject, combineLatest, Observable, Subject} from 'rxjs';
|
||||
import {map, takeUntil, catchError} from 'rxjs/operators';
|
||||
import {ChartConfiguration, ChartData} from 'chart.js';
|
||||
|
||||
import {LibraryFilterService} from './library-filter.service';
|
||||
import {BookService} from '../../book/service/book.service';
|
||||
import {Book} from '../../book/model/book.model';
|
||||
|
||||
interface SeriesLengthStats {
|
||||
category: string;
|
||||
count: number;
|
||||
percentage: number;
|
||||
seriesNames: string[];
|
||||
averageBooksOwned: number;
|
||||
}
|
||||
|
||||
const CHART_COLORS = [
|
||||
'#3498db', '#2ecc71', '#e74c3c', '#f39c12', '#9b59b6',
|
||||
'#1abc9c', '#34495e', '#e67e22', '#95a5a6', '#27ae60'
|
||||
] as const;
|
||||
|
||||
const CHART_DEFAULTS = {
|
||||
borderColor: '#ffffff',
|
||||
borderWidth: 2,
|
||||
hoverBorderWidth: 3,
|
||||
hoverBorderColor: '#ffffff'
|
||||
} as const;
|
||||
|
||||
type SeriesLengthChartData = ChartData<'doughnut', number[], string>;
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class SeriesLengthDistributionChartService implements OnDestroy {
|
||||
private readonly bookService = inject(BookService);
|
||||
private readonly libraryFilterService = inject(LibraryFilterService);
|
||||
private readonly destroy$ = new Subject<void>();
|
||||
|
||||
public readonly seriesLengthChartType = 'doughnut' as const;
|
||||
|
||||
public readonly seriesLengthChartOptions: ChartConfiguration<'doughnut'>['options'] = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: true,
|
||||
position: 'bottom',
|
||||
labels: {
|
||||
color: '#ffffff',
|
||||
font: { size: 11 },
|
||||
padding: 12,
|
||||
usePointStyle: true,
|
||||
generateLabels: this.generateLegendLabels.bind(this)
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.9)',
|
||||
titleColor: '#ffffff',
|
||||
bodyColor: '#ffffff',
|
||||
borderColor: '#ffffff',
|
||||
borderWidth: 1,
|
||||
cornerRadius: 6,
|
||||
displayColors: true,
|
||||
padding: 12,
|
||||
titleFont: { size: 14, weight: 'bold' },
|
||||
bodyFont: { size: 12 },
|
||||
callbacks: {
|
||||
title: (context) => context[0]?.label || '',
|
||||
label: this.formatTooltipLabel.bind(this)
|
||||
}
|
||||
}
|
||||
},
|
||||
interaction: {
|
||||
intersect: false,
|
||||
mode: 'point'
|
||||
},
|
||||
cutout: '50%'
|
||||
};
|
||||
|
||||
private readonly seriesLengthChartDataSubject = new BehaviorSubject<SeriesLengthChartData>({
|
||||
labels: [],
|
||||
datasets: [{
|
||||
data: [],
|
||||
backgroundColor: [...CHART_COLORS],
|
||||
...CHART_DEFAULTS
|
||||
}]
|
||||
});
|
||||
|
||||
public readonly seriesLengthChartData$: Observable<SeriesLengthChartData> = this.seriesLengthChartDataSubject.asObservable();
|
||||
|
||||
constructor() {
|
||||
this.initializeChartDataSubscription();
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
}
|
||||
|
||||
private initializeChartDataSubscription(): void {
|
||||
this.getSeriesLengthStats()
|
||||
.pipe(
|
||||
takeUntil(this.destroy$),
|
||||
catchError((error) => {
|
||||
console.error('Error processing series length stats:', error);
|
||||
return [];
|
||||
})
|
||||
)
|
||||
.subscribe((stats) => this.updateChartData(stats));
|
||||
}
|
||||
|
||||
private updateChartData(stats: SeriesLengthStats[]): void {
|
||||
try {
|
||||
this.lastCalculatedStats = stats;
|
||||
const labels = stats.map(s => s.category);
|
||||
const dataValues = stats.map(s => s.count);
|
||||
const colors = this.getColorsForData(stats.length);
|
||||
|
||||
this.seriesLengthChartDataSubject.next({
|
||||
labels,
|
||||
datasets: [{
|
||||
data: dataValues,
|
||||
backgroundColor: colors,
|
||||
...CHART_DEFAULTS
|
||||
}]
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error updating series length chart data:', error);
|
||||
}
|
||||
}
|
||||
|
||||
private getColorsForData(dataLength: number): string[] {
|
||||
const colors = [...CHART_COLORS];
|
||||
while (colors.length < dataLength) {
|
||||
colors.push(...CHART_COLORS);
|
||||
}
|
||||
return colors.slice(0, dataLength);
|
||||
}
|
||||
|
||||
public getSeriesLengthStats(): Observable<SeriesLengthStats[]> {
|
||||
return combineLatest([
|
||||
this.bookService.bookState$,
|
||||
this.libraryFilterService.selectedLibrary$
|
||||
]).pipe(
|
||||
map(([state, selectedLibraryId]) => {
|
||||
if (!this.isValidBookState(state)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const filteredBooks = this.filterBooksByLibrary(state.books!, String(selectedLibraryId));
|
||||
return this.processSeriesLengthStats(filteredBooks);
|
||||
}),
|
||||
catchError((error) => {
|
||||
console.error('Error getting series length stats:', error);
|
||||
return [];
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private isValidBookState(state: any): boolean {
|
||||
return state?.loaded && state?.books && Array.isArray(state.books) && state.books.length > 0;
|
||||
}
|
||||
|
||||
private filterBooksByLibrary(books: Book[], selectedLibraryId: string | null): Book[] {
|
||||
return selectedLibraryId && selectedLibraryId !== 'null'
|
||||
? books.filter(book => String(book.libraryId) === selectedLibraryId)
|
||||
: books;
|
||||
}
|
||||
|
||||
private processSeriesLengthStats(books: Book[]): SeriesLengthStats[] {
|
||||
if (books.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Group books by series
|
||||
const seriesMap = new Map<string, Book[]>();
|
||||
|
||||
books.forEach(book => {
|
||||
const seriesName = book.metadata?.seriesName;
|
||||
if (seriesName && seriesName.trim()) {
|
||||
if (!seriesMap.has(seriesName)) {
|
||||
seriesMap.set(seriesName, []);
|
||||
}
|
||||
seriesMap.get(seriesName)!.push(book);
|
||||
}
|
||||
});
|
||||
|
||||
// Categorize series by length
|
||||
const categories = new Map<string, { count: number, seriesNames: string[], totalBooksOwned: number }>();
|
||||
|
||||
// Initialize categories
|
||||
const categoryRanges = [
|
||||
{ name: 'Duologies (2 books)', min: 2, max: 2 },
|
||||
{ name: 'Trilogies (3 books)', min: 3, max: 3 },
|
||||
{ name: 'Short Series (4-5 books)', min: 4, max: 5 },
|
||||
{ name: 'Medium Series (6-10 books)', min: 6, max: 10 },
|
||||
{ name: 'Long Series (11-20 books)', min: 11, max: 20 },
|
||||
{ name: 'Epic Series (21+ books)', min: 21, max: Infinity },
|
||||
{ name: 'Unknown Length', min: 0, max: 0 }
|
||||
];
|
||||
|
||||
categoryRanges.forEach(range => {
|
||||
categories.set(range.name, { count: 0, seriesNames: [], totalBooksOwned: 0 });
|
||||
});
|
||||
|
||||
// Categorize each series
|
||||
for (const [seriesName, seriesBooks] of seriesMap) {
|
||||
if (seriesBooks.length < 2) continue; // Skip single books
|
||||
|
||||
// Determine series total length
|
||||
const seriesTotals = seriesBooks
|
||||
.map(book => book.metadata?.seriesTotal)
|
||||
.filter((total): total is number => typeof total === 'number' && total > 0);
|
||||
|
||||
const seriesLength = seriesTotals.length > 0
|
||||
? Math.max(...seriesTotals)
|
||||
: 0; // Unknown length
|
||||
|
||||
let categoryName = 'Unknown Length';
|
||||
|
||||
if (seriesLength > 0) {
|
||||
for (const range of categoryRanges) {
|
||||
if (seriesLength >= range.min && seriesLength <= range.max) {
|
||||
categoryName = range.name;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const categoryData = categories.get(categoryName)!;
|
||||
categoryData.count++;
|
||||
categoryData.seriesNames.push(seriesName);
|
||||
categoryData.totalBooksOwned += seriesBooks.length;
|
||||
}
|
||||
|
||||
// Convert to stats array
|
||||
const totalSeries = Array.from(categories.values()).reduce((sum, cat) => sum + cat.count, 0);
|
||||
|
||||
return Array.from(categories.entries())
|
||||
.filter(([_, data]) => data.count > 0)
|
||||
.map(([category, data]) => ({
|
||||
category,
|
||||
count: data.count,
|
||||
percentage: Number(((data.count / totalSeries) * 100).toFixed(1)),
|
||||
seriesNames: data.seriesNames,
|
||||
averageBooksOwned: data.count > 0 ? Math.round(data.totalBooksOwned / data.count) : 0
|
||||
}))
|
||||
.sort((a, b) => b.count - a.count);
|
||||
}
|
||||
|
||||
private generateLegendLabels(chart: any) {
|
||||
const data = chart.data;
|
||||
if (!data.labels?.length || !data.datasets?.[0]?.data?.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const dataset = data.datasets[0];
|
||||
const dataValues = dataset.data as number[];
|
||||
|
||||
return data.labels.map((label: string, index: number) => {
|
||||
const isVisible = typeof chart.getDataVisibility === 'function'
|
||||
? chart.getDataVisibility(index)
|
||||
: !((chart.getDatasetMeta && chart.getDatasetMeta(0)?.data?.[index]?.hidden) || false);
|
||||
|
||||
return {
|
||||
text: `${label} (${dataValues[index]})`,
|
||||
fillStyle: (dataset.backgroundColor as string[])[index],
|
||||
strokeStyle: '#ffffff',
|
||||
lineWidth: 1,
|
||||
hidden: !isVisible,
|
||||
index,
|
||||
fontColor: '#ffffff'
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private formatTooltipLabel(context: any): string {
|
||||
const dataIndex = context.dataIndex;
|
||||
const stats = this.getLastCalculatedStats();
|
||||
|
||||
if (!stats || dataIndex >= stats.length) {
|
||||
return `${context.parsed} series`;
|
||||
}
|
||||
|
||||
const categoryStats = stats[dataIndex];
|
||||
const total = context.chart.data.datasets[0].data.reduce((a: number, b: number) => a + b, 0);
|
||||
const percentage = ((categoryStats.count / total) * 100).toFixed(1);
|
||||
|
||||
return `${categoryStats.count} series (${percentage}%) | Avg ${categoryStats.averageBooksOwned} books owned | Examples: ${categoryStats.seriesNames.slice(0, 3).join(', ')}${categoryStats.seriesNames.length > 3 ? '...' : ''}`;
|
||||
}
|
||||
|
||||
private lastCalculatedStats: SeriesLengthStats[] = [];
|
||||
|
||||
private getLastCalculatedStats(): SeriesLengthStats[] {
|
||||
return this.lastCalculatedStats;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
import {inject, Injectable, OnDestroy} from '@angular/core';
|
||||
import {BehaviorSubject, combineLatest, Observable, Subject} from 'rxjs';
|
||||
import {map, takeUntil, catchError} from 'rxjs/operators';
|
||||
import {ChartConfiguration, ChartData} from 'chart.js';
|
||||
|
||||
import {LibraryFilterService} from './library-filter.service';
|
||||
import {BookService} from '../../book/service/book.service';
|
||||
import {Book} from '../../book/model/book.model';
|
||||
|
||||
interface SeriesStats {
|
||||
category: string;
|
||||
count: number;
|
||||
percentage: number;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const CHART_COLORS = [
|
||||
'#ff6b6b', '#4ecdc4', '#45b7d1', '#96ceb4', '#ffeaa7',
|
||||
'#dda0dd', '#98d8c8', '#ff7675', '#74b9ff', '#fdcb6e'
|
||||
] as const;
|
||||
|
||||
const CHART_DEFAULTS = {
|
||||
borderColor: '#ffffff',
|
||||
borderWidth: 2,
|
||||
hoverBorderWidth: 3,
|
||||
hoverBorderColor: '#ffffff'
|
||||
} as const;
|
||||
|
||||
type SeriesChartData = ChartData<'polarArea', number[], string>;
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class SeriesStandaloneChartService implements OnDestroy {
|
||||
private readonly bookService = inject(BookService);
|
||||
private readonly libraryFilterService = inject(LibraryFilterService);
|
||||
private readonly destroy$ = new Subject<void>();
|
||||
|
||||
public readonly seriesChartType = 'polarArea' as const;
|
||||
|
||||
public readonly seriesChartOptions: ChartConfiguration<'polarArea'>['options'] = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
scales: {
|
||||
r: {
|
||||
beginAtZero: true,
|
||||
ticks: {
|
||||
color: '#ffffff',
|
||||
font: {size: 10},
|
||||
stepSize: 1
|
||||
},
|
||||
grid: {
|
||||
color: 'rgba(255, 255, 255, 0.2)'
|
||||
},
|
||||
angleLines: {
|
||||
color: 'rgba(255, 255, 255, 0.2)'
|
||||
},
|
||||
pointLabels: {
|
||||
color: '#ffffff',
|
||||
font: {size: 11}
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: true,
|
||||
position: 'bottom',
|
||||
labels: {
|
||||
color: '#ffffff',
|
||||
font: {size: 11},
|
||||
padding: 12,
|
||||
usePointStyle: true,
|
||||
generateLabels: this.generateLegendLabels.bind(this)
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
enabled: true,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.9)',
|
||||
titleColor: '#ffffff',
|
||||
bodyColor: '#ffffff',
|
||||
borderColor: '#ffffff',
|
||||
borderWidth: 1,
|
||||
cornerRadius: 6,
|
||||
displayColors: true,
|
||||
padding: 12,
|
||||
titleFont: {size: 14, weight: 'bold'},
|
||||
bodyFont: {size: 13},
|
||||
position: 'nearest',
|
||||
callbacks: {
|
||||
title: (context) => context[0]?.label || '',
|
||||
label: this.formatTooltipLabel
|
||||
}
|
||||
}
|
||||
},
|
||||
interaction: {
|
||||
intersect: false,
|
||||
mode: 'point'
|
||||
}
|
||||
};
|
||||
|
||||
private readonly seriesChartDataSubject = new BehaviorSubject<SeriesChartData>({
|
||||
labels: [],
|
||||
datasets: [{
|
||||
data: [],
|
||||
backgroundColor: [...CHART_COLORS],
|
||||
...CHART_DEFAULTS
|
||||
}]
|
||||
});
|
||||
|
||||
public readonly seriesChartData$: Observable<SeriesChartData> = this.seriesChartDataSubject.asObservable();
|
||||
|
||||
constructor() {
|
||||
this.initializeChartDataSubscription();
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
}
|
||||
|
||||
private initializeChartDataSubscription(): void {
|
||||
this.getSeriesStats()
|
||||
.pipe(
|
||||
takeUntil(this.destroy$),
|
||||
catchError((error) => {
|
||||
console.error('Error processing series stats:', error);
|
||||
return [];
|
||||
})
|
||||
)
|
||||
.subscribe((stats) => this.updateChartData(stats));
|
||||
}
|
||||
|
||||
private updateChartData(stats: SeriesStats[]): void {
|
||||
try {
|
||||
const labels = stats.map(s => s.category);
|
||||
const dataValues = stats.map(s => s.count);
|
||||
const colors = this.getColorsForData(stats.length);
|
||||
|
||||
this.seriesChartDataSubject.next({
|
||||
labels,
|
||||
datasets: [{
|
||||
data: dataValues,
|
||||
backgroundColor: colors,
|
||||
...CHART_DEFAULTS
|
||||
}]
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error updating series chart data:', error);
|
||||
}
|
||||
}
|
||||
|
||||
private getColorsForData(dataLength: number): string[] {
|
||||
const colors = [...CHART_COLORS];
|
||||
while (colors.length < dataLength) {
|
||||
colors.push(...CHART_COLORS);
|
||||
}
|
||||
return colors.slice(0, dataLength);
|
||||
}
|
||||
|
||||
public getSeriesStats(): Observable<SeriesStats[]> {
|
||||
return combineLatest([
|
||||
this.bookService.bookState$,
|
||||
this.libraryFilterService.selectedLibrary$
|
||||
]).pipe(
|
||||
map(([state, selectedLibraryId]) => {
|
||||
if (!this.isValidBookState(state)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const filteredBooks = this.filterBooksByLibrary(state.books!, String(selectedLibraryId));
|
||||
return this.processSeriesStats(filteredBooks);
|
||||
}),
|
||||
catchError((error) => {
|
||||
console.error('Error getting series stats:', error);
|
||||
return [];
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private isValidBookState(state: any): boolean {
|
||||
return state?.loaded && state?.books && Array.isArray(state.books) && state.books.length > 0;
|
||||
}
|
||||
|
||||
private filterBooksByLibrary(books: Book[], selectedLibraryId: string | null): Book[] {
|
||||
return selectedLibraryId && selectedLibraryId !== 'null'
|
||||
? books.filter(book => String(book.libraryId) === selectedLibraryId)
|
||||
: books;
|
||||
}
|
||||
|
||||
private processSeriesStats(books: Book[]): SeriesStats[] {
|
||||
if (books.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const stats = this.categorizeBooks(books);
|
||||
return this.convertToStats(stats, books.length);
|
||||
}
|
||||
|
||||
private categorizeBooks(books: Book[]): Map<string, { count: number; description: string }> {
|
||||
const categories = new Map<string, { count: number; description: string }>();
|
||||
|
||||
// Initialize categories
|
||||
categories.set('Standalone Books', { count: 0, description: 'Books not part of any series' });
|
||||
categories.set('Series Books', { count: 0, description: 'Books that are part of a series' });
|
||||
categories.set('Complete Series', { count: 0, description: 'Books in complete series' });
|
||||
categories.set('Incomplete Series', { count: 0, description: 'Books in incomplete series' });
|
||||
categories.set('Unknown Series Status', { count: 0, description: 'Books with unclear series information' });
|
||||
|
||||
// Group books by series
|
||||
const seriesMap = new Map<string, Book[]>();
|
||||
const standaloneBooks: Book[] = [];
|
||||
|
||||
for (const book of books) {
|
||||
const seriesName = book.metadata?.seriesName;
|
||||
if (seriesName && seriesName.trim()) {
|
||||
if (!seriesMap.has(seriesName)) {
|
||||
seriesMap.set(seriesName, []);
|
||||
}
|
||||
seriesMap.get(seriesName)!.push(book);
|
||||
} else {
|
||||
standaloneBooks.push(book);
|
||||
}
|
||||
}
|
||||
|
||||
// Count standalone books
|
||||
categories.get('Standalone Books')!.count = standaloneBooks.length;
|
||||
|
||||
// Process series books
|
||||
let seriesBooks = 0;
|
||||
let completeSeriesBooks = 0;
|
||||
let incompleteSeriesBooks = 0;
|
||||
let unknownSeriesBooks = 0;
|
||||
|
||||
for (const [seriesName, seriesBookList] of seriesMap) {
|
||||
seriesBooks += seriesBookList.length;
|
||||
|
||||
// Check if series is complete
|
||||
const seriesTotal = seriesBookList[0]?.metadata?.seriesTotal;
|
||||
const hasSeriesNumbers = seriesBookList.every(book => book.metadata?.seriesNumber);
|
||||
|
||||
if (seriesTotal && hasSeriesNumbers) {
|
||||
const uniqueNumbers = new Set(seriesBookList.map(book => book.metadata?.seriesNumber));
|
||||
if (uniqueNumbers.size === seriesTotal) {
|
||||
completeSeriesBooks += seriesBookList.length;
|
||||
} else {
|
||||
incompleteSeriesBooks += seriesBookList.length;
|
||||
}
|
||||
} else {
|
||||
unknownSeriesBooks += seriesBookList.length;
|
||||
}
|
||||
}
|
||||
|
||||
categories.get('Series Books')!.count = seriesBooks;
|
||||
categories.get('Complete Series')!.count = completeSeriesBooks;
|
||||
categories.get('Incomplete Series')!.count = incompleteSeriesBooks;
|
||||
categories.get('Unknown Series Status')!.count = unknownSeriesBooks;
|
||||
|
||||
return categories;
|
||||
}
|
||||
|
||||
private convertToStats(categoriesMap: Map<string, { count: number; description: string }>, totalBooks: number): SeriesStats[] {
|
||||
return Array.from(categoriesMap.entries())
|
||||
.filter(([_, data]) => data.count > 0) // Only include categories with books
|
||||
.map(([category, data]) => ({
|
||||
category,
|
||||
count: data.count,
|
||||
percentage: Number(((data.count / totalBooks) * 100).toFixed(1)),
|
||||
description: data.description
|
||||
}))
|
||||
.sort((a, b) => b.count - a.count);
|
||||
}
|
||||
|
||||
private generateLegendLabels(chart: any) {
|
||||
const data = chart.data;
|
||||
if (!data.labels?.length || !data.datasets?.[0]?.data?.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const dataset = data.datasets[0];
|
||||
const dataValues = dataset.data as number[];
|
||||
|
||||
return data.labels.map((label: string, index: number) => {
|
||||
const isVisible = typeof chart.getDataVisibility === 'function'
|
||||
? chart.getDataVisibility(index)
|
||||
: !((chart.getDatasetMeta && chart.getDatasetMeta(0)?.data?.[index]?.hidden) || false);
|
||||
|
||||
return {
|
||||
text: `${label} (${dataValues[index]})`,
|
||||
fillStyle: (dataset.backgroundColor as string[])[index],
|
||||
strokeStyle: '#ffffff',
|
||||
lineWidth: 1,
|
||||
hidden: !isVisible,
|
||||
index,
|
||||
fontColor: '#ffffff'
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private formatTooltipLabel(context: any): string {
|
||||
const dataIndex = context.dataIndex;
|
||||
const dataset = context.dataset;
|
||||
const value = dataset.data[dataIndex] as number;
|
||||
const label = context.chart.data.labels?.[dataIndex] || 'Unknown';
|
||||
const total = (dataset.data as number[]).reduce((a: number, b: number) => a + b, 0);
|
||||
const percentage = ((value / total) * 100).toFixed(1);
|
||||
return `${label}: ${value} books (${percentage}%)`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
import {inject, Injectable, OnDestroy} from '@angular/core';
|
||||
import {BehaviorSubject, combineLatest, Observable, Subject} from 'rxjs';
|
||||
import {map, takeUntil, catchError} from 'rxjs/operators';
|
||||
import {ChartConfiguration, ChartData, ChartType} from 'chart.js';
|
||||
|
||||
import {LibraryFilterService} from './library-filter.service';
|
||||
import {BookService} from '../../book/service/book.service';
|
||||
import {Book} from '../../book/model/book.model';
|
||||
|
||||
interface CategoryStats {
|
||||
category: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
const CHART_COLORS = [
|
||||
'#FF6B6B', '#4ECDC4', '#45B7D1', '#96CEB4', '#FECA57',
|
||||
'#FF9FF3', '#54A0FF', '#5F27CD', '#00D2D3', '#FF9F43',
|
||||
'#FF6348', '#2ED573', '#3742FA', '#F368E0', '#FF3838',
|
||||
'#FF4757', '#5352ED', '#70A1FF', '#7F8FA6', '#40407A',
|
||||
'#2C2C54', '#40407A', '#706FD3', '#F97F51', '#F8B500'
|
||||
] as const;
|
||||
|
||||
const HOVER_COLORS = [
|
||||
'#FF5252', '#26A69A', '#2196F3', '#66BB6A', '#FFB74D',
|
||||
'#E91E63', '#3F51B5', '#9C27B0', '#00BCD4', '#FF9800',
|
||||
'#F44336', '#4CAF50', '#2196F3', '#E91E63', '#FF5722',
|
||||
'#FF4081', '#3F51B5', '#5C6BC0', '#607D8B', '#303F9F',
|
||||
'#1A237E', '#303F9F', '#5E35B1', '#FF6F00', '#E65100'
|
||||
] as const;
|
||||
|
||||
const CHART_DEFAULTS = {
|
||||
borderColor: '#ffffff',
|
||||
borderWidth: 1,
|
||||
hoverBorderColor: '#ffffff',
|
||||
hoverBorderWidth: 2
|
||||
} as const;
|
||||
|
||||
type CategoriesChartData = ChartData<'bar', number[], string>;
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class TopCategoriesChartService implements OnDestroy {
|
||||
private readonly bookService = inject(BookService);
|
||||
private readonly libraryFilterService = inject(LibraryFilterService);
|
||||
private readonly destroy$ = new Subject<void>();
|
||||
|
||||
public readonly categoriesChartType: ChartType = 'bar';
|
||||
|
||||
public readonly categoriesChartOptions: ChartConfiguration['options'] = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
indexAxis: 'y',
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.8)',
|
||||
titleColor: '#ffffff',
|
||||
bodyColor: '#ffffff',
|
||||
borderColor: '#666666',
|
||||
borderWidth: 1,
|
||||
callbacks: {
|
||||
title: () => '',
|
||||
label: (context) => {
|
||||
const label = context.label || '';
|
||||
const value = context.parsed.x;
|
||||
return `${label}: ${value} books`;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
beginAtZero: true,
|
||||
ticks: {
|
||||
color: '#ffffff',
|
||||
font: {
|
||||
size: 12
|
||||
}
|
||||
},
|
||||
grid: {
|
||||
color: 'rgba(255, 255, 255, 0.1)'
|
||||
},
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Number of Books',
|
||||
color: '#ffffff',
|
||||
font: {
|
||||
size: 14,
|
||||
weight: 'bold'
|
||||
}
|
||||
}
|
||||
},
|
||||
y: {
|
||||
ticks: {
|
||||
color: '#ffffff',
|
||||
font: {
|
||||
size: 11
|
||||
},
|
||||
maxRotation: 0,
|
||||
callback: function (value, index) {
|
||||
const label = this.getLabelForValue(value as number);
|
||||
return label.length > 25 ? label.substring(0, 25) + '...' : label;
|
||||
}
|
||||
},
|
||||
grid: {
|
||||
color: 'rgba(255, 255, 255, 0.05)'
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
private readonly categoriesChartDataSubject = new BehaviorSubject<CategoriesChartData>({
|
||||
labels: [],
|
||||
datasets: [{
|
||||
label: 'Books',
|
||||
data: [],
|
||||
backgroundColor: [...CHART_COLORS],
|
||||
hoverBackgroundColor: [...HOVER_COLORS],
|
||||
...CHART_DEFAULTS
|
||||
}]
|
||||
});
|
||||
|
||||
public readonly categoriesChartData$: Observable<CategoriesChartData> =
|
||||
this.categoriesChartDataSubject.asObservable();
|
||||
|
||||
constructor() {
|
||||
this.initializeChartDataSubscription();
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
}
|
||||
|
||||
private initializeChartDataSubscription(): void {
|
||||
this.getCategoryStats()
|
||||
.pipe(
|
||||
takeUntil(this.destroy$),
|
||||
catchError((error) => {
|
||||
console.error('Error processing category stats:', error);
|
||||
return [];
|
||||
})
|
||||
)
|
||||
.subscribe((stats) => this.updateChartData(stats));
|
||||
}
|
||||
|
||||
private updateChartData(stats: CategoryStats[]): void {
|
||||
try {
|
||||
const reversedStats = [...stats].reverse();
|
||||
const labels = reversedStats.map(s => s.category);
|
||||
const dataValues = reversedStats.map(s => s.count);
|
||||
|
||||
this.categoriesChartDataSubject.next({
|
||||
labels,
|
||||
datasets: [{
|
||||
label: 'Books',
|
||||
data: dataValues,
|
||||
backgroundColor: [...CHART_COLORS],
|
||||
hoverBackgroundColor: [...HOVER_COLORS],
|
||||
...CHART_DEFAULTS
|
||||
}]
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error updating chart data:', error);
|
||||
}
|
||||
}
|
||||
|
||||
public getCategoryStats(): Observable<CategoryStats[]> {
|
||||
return combineLatest([
|
||||
this.bookService.bookState$,
|
||||
this.libraryFilterService.selectedLibrary$
|
||||
]).pipe(
|
||||
map(([state, selectedLibraryId]) => {
|
||||
if (!this.isValidBookState(state)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const filteredBooks = this.filterBooksByLibrary(state.books!, selectedLibraryId);
|
||||
return this.processCategoryStats(filteredBooks);
|
||||
}),
|
||||
catchError((error) => {
|
||||
console.error('Error getting category stats:', error);
|
||||
return [];
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
public updateFromStats(stats: CategoryStats[]): void {
|
||||
this.updateChartData(stats);
|
||||
}
|
||||
|
||||
private isValidBookState(state: any): boolean {
|
||||
return state?.loaded && state?.books && Array.isArray(state.books) && state.books.length > 0;
|
||||
}
|
||||
|
||||
private filterBooksByLibrary(books: Book[], selectedLibraryId: string | number | null): Book[] {
|
||||
return selectedLibraryId
|
||||
? books.filter(book => book.libraryId === selectedLibraryId)
|
||||
: books;
|
||||
}
|
||||
|
||||
private processCategoryStats(books: Book[]): CategoryStats[] {
|
||||
const categoryMap = new Map<string, number>();
|
||||
|
||||
books.forEach(book => {
|
||||
if (book.metadata?.categories && Array.isArray(book.metadata.categories)) {
|
||||
book.metadata.categories.forEach(category => {
|
||||
if (category) {
|
||||
categoryMap.set(category, (categoryMap.get(category) || 0) + 1);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(categoryMap.entries())
|
||||
.map(([category, count]) => ({category, count}))
|
||||
.sort((a, b) => b.count - a.count)
|
||||
.slice(0, 15);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
import {inject, Injectable, OnDestroy} from '@angular/core';
|
||||
import {BehaviorSubject, EMPTY, Observable, Subject} from 'rxjs';
|
||||
import {map, takeUntil, catchError, filter, first, switchMap} from 'rxjs/operators';
|
||||
import {ChartConfiguration, ChartData} from 'chart.js';
|
||||
|
||||
import {LibraryFilterService} from './library-filter.service';
|
||||
import {BookService} from '../../book/service/book.service';
|
||||
import {Book} from '../../book/model/book.model';
|
||||
|
||||
interface SeriesStats {
|
||||
seriesName: string;
|
||||
bookCount: number;
|
||||
}
|
||||
|
||||
const CHART_COLORS = [
|
||||
'#4e79a7', '#f28e2c', '#e15759', '#76b7b2',
|
||||
'#59a14f', '#edc949', '#af7aa1', '#ff9da7',
|
||||
'#9c755f', '#bab0ab', '#5778a4', '#e69138',
|
||||
'#d62728', '#6aa7b8', '#54a24b', '#fdd247',
|
||||
'#b07aa1', '#ff9d9a', '#9e6762', '#c9b2d6'
|
||||
] as const;
|
||||
|
||||
const CHART_DEFAULTS = {
|
||||
borderWidth: 1,
|
||||
hoverBorderWidth: 2,
|
||||
hoverBorderColor: '#ffffff'
|
||||
} as const;
|
||||
|
||||
type SeriesChartData = ChartData<'bar', number[], string>;
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class TopSeriesChartService implements OnDestroy {
|
||||
private readonly bookService = inject(BookService);
|
||||
private readonly libraryFilterService = inject(LibraryFilterService);
|
||||
private readonly destroy$ = new Subject<void>();
|
||||
|
||||
public readonly seriesChartType = 'bar' as const;
|
||||
|
||||
public readonly seriesChartOptions: ChartConfiguration<'bar'>['options'] = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
indexAxis: 'y',
|
||||
scales: {
|
||||
x: {
|
||||
beginAtZero: true,
|
||||
ticks: {
|
||||
color: '#ffffff',
|
||||
font: {
|
||||
family: "'Inter', sans-serif",
|
||||
size: 11.5
|
||||
},
|
||||
precision: 0
|
||||
},
|
||||
grid: {
|
||||
color: 'rgba(255, 255, 255, 0.1)'
|
||||
},
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Number of Books',
|
||||
color: '#ffffff',
|
||||
font: {
|
||||
family: "'Inter', sans-serif",
|
||||
size: 12
|
||||
}
|
||||
}
|
||||
},
|
||||
y: {
|
||||
ticks: {
|
||||
color: '#ffffff',
|
||||
font: {
|
||||
family: "'Inter', sans-serif",
|
||||
size: 11
|
||||
},
|
||||
maxTicksLimit: 20
|
||||
},
|
||||
grid: {
|
||||
color: 'rgba(255, 255, 255, 0.05)'
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.9)',
|
||||
titleColor: '#ffffff',
|
||||
bodyColor: '#ffffff',
|
||||
borderColor: '#ffffff',
|
||||
borderWidth: 1,
|
||||
cornerRadius: 6,
|
||||
displayColors: true,
|
||||
padding: 12,
|
||||
titleFont: {size: 14, weight: 'bold'},
|
||||
bodyFont: {size: 12},
|
||||
callbacks: {
|
||||
title: (context) => {
|
||||
const dataIndex = context[0].dataIndex;
|
||||
const stats = this.getLastCalculatedStats();
|
||||
return stats[dataIndex]?.seriesName || 'Unknown Series';
|
||||
},
|
||||
label: this.formatTooltipLabel.bind(this)
|
||||
}
|
||||
},
|
||||
datalabels: {
|
||||
display: true,
|
||||
color: '#ffffff',
|
||||
font: {
|
||||
size: 10,
|
||||
family: "'Inter', sans-serif",
|
||||
weight: 'bold'
|
||||
},
|
||||
align: 'center',
|
||||
offset: 8,
|
||||
formatter: (value: number) => value.toString()
|
||||
}
|
||||
},
|
||||
interaction: {
|
||||
intersect: false,
|
||||
mode: 'point'
|
||||
}
|
||||
};
|
||||
|
||||
private readonly seriesChartDataSubject = new BehaviorSubject<SeriesChartData>({
|
||||
labels: [],
|
||||
datasets: []
|
||||
});
|
||||
|
||||
public readonly seriesChartData$: Observable<SeriesChartData> = this.seriesChartDataSubject.asObservable();
|
||||
|
||||
constructor() {
|
||||
this.bookService.bookState$
|
||||
.pipe(
|
||||
filter(state => state.loaded),
|
||||
first(),
|
||||
switchMap(() =>
|
||||
this.libraryFilterService.selectedLibrary$.pipe(
|
||||
takeUntil(this.destroy$)
|
||||
)
|
||||
),
|
||||
catchError((error) => {
|
||||
console.error('Error processing top series stats:', error);
|
||||
return EMPTY;
|
||||
})
|
||||
)
|
||||
.subscribe(() => {
|
||||
const stats = this.calculateTopSeriesStats();
|
||||
this.updateChartData(stats);
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
}
|
||||
|
||||
private updateChartData(stats: SeriesStats[]): void {
|
||||
try {
|
||||
this.lastCalculatedStats = stats;
|
||||
const labels = stats.map(s => this.truncateTitle(s.seriesName, 30));
|
||||
const dataValues = stats.map(s => s.bookCount);
|
||||
const colors = this.getColorsForData(stats.length);
|
||||
|
||||
this.seriesChartDataSubject.next({
|
||||
labels,
|
||||
datasets: [{
|
||||
label: 'Books',
|
||||
data: dataValues,
|
||||
backgroundColor: colors,
|
||||
borderColor: colors,
|
||||
...CHART_DEFAULTS
|
||||
}]
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error updating series chart data:', error);
|
||||
}
|
||||
}
|
||||
|
||||
private calculateTopSeriesStats(): SeriesStats[] {
|
||||
const currentState = this.bookService.getCurrentBookState();
|
||||
const selectedLibraryId = this.libraryFilterService.getCurrentSelectedLibrary();
|
||||
|
||||
if (!this.isValidBookState(currentState)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const filteredBooks = this.filterBooksByLibrary(currentState.books!, String(selectedLibraryId));
|
||||
return this.processTopSeriesStats(filteredBooks);
|
||||
}
|
||||
|
||||
private isValidBookState(state: any): boolean {
|
||||
return state?.loaded && state?.books && Array.isArray(state.books) && state.books.length > 0;
|
||||
}
|
||||
|
||||
private filterBooksByLibrary(books: Book[], selectedLibraryId: string | null): Book[] {
|
||||
return selectedLibraryId && selectedLibraryId !== 'null'
|
||||
? books.filter(book => String(book.libraryId) === selectedLibraryId)
|
||||
: books;
|
||||
}
|
||||
|
||||
private processTopSeriesStats(books: Book[]): SeriesStats[] {
|
||||
if (books.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const seriesMap = this.buildSeriesMap(books);
|
||||
return this.convertMapToStats(seriesMap);
|
||||
}
|
||||
|
||||
private buildSeriesMap(books: Book[]): Map<string, number> {
|
||||
const seriesMap = new Map<string, number>();
|
||||
|
||||
for (const book of books) {
|
||||
const seriesName = book.metadata?.seriesName;
|
||||
if (seriesName && seriesName.trim()) {
|
||||
const normalizedName = seriesName.trim();
|
||||
seriesMap.set(normalizedName, (seriesMap.get(normalizedName) || 0) + 1);
|
||||
}
|
||||
}
|
||||
|
||||
return seriesMap;
|
||||
}
|
||||
|
||||
private convertMapToStats(seriesMap: Map<string, number>): SeriesStats[] {
|
||||
return Array.from(seriesMap.entries())
|
||||
.map(([seriesName, bookCount]) => ({
|
||||
seriesName,
|
||||
bookCount
|
||||
}))
|
||||
.sort((a, b) => b.bookCount - a.bookCount)
|
||||
.slice(0, 20);
|
||||
}
|
||||
|
||||
private getColorsForData(dataLength: number): string[] {
|
||||
const colors = [...CHART_COLORS];
|
||||
while (colors.length < dataLength) {
|
||||
colors.push(...CHART_COLORS);
|
||||
}
|
||||
return colors.slice(0, dataLength);
|
||||
}
|
||||
|
||||
private formatTooltipLabel(context: any): string {
|
||||
const dataIndex = context.dataIndex;
|
||||
const stats = this.getLastCalculatedStats();
|
||||
|
||||
if (!stats || dataIndex >= stats.length) {
|
||||
return `${context.parsed.x} books`;
|
||||
}
|
||||
|
||||
const series = stats[dataIndex];
|
||||
const bookCount = series.bookCount;
|
||||
const bookText = bookCount === 1 ? 'book' : 'books';
|
||||
|
||||
return `${bookCount} ${bookText}`;
|
||||
}
|
||||
|
||||
private lastCalculatedStats: SeriesStats[] = [];
|
||||
|
||||
private getLastCalculatedStats(): SeriesStats[] {
|
||||
return this.lastCalculatedStats;
|
||||
}
|
||||
|
||||
private truncateTitle(title: string, maxLength: number): string {
|
||||
return title.length > maxLength ? title.substring(0, maxLength) + '...' : title;
|
||||
}
|
||||
}
|
||||
438
booklore-ui/src/app/stats-component/stats-component.html
Normal file
438
booklore-ui/src/app/stats-component/stats-component.html
Normal file
@@ -0,0 +1,438 @@
|
||||
<div class="stats-container">
|
||||
<div class="header-card">
|
||||
<div class="filter-section">
|
||||
<div class="library-filter">
|
||||
<div class="select-row">
|
||||
<label for="library-dropdown">Library Statistics</label>
|
||||
<p-select
|
||||
id="library-dropdown"
|
||||
[options]="libraryOptions"
|
||||
[(ngModel)]="selectedLibrary"
|
||||
optionLabel="name"
|
||||
size="small"
|
||||
placeholder="Select a library"
|
||||
(onChange)="onLibraryChange()"
|
||||
appendTo="body"
|
||||
[style]="{'min-width': '200px'}"
|
||||
class="library-dropdown">
|
||||
</p-select>
|
||||
|
||||
<p-button
|
||||
outlined
|
||||
size="small"
|
||||
(onClick)="toggleConfigPanel()"
|
||||
icon="pi pi-cog">
|
||||
</p-button>
|
||||
</div>
|
||||
|
||||
<div class="library-totals" role="status" aria-live="polite">
|
||||
<div class="total-item total-books" title="Total books">
|
||||
<i class="pi pi-book"></i>
|
||||
<span class="total-label">Books</span>
|
||||
<span class="total-value">{{ totalBooks$ | async }}</span>
|
||||
</div>
|
||||
|
||||
<div class="total-item total-authors" title="Total authors">
|
||||
<i class="pi pi-users"></i>
|
||||
<span class="total-label">Authors</span>
|
||||
<span class="total-value">{{ totalAuthors$ | async }}</span>
|
||||
</div>
|
||||
|
||||
<div class="total-item total-series" title="Total series">
|
||||
<i class="pi pi-bookmark"></i>
|
||||
<span class="total-label">Series</span>
|
||||
<span class="total-value">{{ totalSeries$ | async }}</span>
|
||||
</div>
|
||||
|
||||
<div class="total-item total-publishers" title="Total publishers">
|
||||
<i class="pi pi-building"></i>
|
||||
<span class="total-label">Publishers</span>
|
||||
<span class="total-value">{{ totalPublishers$ | async }}</span>
|
||||
</div>
|
||||
|
||||
<div class="total-item total-size" title="Total size">
|
||||
<i class="pi pi-file"></i>
|
||||
<span class="total-label">Library Size</span>
|
||||
<span class="total-value">{{ totalSize$ | async }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Chart Configuration Modal Overlay -->
|
||||
@if (showConfigPanel) {
|
||||
<div class="config-modal-overlay" (click)="closeConfigPanel()">
|
||||
<div class="config-modal" (click)="$event.stopPropagation()">
|
||||
<div class="config-modal-header">
|
||||
<h3><i class="pi pi-cog"></i> Chart Configuration</h3>
|
||||
<button type="button" class="close-btn" (click)="closeConfigPanel()" title="Close">
|
||||
<i class="pi pi-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="config-modal-actions">
|
||||
<button type="button" class="config-action-btn enable-all" (click)="enableAllCharts()">
|
||||
<i class="pi pi-check"></i> Enable All
|
||||
</button>
|
||||
<button type="button" class="config-action-btn disable-all" (click)="disableAllCharts()">
|
||||
<i class="pi pi-times"></i> Disable All
|
||||
</button>
|
||||
<button type="button" class="config-action-btn reset-order" (click)="resetChartOrder()">
|
||||
<i class="pi pi-refresh"></i> Reset Order
|
||||
</button>
|
||||
<button type="button" class="config-action-btn reset-positions" (click)="resetChartPositions()">
|
||||
<i class="pi pi-map"></i> Reset Positions
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="config-modal-content">
|
||||
<div class="config-categories">
|
||||
<div class="config-category">
|
||||
<h4>Small Charts</h4>
|
||||
<div class="chart-toggles">
|
||||
@for (chart of getChartsByCategory('small'); track chart.id) {
|
||||
<div class="chart-toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
[id]="'chart-' + chart.id"
|
||||
[checked]="chart.enabled"
|
||||
(change)="toggleChart(chart.id)">
|
||||
<label [for]="'chart-' + chart.id">{{ chart.name }}</label>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="config-category">
|
||||
<h4>Medium Charts</h4>
|
||||
<div class="chart-toggles">
|
||||
@for (chart of getChartsByCategory('medium'); track chart.id) {
|
||||
<div class="chart-toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
[id]="'chart-' + chart.id"
|
||||
[checked]="chart.enabled"
|
||||
(change)="toggleChart(chart.id)">
|
||||
<label [for]="'chart-' + chart.id">{{ chart.name }}</label>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="config-category">
|
||||
<h4>Large Charts</h4>
|
||||
<div class="chart-toggles">
|
||||
@for (chart of getChartsByCategory('large'); track chart.id) {
|
||||
<div class="chart-toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
[id]="'chart-' + chart.id"
|
||||
[checked]="chart.enabled"
|
||||
(change)="toggleChart(chart.id)">
|
||||
<label [for]="'chart-' + chart.id">{{ chart.name }}</label>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="config-category">
|
||||
<h4>Full Width Charts</h4>
|
||||
<div class="chart-toggles">
|
||||
@for (chart of getChartsByCategory('full-width'); track chart.id) {
|
||||
<div class="chart-toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
[id]="'chart-' + chart.id"
|
||||
[checked]="chart.enabled"
|
||||
(change)="toggleChart(chart.id)">
|
||||
<label [for]="'chart-' + chart.id">{{ chart.name }}</label>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!isLoading && hasData) {
|
||||
<div class="charts-grid"
|
||||
cdkDropList
|
||||
(cdkDropListDropped)="onChartReorder($event)"
|
||||
[cdkDropListData]="getEnabledChartsSorted()">
|
||||
|
||||
@for (chartConfig of getEnabledChartsSorted(); track chartConfig.id) {
|
||||
<div class="chart-container"
|
||||
cdkDrag
|
||||
[cdkDragData]="chartConfig"
|
||||
[class]="'chart-section ' + chartConfig.category">
|
||||
|
||||
<div class="drag-handle" cdkDragHandle title="Drag to reorder">
|
||||
<i class="pi pi-bars"></i>
|
||||
</div>
|
||||
|
||||
@switch (chartConfig.id) {
|
||||
@case ('readingStatus') {
|
||||
<h3>Reading Status</h3>
|
||||
<div class="chart-wrapper status-chart">
|
||||
<canvas baseChart
|
||||
[data]="(readStatusChartService.statusChartData$ | async) ?? {labels: [], datasets: []}"
|
||||
[options]="readStatusChartService.statusChartOptions"
|
||||
[type]="readStatusChartService.statusChartType">
|
||||
</canvas>
|
||||
</div>
|
||||
}
|
||||
|
||||
@case ('bookFormats') {
|
||||
<h3>Book Formats</h3>
|
||||
<div class="chart-wrapper book-type-chart">
|
||||
<canvas baseChart
|
||||
[data]="(bookTypeChartService.bookTypeChartData$ | async) ?? {labels: [], datasets: []}"
|
||||
[options]="bookTypeChartService.bookTypeChartOptions"
|
||||
[type]="bookTypeChartService.bookTypeChartType">
|
||||
</canvas>
|
||||
</div>
|
||||
}
|
||||
|
||||
@case ('languageDistribution') {
|
||||
<h3>Language Distribution</h3>
|
||||
<div class="chart-wrapper language-chart">
|
||||
<canvas baseChart
|
||||
[data]="(languageDistributionChartService.languageChartData$ | async) ?? {labels: [], datasets: []}"
|
||||
[options]="languageDistributionChartService.languageChartOptions"
|
||||
[type]="languageDistributionChartService.languageChartType">
|
||||
</canvas>
|
||||
</div>
|
||||
}
|
||||
|
||||
@case ('bookMetadataScore') {
|
||||
<h3>Book Metadata Score</h3>
|
||||
<div class="chart-wrapper quality-chart">
|
||||
<canvas baseChart
|
||||
[data]="(bookQualityScoreChartService.qualityChartData$ | async) ?? {labels: [], datasets: []}"
|
||||
[options]="bookQualityScoreChartService.qualityChartOptions"
|
||||
[type]="bookQualityScoreChartService.qualityChartType">
|
||||
</canvas>
|
||||
</div>
|
||||
}
|
||||
|
||||
@case ('topAuthors') {
|
||||
<h3>Top 25 Authors (with Reading Breakdown)</h3>
|
||||
<div class="chart-wrapper author-chart">
|
||||
<canvas baseChart
|
||||
[data]="(authorPopularityChartService.authorChartData$ | async) ?? {labels: [], datasets: []}"
|
||||
[options]="authorPopularityChartService.authorChartOptions"
|
||||
[type]="authorPopularityChartService.authorChartType">
|
||||
</canvas>
|
||||
</div>
|
||||
}
|
||||
|
||||
@case ('topCategories') {
|
||||
<h3>Top 25 Categories (with Reading Breakdown)</h3>
|
||||
<div class="chart-wrapper completion-chart">
|
||||
<canvas baseChart
|
||||
[data]="(readingCompletionChartService.completionChartData$ | async) ?? {labels: [], datasets: []}"
|
||||
[options]="readingCompletionChartService.completionChartOptions"
|
||||
[type]="readingCompletionChartService.completionChartType">
|
||||
</canvas>
|
||||
</div>
|
||||
}
|
||||
|
||||
@case ('topBooksBySize') {
|
||||
<h3>Top 20 Largest Books by File Size</h3>
|
||||
<div class="chart-wrapper book-size-chart">
|
||||
<canvas baseChart
|
||||
[data]="(bookSizeChartService.bookSizeChartData$ | async) ?? {labels: [], datasets: []}"
|
||||
[options]="bookSizeChartService.bookSizeChartOptions"
|
||||
[type]="bookSizeChartService.bookSizeChartType">
|
||||
</canvas>
|
||||
</div>
|
||||
}
|
||||
|
||||
@case ('monthlyReadingPatterns') {
|
||||
<h3>Monthly Reading Patterns</h3>
|
||||
<div class="chart-wrapper monthly-patterns-chart">
|
||||
<canvas baseChart
|
||||
[data]="(monthlyReadingPatternsChartService.monthlyPatternsChartData$ | async) ?? {labels: [], datasets: []}"
|
||||
[options]="monthlyReadingPatternsChartService.monthlyPatternsChartOptions"
|
||||
[type]="monthlyReadingPatternsChartService.monthlyPatternsChartType">
|
||||
</canvas>
|
||||
</div>
|
||||
}
|
||||
|
||||
@case ('readingProgress') {
|
||||
<h3>Reading Progress</h3>
|
||||
<div class="chart-wrapper progress-chart">
|
||||
<canvas baseChart
|
||||
[data]="(readingProgressChartService.progressChartData$ | async) ?? {labels: [], datasets: []}"
|
||||
[options]="readingProgressChartService.progressChartOptions"
|
||||
[type]="readingProgressChartService.progressChartType">
|
||||
</canvas>
|
||||
</div>
|
||||
}
|
||||
|
||||
@case ('externalRating') {
|
||||
<h3>External Rating Distribution</h3>
|
||||
<div class="chart-wrapper rating-chart">
|
||||
<canvas baseChart
|
||||
[data]="(bookRatingChartService.ratingChartData$ | async) ?? {labels: [], datasets: []}"
|
||||
[options]="bookRatingChartService.ratingChartOptions"
|
||||
[type]="bookRatingChartService.ratingChartType">
|
||||
</canvas>
|
||||
</div>
|
||||
}
|
||||
|
||||
@case ('personalRating') {
|
||||
<h3>Personal Rating Distribution</h3>
|
||||
<div class="chart-wrapper personal-rating-chart">
|
||||
<canvas baseChart
|
||||
[data]="(personalRatingChartService.personalRatingChartData$ | async) ?? {labels: [], datasets: []}"
|
||||
[options]="personalRatingChartService.personalRatingChartOptions"
|
||||
[type]="personalRatingChartService.personalRatingChartType">
|
||||
</canvas>
|
||||
</div>
|
||||
}
|
||||
|
||||
@case ('pageCount') {
|
||||
<h3>Page Count Distribution</h3>
|
||||
<div class="chart-wrapper page-count-chart">
|
||||
<canvas baseChart
|
||||
[data]="(pageCountChartService.pageCountChartData$ | async) ?? {labels: [], datasets: []}"
|
||||
[options]="pageCountChartService.pageCountChartOptions"
|
||||
[type]="pageCountChartService.pageCountChartType">
|
||||
</canvas>
|
||||
</div>
|
||||
}
|
||||
|
||||
@case ('readingVelocityTimeline') {
|
||||
<h3>Reading Velocity Timeline</h3>
|
||||
<div class="chart-wrapper velocity-timeline-chart">
|
||||
<canvas baseChart
|
||||
[data]="(readingVelocityTimelineChartService.velocityTimelineChartData$ | async) ?? {labels: [], datasets: []}"
|
||||
[options]="readingVelocityTimelineChartService.velocityTimelineChartOptions"
|
||||
[type]="readingVelocityTimelineChartService.velocityTimelineChartType">
|
||||
</canvas>
|
||||
</div>
|
||||
}
|
||||
|
||||
@case ('topSeries') {
|
||||
<h3>Top 20 Series by Book Count</h3>
|
||||
<div class="chart-wrapper top-series-chart">
|
||||
<canvas baseChart
|
||||
[data]="(topSeriesChartService.seriesChartData$ | async) ?? {labels: [], datasets: []}"
|
||||
[options]="topSeriesChartService.seriesChartOptions"
|
||||
[type]="topSeriesChartService.seriesChartType">
|
||||
</canvas>
|
||||
</div>
|
||||
}
|
||||
|
||||
@case ('publicationYear') {
|
||||
<h3>Publication Year Timeline</h3>
|
||||
<div class="chart-wrapper year-chart">
|
||||
<canvas baseChart
|
||||
[data]="(publicationYearChartService.yearChartData$ | async) ?? {labels: [], datasets: []}"
|
||||
[options]="publicationYearChartService.yearChartOptions"
|
||||
[type]="publicationYearChartService.yearChartType"
|
||||
[plugins]="[ChartDataLabels]">
|
||||
</canvas>
|
||||
</div>
|
||||
}
|
||||
|
||||
@case ('finishedBooksTimeline') {
|
||||
<h3>Books Finished Timeline (Per Month)</h3>
|
||||
<div class="chart-wrapper finished-books-timeline-chart">
|
||||
<canvas baseChart
|
||||
[data]="(finishedBooksTimelineChartService.finishedBooksChartData$ | async) ?? {labels: [], datasets: []}"
|
||||
[options]="finishedBooksTimelineChartService.finishedBooksChartOptions"
|
||||
[type]="finishedBooksTimelineChartService.finishedBooksChartType"
|
||||
[plugins]="[ChartDataLabels]">
|
||||
</canvas>
|
||||
</div>
|
||||
}
|
||||
|
||||
@case ('readingDNA') {
|
||||
<h3>Your Reading DNA Profile</h3>
|
||||
<div class="chart-description">
|
||||
Discover your unique reading personality based on behavioral patterns and preferences
|
||||
</div>
|
||||
<div class="chart-wrapper reading-dna-chart">
|
||||
<canvas baseChart
|
||||
[data]="(readingDNAChartService.readingDNAChartData$ | async) ?? {labels: [], datasets: []}"
|
||||
[options]="readingDNAChartService.readingDNAChartOptions"
|
||||
[type]="readingDNAChartService.readingDNAChartType">
|
||||
</canvas>
|
||||
</div>
|
||||
<div class="dna-insights">
|
||||
@if ((readingDNAChartService.readingDNAChartData$ | async)?.datasets?.[0]?.data?.length) {
|
||||
<div class="insights-grid">
|
||||
@for (insight of readingDNAChartService.getPersonalityInsights(); track trackByTrait($index, insight)) {
|
||||
<div class="insight-card">
|
||||
<div class="insight-header">
|
||||
<div class="trait-dot" [style.background-color]="insight.color"></div>
|
||||
<span class="trait-name">{{ insight.trait }}</span>
|
||||
<span class="trait-score">{{ insight.score.toFixed(0) }}%</span>
|
||||
</div>
|
||||
<div class="insight-bar">
|
||||
<div class="insight-fill" [style.width.%]="insight.score" [style.background-color]="insight.color"></div>
|
||||
</div>
|
||||
<p class="insight-description">{{ insight.description }}</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@case ('readingHabits') {
|
||||
<h3>Reading Habits Analysis</h3>
|
||||
<div class="chart-description">
|
||||
Analyze your behavioral reading patterns, routines, and systematic approaches
|
||||
</div>
|
||||
<div class="chart-wrapper reading-habits-chart">
|
||||
<canvas baseChart
|
||||
[data]="(readingHabitsChartService.readingHabitsChartData$ | async) ?? {labels: [], datasets: []}"
|
||||
[options]="readingHabitsChartService.readingHabitsChartOptions"
|
||||
[type]="readingHabitsChartService.readingHabitsChartType">
|
||||
</canvas>
|
||||
</div>
|
||||
<div class="dna-insights">
|
||||
@if ((readingHabitsChartService.readingHabitsChartData$ | async)?.datasets?.[0]?.data?.length) {
|
||||
<div class="insights-grid">
|
||||
@for (habit of readingHabitsChartService.getHabitInsights(); track trackByHabit($index, habit)) {
|
||||
<div class="insight-card">
|
||||
<div class="insight-header">
|
||||
<div class="trait-dot" [style.background-color]="habit.color"></div>
|
||||
<span class="trait-name">{{ habit.habit }}</span>
|
||||
<span class="trait-score">{{ habit.score.toFixed(0) }}%</span>
|
||||
</div>
|
||||
<div class="insight-bar">
|
||||
<div class="insight-fill" [style.width.%]="habit.score" [style.background-color]="habit.color"></div>
|
||||
</div>
|
||||
<p class="insight-description">{{ habit.description }}</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (isLoading) {
|
||||
<div class="loading-message">
|
||||
Loading statistics...
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!isLoading && !hasData) {
|
||||
<div class="no-data-message">
|
||||
No data available.
|
||||
<small>Make sure your library contains books with metadata.</small>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
1017
booklore-ui/src/app/stats-component/stats-component.scss
Normal file
1017
booklore-ui/src/app/stats-component/stats-component.scss
Normal file
File diff suppressed because it is too large
Load Diff
222
booklore-ui/src/app/stats-component/stats-component.ts
Normal file
222
booklore-ui/src/app/stats-component/stats-component.ts
Normal file
@@ -0,0 +1,222 @@
|
||||
import {Component, inject, OnDestroy, OnInit, ViewChild} from '@angular/core';
|
||||
import {BaseChartDirective} from 'ng2-charts';
|
||||
import {Chart, registerables, Tooltip} from 'chart.js';
|
||||
import {CommonModule} from '@angular/common';
|
||||
import {FormsModule} from '@angular/forms';
|
||||
import {catchError, map, of, startWith, Subject, takeUntil} from 'rxjs';
|
||||
import {CdkDragDrop, DragDropModule} from '@angular/cdk/drag-drop';
|
||||
import {LibraryFilterService, LibraryOption} from './charts-service/library-filter.service';
|
||||
import {LibrariesSummaryService} from './charts-service/libraries-summary.service';
|
||||
import {Select} from 'primeng/select';
|
||||
import {ReadStatusChartService} from './charts-service/read-status-chart.service';
|
||||
import {BookTypeChartService} from './charts-service/book-type-chart.service';
|
||||
import {ReadingProgressChartService} from './charts-service/reading-progress-chart-service';
|
||||
import {PageCountChartService} from './charts-service/page-count-chart.service';
|
||||
import {BookRatingChartService} from './charts-service/book-rating-chart.service';
|
||||
import {PersonalRatingChartService} from './charts-service/personal-rating-chart.service';
|
||||
import {PublicationYearChartService} from './charts-service/publication-year-chart-service';
|
||||
import {AuthorPopularityChartService} from './charts-service/author-popularity-chart.service';
|
||||
import {ReadingCompletionChartService} from './charts-service/reading-completion-chart.service';
|
||||
import {LanguageDistributionChartService} from './charts-service/language-distribution-chart.service';
|
||||
import {BookQualityScoreChartService} from './charts-service/book-quality-score-chart.service';
|
||||
import {BookSizeChartService} from './charts-service/book-size-chart.service';
|
||||
import {ReadingVelocityTimelineChartService} from './charts-service/reading-velocity-timeline-chart.service';
|
||||
import {MonthlyReadingPatternsChartService} from './charts-service/monthly-reading-patterns-chart.service';
|
||||
import {TopSeriesChartService} from './charts-service/top-series-chart.service';
|
||||
import {FinishedBooksTimelineChartService} from './charts-service/finished-books-timeline-chart.service';
|
||||
import ChartDataLabels from 'chartjs-plugin-datalabels';
|
||||
import {ReadingDNAChartService} from './charts-service/reading-dna-chart.service';
|
||||
import {ReadingHabitsChartService} from './charts-service/reading-habits-chart.service';
|
||||
import {ChartConfigService, ChartConfig} from './charts-service/chart-config.service';
|
||||
import {Button} from 'primeng/button';
|
||||
|
||||
@Component({
|
||||
selector: 'app-stats-component',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
BaseChartDirective,
|
||||
Select,
|
||||
DragDropModule,
|
||||
Button
|
||||
],
|
||||
templateUrl: './stats-component.html',
|
||||
styleUrls: ['./stats-component.scss']
|
||||
})
|
||||
export class StatsComponent implements OnInit, OnDestroy {
|
||||
|
||||
@ViewChild(BaseChartDirective) chart: BaseChartDirective | undefined;
|
||||
|
||||
private readonly libraryFilterService = inject(LibraryFilterService);
|
||||
private readonly librariesSummaryService = inject(LibrariesSummaryService);
|
||||
protected readonly readStatusChartService = inject(ReadStatusChartService);
|
||||
protected readonly bookTypeChartService = inject(BookTypeChartService);
|
||||
protected readonly readingProgressChartService = inject(ReadingProgressChartService);
|
||||
protected readonly pageCountChartService = inject(PageCountChartService);
|
||||
protected readonly bookRatingChartService = inject(BookRatingChartService);
|
||||
protected readonly personalRatingChartService = inject(PersonalRatingChartService);
|
||||
protected readonly publicationYearChartService = inject(PublicationYearChartService);
|
||||
protected readonly authorPopularityChartService = inject(AuthorPopularityChartService);
|
||||
protected readonly readingCompletionChartService = inject(ReadingCompletionChartService);
|
||||
protected readonly languageDistributionChartService = inject(LanguageDistributionChartService);
|
||||
protected readonly bookQualityScoreChartService = inject(BookQualityScoreChartService);
|
||||
protected readonly bookSizeChartService = inject(BookSizeChartService);
|
||||
protected readonly readingVelocityTimelineChartService = inject(ReadingVelocityTimelineChartService);
|
||||
protected readonly monthlyReadingPatternsChartService = inject(MonthlyReadingPatternsChartService);
|
||||
protected readonly topSeriesChartService = inject(TopSeriesChartService);
|
||||
protected readonly finishedBooksTimelineChartService = inject(FinishedBooksTimelineChartService);
|
||||
protected readonly readingDNAChartService = inject(ReadingDNAChartService);
|
||||
protected readonly readingHabitsChartService = inject(ReadingHabitsChartService);
|
||||
protected readonly chartConfigService = inject(ChartConfigService);
|
||||
private readonly destroy$ = new Subject<void>();
|
||||
|
||||
public isLoading = true;
|
||||
public hasData = false;
|
||||
public hasError = false;
|
||||
public libraryOptions: LibraryOption[] = [];
|
||||
public selectedLibrary: LibraryOption | null = null;
|
||||
public showConfigPanel = false;
|
||||
public chartsConfig: ChartConfig[] = [];
|
||||
|
||||
booksSummary$ = this.librariesSummaryService.getBooksSummary().pipe(
|
||||
catchError(error => {
|
||||
console.error('Error loading books summary:', error);
|
||||
this.hasError = true;
|
||||
return of({totalBooks: 0, totalSizeKb: 0, totalAuthors: 0, totalSeries: 0, totalPublishers: 0});
|
||||
})
|
||||
);
|
||||
|
||||
public readonly totalBooks$ = this.booksSummary$.pipe(map(summary => summary.totalBooks));
|
||||
public readonly totalAuthors$ = this.booksSummary$.pipe(map(summary => summary.totalAuthors));
|
||||
public readonly totalSeries$ = this.booksSummary$.pipe(map(summary => summary.totalSeries));
|
||||
public readonly totalPublishers$ = this.booksSummary$.pipe(map(summary => summary.totalPublishers));
|
||||
public readonly totalSize$ = this.librariesSummaryService.getFormattedSize().pipe(catchError(() => of('0 KB')));
|
||||
|
||||
ngOnInit(): void {
|
||||
Chart.register(...registerables, Tooltip, ChartDataLabels);
|
||||
Chart.defaults.plugins.legend.labels.font = {
|
||||
family: "'Inter', sans-serif",
|
||||
size: 11.5,
|
||||
};
|
||||
this.loadLibraryOptions();
|
||||
this.loadChartConfig();
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
}
|
||||
|
||||
onLibraryChange(): void {
|
||||
if (!this.selectedLibrary) {
|
||||
return;
|
||||
}
|
||||
const libraryId = this.selectedLibrary.id;
|
||||
this.libraryFilterService.setSelectedLibrary(libraryId);
|
||||
}
|
||||
|
||||
private loadLibraryOptions(): void {
|
||||
this.libraryFilterService.getLibraryOptions()
|
||||
.pipe(
|
||||
takeUntil(this.destroy$),
|
||||
startWith([]),
|
||||
catchError(error => {
|
||||
console.error('Error loading library options:', error);
|
||||
this.hasError = true;
|
||||
this.isLoading = false;
|
||||
return of([]);
|
||||
})
|
||||
)
|
||||
.subscribe({
|
||||
next: (options) => {
|
||||
this.libraryOptions = options;
|
||||
this.initializeSelectedLibrary(options);
|
||||
},
|
||||
error: (error) => {
|
||||
console.error('Subscription error:', error);
|
||||
this.hasError = true;
|
||||
this.isLoading = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private initializeSelectedLibrary(options: LibraryOption[]): void {
|
||||
if (options.length === 0) {
|
||||
this.hasData = false;
|
||||
this.isLoading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.selectedLibrary) {
|
||||
this.hasData = true;
|
||||
this.isLoading = false;
|
||||
this.selectedLibrary = options[0];
|
||||
this.libraryFilterService.setSelectedLibrary(this.selectedLibrary.id);
|
||||
}
|
||||
}
|
||||
|
||||
private loadChartConfig(): void {
|
||||
this.chartConfigService.chartsConfig$
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe(config => {
|
||||
this.chartsConfig = config;
|
||||
});
|
||||
}
|
||||
|
||||
public toggleConfigPanel(): void {
|
||||
this.showConfigPanel = !this.showConfigPanel;
|
||||
}
|
||||
|
||||
public closeConfigPanel(): void {
|
||||
this.showConfigPanel = false;
|
||||
}
|
||||
|
||||
public toggleChart(chartId: string): void {
|
||||
this.chartConfigService.toggleChart(chartId);
|
||||
}
|
||||
|
||||
public isChartEnabled(chartId: string): boolean {
|
||||
return this.chartConfigService.isChartEnabled(chartId);
|
||||
}
|
||||
|
||||
public enableAllCharts(): void {
|
||||
this.chartConfigService.enableAllCharts();
|
||||
}
|
||||
|
||||
public disableAllCharts(): void {
|
||||
this.chartConfigService.disableAllCharts();
|
||||
}
|
||||
|
||||
public getChartsByCategory(category: string): ChartConfig[] {
|
||||
return this.chartsConfig.filter(chart => chart.category === category);
|
||||
}
|
||||
|
||||
public getEnabledChartsSorted(): ChartConfig[] {
|
||||
return this.chartConfigService.getEnabledChartsSorted();
|
||||
}
|
||||
|
||||
public onChartReorder(event: CdkDragDrop<ChartConfig[]>): void {
|
||||
if (event.previousIndex !== event.currentIndex) {
|
||||
this.chartConfigService.reorderCharts(event.previousIndex, event.currentIndex);
|
||||
}
|
||||
}
|
||||
|
||||
public resetChartOrder(): void {
|
||||
this.chartConfigService.resetOrder();
|
||||
}
|
||||
|
||||
public resetChartPositions(): void {
|
||||
this.chartConfigService.resetPositions();
|
||||
}
|
||||
|
||||
trackByTrait(index: number, insight: any): string {
|
||||
return insight.trait;
|
||||
}
|
||||
|
||||
trackByHabit(index: number, habit: any): string {
|
||||
return habit.habit;
|
||||
}
|
||||
|
||||
protected readonly ChartDataLabels = ChartDataLabels;
|
||||
}
|
||||
@@ -17,9 +17,12 @@ import {provideOAuthClient} from 'angular-oauth2-oidc';
|
||||
import {APP_INITIALIZER, provideAppInitializer} from '@angular/core';
|
||||
import {initializeAuthFactory} from './app/auth-initializer';
|
||||
import {StartupService} from './app/core/service/startup.service';
|
||||
import {provideCharts, withDefaultRegisterables} from 'ng2-charts';
|
||||
import ChartDataLabels from 'chartjs-plugin-datalabels';
|
||||
|
||||
bootstrapApplication(AppComponent, {
|
||||
providers: [
|
||||
provideCharts(withDefaultRegisterables(), ChartDataLabels),
|
||||
{
|
||||
provide: APP_INITIALIZER,
|
||||
useFactory: websocketInitializer,
|
||||
|
||||
Reference in New Issue
Block a user