Merge pull request #1090 from booklore-app/develop

Merge develop into master for the release
This commit is contained in:
Aditya Chandel
2025-09-05 14:20:15 -06:00
committed by GitHub
41 changed files with 9685 additions and 20 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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]},
]
},
{

View File

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

View File

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

View File

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

View File

@@ -97,4 +97,3 @@
</div>
}
</div>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

File diff suppressed because it is too large Load Diff

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

View File

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