mirror of
https://github.com/booklore-app/booklore.git
synced 2025-12-23 14:20:48 -05:00
Add 'Books Finished per Month' heat map chart
This commit is contained in:
committed by
Aditya Chandel
parent
0db2913e28
commit
af622d9f75
10
booklore-ui/package-lock.json
generated
10
booklore-ui/package-lock.json
generated
@@ -25,6 +25,7 @@
|
||||
"@tweenjs/tween.js": "^25.0.0",
|
||||
"angular-oauth2-oidc": "^20.0.0",
|
||||
"chart.js": "^4.5.0",
|
||||
"chartjs-chart-matrix": "^3.0.0",
|
||||
"chartjs-plugin-datalabels": "^2.2.0",
|
||||
"epubjs": "^0.3.93",
|
||||
"jwt-decode": "^4.0.0",
|
||||
@@ -7450,6 +7451,15 @@
|
||||
"pnpm": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/chartjs-chart-matrix": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/chartjs-chart-matrix/-/chartjs-chart-matrix-3.0.0.tgz",
|
||||
"integrity": "sha512-lUWC1UaWkxGdG02dBJ5r1ppbSYB/uWmwAh11VEs7V3ZQItNCk4am+rmacwkgeb+SQeEj2hP9Qq4oGsUmPl/1lQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"chart.js": ">=3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/chartjs-plugin-datalabels": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/chartjs-plugin-datalabels/-/chartjs-plugin-datalabels-2.2.0.tgz",
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
"@tweenjs/tween.js": "^25.0.0",
|
||||
"angular-oauth2-oidc": "^20.0.0",
|
||||
"chart.js": "^4.5.0",
|
||||
"chartjs-chart-matrix": "^3.0.0",
|
||||
"chartjs-plugin-datalabels": "^2.2.0",
|
||||
"epubjs": "^0.3.93",
|
||||
"jwt-decode": "^4.0.0",
|
||||
|
||||
@@ -18,8 +18,8 @@ export class ChartConfigService {
|
||||
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: 'bookMetadataScore', name: 'Book Metadata Score', enabled: true, category: 'small', order: 2},
|
||||
{id: 'readingHeatmap', name: 'Books Finished per Month', category: 'small', enabled: true, 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},
|
||||
@@ -33,7 +33,7 @@ export class ChartConfigService {
|
||||
{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}
|
||||
{id: 'languageDistribution', name: 'Language Distribution', enabled: true, category: 'small', order: 17}
|
||||
];
|
||||
|
||||
private chartsConfigSubject = new BehaviorSubject<ChartConfig[]>(this.loadConfig());
|
||||
|
||||
@@ -1,248 +0,0 @@
|
||||
import {inject, Injectable, OnDestroy} from '@angular/core';
|
||||
import {BehaviorSubject, EMPTY, Observable, Subject} from 'rxjs';
|
||||
import {map, takeUntil, catchError, filter, first, switchMap} from 'rxjs/operators';
|
||||
import {ChartConfiguration, ChartData, ChartType} from 'chart.js';
|
||||
|
||||
import {LibraryFilterService} from './library-filter.service';
|
||||
import {BookService} from '../../book/service/book.service';
|
||||
import {Book} from '../../book/model/book.model';
|
||||
|
||||
interface FinishedBooksStats {
|
||||
yearMonth: string;
|
||||
count: number;
|
||||
year: number;
|
||||
month: number;
|
||||
}
|
||||
|
||||
const CHART_COLORS = {
|
||||
primary: '#EF476F',
|
||||
primaryBackground: 'rgba(239, 71, 111, 0.1)',
|
||||
border: '#ffffff'
|
||||
} as const;
|
||||
|
||||
const CHART_DEFAULTS = {
|
||||
borderColor: CHART_COLORS.primary,
|
||||
backgroundColor: CHART_COLORS.primaryBackground,
|
||||
borderWidth: 2,
|
||||
pointBackgroundColor: CHART_COLORS.primary,
|
||||
pointBorderColor: CHART_COLORS.border,
|
||||
pointBorderWidth: 2,
|
||||
pointRadius: 4,
|
||||
pointHoverRadius: 6,
|
||||
fill: true,
|
||||
tension: 0.4
|
||||
} as const;
|
||||
|
||||
type FinishedBooksChartData = ChartData<'line', number[], string>;
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class FinishedBooksTimelineChartService implements OnDestroy {
|
||||
private readonly bookService = inject(BookService);
|
||||
private readonly libraryFilterService = inject(LibraryFilterService);
|
||||
private readonly destroy$ = new Subject<void>();
|
||||
|
||||
public readonly finishedBooksChartType = 'line' as const;
|
||||
|
||||
public readonly finishedBooksChartOptions: ChartConfiguration['options'] = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {display: false},
|
||||
tooltip: {
|
||||
enabled: true,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.9)',
|
||||
titleColor: '#ffffff',
|
||||
bodyColor: '#ffffff',
|
||||
borderColor: '#ffffff',
|
||||
borderWidth: 1,
|
||||
cornerRadius: 6,
|
||||
displayColors: true,
|
||||
padding: 12,
|
||||
titleFont: {size: 14, weight: 'bold'},
|
||||
bodyFont: {size: 13},
|
||||
position: 'nearest',
|
||||
callbacks: {
|
||||
title: (context) => {
|
||||
return context[0].label; // Already formatted as "Month Year"
|
||||
},
|
||||
label: this.formatTooltipLabel.bind(this)
|
||||
}
|
||||
},
|
||||
datalabels: {
|
||||
display: true,
|
||||
color: '#ffffff',
|
||||
font: {
|
||||
size: 10,
|
||||
weight: 'bold'
|
||||
},
|
||||
align: 'top',
|
||||
offset: 8,
|
||||
formatter: (value: number) => value.toString()
|
||||
}
|
||||
},
|
||||
interaction: {
|
||||
intersect: false,
|
||||
mode: 'point'
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
beginAtZero: true,
|
||||
ticks: {
|
||||
color: '#ffffff',
|
||||
font: {
|
||||
family: "'Inter', sans-serif",
|
||||
size: 11.5
|
||||
},
|
||||
maxRotation: 45,
|
||||
callback: function (value, index, values) {
|
||||
const totalLabels = values.length;
|
||||
const skipInterval = totalLabels > 24 ? Math.ceil(totalLabels / 12) : totalLabels > 12 ? 2 : 1;
|
||||
return index % skipInterval === 0 ? this.getLabelForValue(value as number) : '';
|
||||
}
|
||||
},
|
||||
grid: {color: 'rgba(255, 255, 255, 0.1)'},
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Month',
|
||||
color: '#ffffff',
|
||||
font: {
|
||||
family: "'Inter', sans-serif",
|
||||
size: 11.5
|
||||
}
|
||||
}
|
||||
},
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
ticks: {
|
||||
color: '#ffffff',
|
||||
font: {
|
||||
family: "'Inter', sans-serif",
|
||||
size: 11.5
|
||||
},
|
||||
stepSize: 1
|
||||
},
|
||||
grid: {color: 'rgba(255, 255, 255, 0.05)'},
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Books Finished',
|
||||
color: '#ffffff',
|
||||
font: {
|
||||
family: "'Inter', sans-serif",
|
||||
size: 11.5
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
private readonly finishedBooksChartDataSubject = new BehaviorSubject<FinishedBooksChartData>({
|
||||
labels: [],
|
||||
datasets: [{
|
||||
label: 'Books Finished',
|
||||
data: [],
|
||||
...CHART_DEFAULTS
|
||||
}]
|
||||
});
|
||||
|
||||
public readonly finishedBooksChartData$: Observable<FinishedBooksChartData> =
|
||||
this.finishedBooksChartDataSubject.asObservable();
|
||||
|
||||
constructor() {
|
||||
this.bookService.bookState$
|
||||
.pipe(
|
||||
filter(state => state.loaded),
|
||||
first(),
|
||||
switchMap(() =>
|
||||
this.libraryFilterService.selectedLibrary$.pipe(
|
||||
takeUntil(this.destroy$)
|
||||
)
|
||||
),
|
||||
catchError((error) => {
|
||||
console.error('Error processing finished books stats:', error);
|
||||
return EMPTY;
|
||||
})
|
||||
)
|
||||
.subscribe(() => {
|
||||
const stats = this.calculateFinishedBooksStats();
|
||||
this.updateChartData(stats);
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
}
|
||||
|
||||
private updateChartData(stats: FinishedBooksStats[]): void {
|
||||
try {
|
||||
const labels = stats.map(s => {
|
||||
const [year, month] = s.yearMonth.split('-');
|
||||
const monthName = new Date(parseInt(year), parseInt(month) - 1).toLocaleString('default', { month: 'short' });
|
||||
return `${monthName} ${year}`;
|
||||
});
|
||||
const dataValues = stats.map(s => s.count);
|
||||
|
||||
this.finishedBooksChartDataSubject.next({
|
||||
labels,
|
||||
datasets: [{
|
||||
label: 'Books Finished',
|
||||
data: dataValues,
|
||||
...CHART_DEFAULTS
|
||||
}]
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error updating chart data:', error);
|
||||
}
|
||||
}
|
||||
|
||||
private calculateFinishedBooksStats(): FinishedBooksStats[] {
|
||||
const currentState = this.bookService.getCurrentBookState();
|
||||
const selectedLibraryId = this.libraryFilterService.getCurrentSelectedLibrary();
|
||||
|
||||
if (!this.isValidBookState(currentState)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const filteredBooks = this.filterBooksByLibrary(currentState.books!, selectedLibraryId);
|
||||
return this.processFinishedBooksStats(filteredBooks);
|
||||
}
|
||||
|
||||
private isValidBookState(state: any): boolean {
|
||||
return state?.loaded && state?.books && Array.isArray(state.books) && state.books.length > 0;
|
||||
}
|
||||
|
||||
private filterBooksByLibrary(books: Book[], selectedLibraryId: string | number | null): Book[] {
|
||||
return selectedLibraryId
|
||||
? books.filter(book => book.libraryId === selectedLibraryId)
|
||||
: books;
|
||||
}
|
||||
|
||||
private processFinishedBooksStats(books: Book[]): FinishedBooksStats[] {
|
||||
const yearMonthMap = new Map<string, number>();
|
||||
|
||||
books.forEach(book => {
|
||||
if (book.dateFinished) {
|
||||
const finishedDate = new Date(book.dateFinished);
|
||||
const yearMonth = `${finishedDate.getFullYear()}-${(finishedDate.getMonth() + 1).toString().padStart(2, '0')}`;
|
||||
yearMonthMap.set(yearMonth, (yearMonthMap.get(yearMonth) || 0) + 1);
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(yearMonthMap.entries())
|
||||
.filter(([yearMonth, count]) => count > 0)
|
||||
.map(([yearMonth, count]) => ({
|
||||
yearMonth,
|
||||
count,
|
||||
year: parseInt(yearMonth.split('-')[0]),
|
||||
month: parseInt(yearMonth.split('-')[1])
|
||||
}))
|
||||
.sort((a, b) => a.yearMonth.localeCompare(b.yearMonth));
|
||||
}
|
||||
|
||||
private formatTooltipLabel(context: any): string {
|
||||
const value = context.parsed.y;
|
||||
return `${value} book${value === 1 ? '' : 's'} finished`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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} from 'chart.js';
|
||||
|
||||
import {LibraryFilterService} from './library-filter.service';
|
||||
import {BookService} from '../../book/service/book.service';
|
||||
import {Book} from '../../book/model/book.model';
|
||||
|
||||
interface MatrixDataPoint {
|
||||
x: number; // month (0-11)
|
||||
y: number; // year index
|
||||
v: number; // book count
|
||||
}
|
||||
|
||||
interface YearMonthData {
|
||||
year: number;
|
||||
month: number;
|
||||
count: number;
|
||||
}
|
||||
|
||||
const MONTH_NAMES = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||
|
||||
type HeatmapChartData = ChartData<'matrix', MatrixDataPoint[], string>;
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class ReadingHeatmapChartService implements OnDestroy {
|
||||
private readonly bookService = inject(BookService);
|
||||
private readonly libraryFilterService = inject(LibraryFilterService);
|
||||
private readonly destroy$ = new Subject<void>();
|
||||
|
||||
public readonly heatmapChartType = 'matrix' as const;
|
||||
|
||||
private yearLabels: string[] = [];
|
||||
private maxBookCount = 1;
|
||||
|
||||
public readonly heatmapChartOptions: ChartConfiguration['options'] = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
layout: {
|
||||
padding: {
|
||||
top: 20
|
||||
}
|
||||
},
|
||||
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: false,
|
||||
padding: 12,
|
||||
titleFont: {size: 14, weight: 'bold'},
|
||||
bodyFont: {size: 13},
|
||||
callbacks: {
|
||||
title: (context) => {
|
||||
const point = context[0].raw as MatrixDataPoint;
|
||||
const year = this.yearLabels[point.y];
|
||||
const month = MONTH_NAMES[point.x];
|
||||
return `${month} ${year}`;
|
||||
},
|
||||
label: (context) => {
|
||||
const point = context.raw as MatrixDataPoint;
|
||||
return `${point.v} book${point.v === 1 ? '' : 's'} read`;
|
||||
}
|
||||
}
|
||||
},
|
||||
datalabels: {
|
||||
display: true,
|
||||
color: '#ffffff',
|
||||
font: {
|
||||
family: "'Inter', sans-serif",
|
||||
size: 10,
|
||||
weight: 'bold'
|
||||
},
|
||||
formatter: (value: MatrixDataPoint) => value.v > 0 ? value.v.toString() : ''
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
type: 'linear',
|
||||
position: 'bottom',
|
||||
ticks: {
|
||||
stepSize: 1,
|
||||
callback: (value) => MONTH_NAMES[value as number] || '',
|
||||
color: '#ffffff',
|
||||
font: {
|
||||
family: "'Inter', sans-serif",
|
||||
size: 11
|
||||
}
|
||||
},
|
||||
grid: {display: false},
|
||||
},
|
||||
y: {
|
||||
type: 'linear',
|
||||
ticks: {
|
||||
stepSize: 1,
|
||||
callback: (value) => this.yearLabels[value as number] || '',
|
||||
color: '#ffffff',
|
||||
font: {
|
||||
family: "'Inter', sans-serif",
|
||||
size: 11
|
||||
}
|
||||
},
|
||||
grid: {display: false},
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
private readonly heatmapChartDataSubject = new BehaviorSubject<HeatmapChartData>({
|
||||
labels: [],
|
||||
datasets: [{
|
||||
label: 'Books Read',
|
||||
data: []
|
||||
}]
|
||||
});
|
||||
|
||||
public readonly heatmapChartData$: Observable<HeatmapChartData> =
|
||||
this.heatmapChartDataSubject.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 heatmap stats:', error);
|
||||
return EMPTY;
|
||||
})
|
||||
)
|
||||
.subscribe(() => {
|
||||
const stats = this.calculateHeatmapData();
|
||||
this.updateChartData(stats);
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
}
|
||||
|
||||
private updateChartData(yearMonthData: YearMonthData[]): void {
|
||||
const currentYear = new Date().getFullYear();
|
||||
const currentMonth = new Date().getMonth();
|
||||
const years = Array.from({length: 10}, (_, i) => currentYear - 9 + i);
|
||||
|
||||
this.yearLabels = years.map(String);
|
||||
this.maxBookCount = Math.max(1, ...yearMonthData.map(d => d.count));
|
||||
|
||||
const heatmapData: MatrixDataPoint[] = [];
|
||||
|
||||
years.forEach((year, yearIndex) => {
|
||||
const maxMonth = year === currentYear ? currentMonth : 11;
|
||||
|
||||
for (let month = 0; month <= maxMonth; month++) {
|
||||
const dataPoint = yearMonthData.find(d => d.year === year && d.month === month + 1);
|
||||
heatmapData.push({
|
||||
x: month,
|
||||
y: yearIndex,
|
||||
v: dataPoint?.count || 0
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
if (this.heatmapChartOptions?.scales?.['y']) {
|
||||
(this.heatmapChartOptions.scales['y'] as any).max = years.length - 0.5;
|
||||
}
|
||||
|
||||
this.heatmapChartDataSubject.next({
|
||||
labels: [],
|
||||
datasets: [{
|
||||
label: 'Books Read',
|
||||
data: heatmapData,
|
||||
backgroundColor: (context) => {
|
||||
const point = context.raw as MatrixDataPoint;
|
||||
if (!point?.v) return 'rgba(255, 255, 255, 0.05)';
|
||||
|
||||
const intensity = point.v / this.maxBookCount;
|
||||
const alpha = Math.max(0.2, Math.min(1.0, intensity * 0.8 + 0.2));
|
||||
return `rgba(239, 71, 111, ${alpha})`;
|
||||
},
|
||||
borderColor: 'rgba(255, 255, 255, 0.2)',
|
||||
borderWidth: 1,
|
||||
width: ({chart}) => (chart.chartArea?.width || 0) / 12 - 1,
|
||||
height: ({chart}) => (chart.chartArea?.height || 0) / years.length - 1
|
||||
}]
|
||||
});
|
||||
}
|
||||
|
||||
private calculateHeatmapData(): YearMonthData[] {
|
||||
const currentState = this.bookService.getCurrentBookState();
|
||||
const selectedLibraryId = this.libraryFilterService.getCurrentSelectedLibrary();
|
||||
|
||||
if (!this.isValidBookState(currentState)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const filteredBooks = this.filterBooksByLibrary(currentState.books!, selectedLibraryId);
|
||||
return this.processHeatmapData(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 processHeatmapData(books: Book[]): YearMonthData[] {
|
||||
const yearMonthMap = new Map<string, number>();
|
||||
const currentYear = new Date().getFullYear();
|
||||
const startYear = currentYear - 9;
|
||||
|
||||
books
|
||||
.filter(book => book.dateFinished)
|
||||
.forEach(book => {
|
||||
const finishedDate = new Date(book.dateFinished!);
|
||||
const year = finishedDate.getFullYear();
|
||||
|
||||
if (year >= startYear && year <= currentYear) {
|
||||
const month = finishedDate.getMonth() + 1;
|
||||
const key = `${year}-${month}`;
|
||||
yearMonthMap.set(key, (yearMonthMap.get(key) || 0) + 1);
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(yearMonthMap.entries())
|
||||
.map(([key, count]) => {
|
||||
const [year, month] = key.split('-').map(Number);
|
||||
return {year, month, count};
|
||||
})
|
||||
.sort((a, b) => a.year - b.year || a.month - b.month);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
// ...existing code...
|
||||
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
// Get the range of months
|
||||
const monthsWithData = Array.from(yearMonthMap.keys()).sort();
|
||||
|
||||
if (monthsWithData.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Fill in all missing months between first and last data points
|
||||
const startDate = this.parseYearMonth(monthsWithData[0]);
|
||||
const endDate = this.parseYearMonth(monthsWithData[monthsWithData.length - 1]);
|
||||
|
||||
const completeStats: FinishedBooksStats[] = [];
|
||||
let currentDate = new Date(startDate.year, startDate.month - 1, 1);
|
||||
const endDateObj = new Date(endDate.year, endDate.month - 1, 1);
|
||||
|
||||
while (currentDate <= endDateObj) {
|
||||
const year = currentDate.getFullYear();
|
||||
const month = currentDate.getMonth() + 1;
|
||||
const yearMonth = `${year}-${month.toString().padStart(2, '0')}`;
|
||||
|
||||
completeStats.push({
|
||||
yearMonth,
|
||||
count: yearMonthMap.get(yearMonth) || 0,
|
||||
year,
|
||||
month
|
||||
});
|
||||
|
||||
// Move to next month
|
||||
currentDate.setMonth(currentDate.getMonth() + 1);
|
||||
}
|
||||
|
||||
return completeStats.sort((a, b) => a.yearMonth.localeCompare(b.yearMonth));
|
||||
}
|
||||
|
||||
private parseYearMonth(yearMonthStr: string): { year: number, month: number } {
|
||||
const [year, month] = yearMonthStr.split('-').map(Number);
|
||||
return { year, month };
|
||||
}
|
||||
|
||||
// ...existing code...
|
||||
|
||||
@@ -184,7 +184,6 @@
|
||||
</canvas>
|
||||
</div>
|
||||
}
|
||||
|
||||
@case ('bookFormats') {
|
||||
<h3>Book Formats</h3>
|
||||
<div class="chart-wrapper book-type-chart">
|
||||
@@ -195,7 +194,6 @@
|
||||
</canvas>
|
||||
</div>
|
||||
}
|
||||
|
||||
@case ('languageDistribution') {
|
||||
<h3>Language Distribution</h3>
|
||||
<div class="chart-wrapper language-chart">
|
||||
@@ -206,7 +204,6 @@
|
||||
</canvas>
|
||||
</div>
|
||||
}
|
||||
|
||||
@case ('bookMetadataScore') {
|
||||
<h3>Book Metadata Score</h3>
|
||||
<div class="chart-wrapper quality-chart">
|
||||
@@ -217,7 +214,6 @@
|
||||
</canvas>
|
||||
</div>
|
||||
}
|
||||
|
||||
@case ('topAuthors') {
|
||||
<h3>Top 25 Authors (with Reading Breakdown)</h3>
|
||||
<div class="chart-wrapper author-chart">
|
||||
@@ -228,7 +224,6 @@
|
||||
</canvas>
|
||||
</div>
|
||||
}
|
||||
|
||||
@case ('topCategories') {
|
||||
<h3>Top 25 Categories (with Reading Breakdown)</h3>
|
||||
<div class="chart-wrapper completion-chart">
|
||||
@@ -239,7 +234,6 @@
|
||||
</canvas>
|
||||
</div>
|
||||
}
|
||||
|
||||
@case ('topBooksBySize') {
|
||||
<h3>Top 20 Largest Books by File Size</h3>
|
||||
<div class="chart-wrapper book-size-chart">
|
||||
@@ -250,7 +244,6 @@
|
||||
</canvas>
|
||||
</div>
|
||||
}
|
||||
|
||||
@case ('monthlyReadingPatterns') {
|
||||
<h3>Monthly Reading Patterns</h3>
|
||||
<div class="chart-wrapper monthly-patterns-chart">
|
||||
@@ -261,7 +254,6 @@
|
||||
</canvas>
|
||||
</div>
|
||||
}
|
||||
|
||||
@case ('readingProgress') {
|
||||
<h3>Reading Progress</h3>
|
||||
<div class="chart-wrapper progress-chart">
|
||||
@@ -272,7 +264,6 @@
|
||||
</canvas>
|
||||
</div>
|
||||
}
|
||||
|
||||
@case ('externalRating') {
|
||||
<h3>External Rating Distribution</h3>
|
||||
<div class="chart-wrapper rating-chart">
|
||||
@@ -283,7 +274,6 @@
|
||||
</canvas>
|
||||
</div>
|
||||
}
|
||||
|
||||
@case ('personalRating') {
|
||||
<h3>Personal Rating Distribution</h3>
|
||||
<div class="chart-wrapper personal-rating-chart">
|
||||
@@ -294,7 +284,6 @@
|
||||
</canvas>
|
||||
</div>
|
||||
}
|
||||
|
||||
@case ('pageCount') {
|
||||
<h3>Page Count Distribution</h3>
|
||||
<div class="chart-wrapper page-count-chart">
|
||||
@@ -305,7 +294,6 @@
|
||||
</canvas>
|
||||
</div>
|
||||
}
|
||||
|
||||
@case ('readingVelocityTimeline') {
|
||||
<h3>Reading Velocity Timeline</h3>
|
||||
<div class="chart-wrapper velocity-timeline-chart">
|
||||
@@ -316,7 +304,6 @@
|
||||
</canvas>
|
||||
</div>
|
||||
}
|
||||
|
||||
@case ('topSeries') {
|
||||
<h3>Top 20 Series by Book Count</h3>
|
||||
<div class="chart-wrapper top-series-chart">
|
||||
@@ -327,7 +314,6 @@
|
||||
</canvas>
|
||||
</div>
|
||||
}
|
||||
|
||||
@case ('publicationYear') {
|
||||
<h3>Publication Year Timeline</h3>
|
||||
<div class="chart-wrapper year-chart">
|
||||
@@ -339,19 +325,6 @@
|
||||
</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">
|
||||
@@ -384,7 +357,6 @@
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@case ('readingHabits') {
|
||||
<h3>Reading Habits Analysis</h3>
|
||||
<div class="chart-description">
|
||||
@@ -417,6 +389,16 @@
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@case ('readingHeatmap') {
|
||||
<h3>Books Finished per Month</h3>
|
||||
<div class="chart-wrapper reading-heatmap-chart">
|
||||
<canvas baseChart
|
||||
[data]="(readingHeatmapChartService.heatmapChartData$ | async) ?? {labels: [], datasets: []}"
|
||||
[options]="readingHeatmapChartService.heatmapChartOptions"
|
||||
[type]="readingHeatmapChartService.heatmapChartType">
|
||||
</canvas>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -530,6 +530,10 @@
|
||||
height: 400px;
|
||||
}
|
||||
|
||||
&.reading-heatmap-chart {
|
||||
height: 300px;
|
||||
}
|
||||
|
||||
&.reading-profile-radar-chart,
|
||||
&.reading-dna-chart,
|
||||
&.reading-habits-chart {
|
||||
@@ -889,7 +893,11 @@
|
||||
height: 280px;
|
||||
}
|
||||
|
||||
&.reading-profile-radar-chart,
|
||||
&.reading-heatmap-chart {
|
||||
height: 250px;
|
||||
}
|
||||
|
||||
.reading-profile-radar-chart,
|
||||
&.reading-dna-chart,
|
||||
&.reading-habits-chart {
|
||||
height: 350px;
|
||||
@@ -974,6 +982,10 @@
|
||||
height: 250px;
|
||||
}
|
||||
|
||||
&.reading-heatmap-chart {
|
||||
height: 300px;
|
||||
}
|
||||
|
||||
.reading-profile-radar-chart,
|
||||
&.reading-dna-chart,
|
||||
&.reading-habits-chart {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {Component, inject, OnDestroy, OnInit, ViewChild} from '@angular/core';
|
||||
import {BaseChartDirective} from 'ng2-charts';
|
||||
import {Chart, registerables, Tooltip} from 'chart.js';
|
||||
import 'chartjs-chart-matrix';
|
||||
import {CommonModule} from '@angular/common';
|
||||
import {FormsModule} from '@angular/forms';
|
||||
import {catchError, map, of, startWith, Subject, takeUntil} from 'rxjs';
|
||||
@@ -23,12 +24,13 @@ 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';
|
||||
import {ReadingHeatmapChartService} from './charts-service/reading-heatmap-chart.service';
|
||||
import {MatrixController, MatrixElement} from 'chartjs-chart-matrix';
|
||||
|
||||
@Component({
|
||||
selector: 'app-stats-component',
|
||||
@@ -65,10 +67,10 @@ export class StatsComponent implements OnInit, OnDestroy {
|
||||
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);
|
||||
protected readonly readingHeatmapChartService = inject(ReadingHeatmapChartService);
|
||||
private readonly destroy$ = new Subject<void>();
|
||||
|
||||
public isLoading = true;
|
||||
@@ -94,7 +96,8 @@ export class StatsComponent implements OnInit, OnDestroy {
|
||||
public readonly totalSize$ = this.librariesSummaryService.getFormattedSize().pipe(catchError(() => of('0 KB')));
|
||||
|
||||
ngOnInit(): void {
|
||||
Chart.register(...registerables, Tooltip, ChartDataLabels);
|
||||
Chart.register(...registerables, Tooltip, ChartDataLabels, MatrixController, MatrixElement);
|
||||
// Register matrix chart - it's automatically registered when imported
|
||||
Chart.defaults.plugins.legend.labels.font = {
|
||||
family: "'Inter', sans-serif",
|
||||
size: 11.5,
|
||||
|
||||
Reference in New Issue
Block a user