diff --git a/booklore-ui/package-lock.json b/booklore-ui/package-lock.json index 47d63c9a..f6358ac0 100644 --- a/booklore-ui/package-lock.json +++ b/booklore-ui/package-lock.json @@ -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", diff --git a/booklore-ui/package.json b/booklore-ui/package.json index 69d96991..b434cb42 100644 --- a/booklore-ui/package.json +++ b/booklore-ui/package.json @@ -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", diff --git a/booklore-ui/src/app/stats-component/charts-service/chart-config.service.ts b/booklore-ui/src/app/stats-component/charts-service/chart-config.service.ts index a67d06fa..64113087 100644 --- a/booklore-ui/src/app/stats-component/charts-service/chart-config.service.ts +++ b/booklore-ui/src/app/stats-component/charts-service/chart-config.service.ts @@ -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(this.loadConfig()); diff --git a/booklore-ui/src/app/stats-component/charts-service/finished-books-timeline-chart.service.ts b/booklore-ui/src/app/stats-component/charts-service/finished-books-timeline-chart.service.ts deleted file mode 100644 index c1fd24f1..00000000 --- a/booklore-ui/src/app/stats-component/charts-service/finished-books-timeline-chart.service.ts +++ /dev/null @@ -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(); - - 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({ - labels: [], - datasets: [{ - label: 'Books Finished', - data: [], - ...CHART_DEFAULTS - }] - }); - - public readonly finishedBooksChartData$: Observable = - 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(); - - 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`; - } -} diff --git a/booklore-ui/src/app/stats-component/charts-service/reading-heatmap-chart.service.ts b/booklore-ui/src/app/stats-component/charts-service/reading-heatmap-chart.service.ts new file mode 100644 index 00000000..0bb6541f --- /dev/null +++ b/booklore-ui/src/app/stats-component/charts-service/reading-heatmap-chart.service.ts @@ -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(); + + 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({ + labels: [], + datasets: [{ + label: 'Books Read', + data: [] + }] + }); + + public readonly heatmapChartData$: Observable = + 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(); + 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); + } +} diff --git a/booklore-ui/src/app/stats-component/finished-books-timeline-chart.service.ts b/booklore-ui/src/app/stats-component/finished-books-timeline-chart.service.ts new file mode 100644 index 00000000..939f5e92 --- /dev/null +++ b/booklore-ui/src/app/stats-component/finished-books-timeline-chart.service.ts @@ -0,0 +1,54 @@ +// ...existing code... + + private processFinishedBooksStats(books: Book[]): FinishedBooksStats[] { + const yearMonthMap = new Map(); + + 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... + diff --git a/booklore-ui/src/app/stats-component/stats-component.html b/booklore-ui/src/app/stats-component/stats-component.html index 1be376c4..54a866cc 100644 --- a/booklore-ui/src/app/stats-component/stats-component.html +++ b/booklore-ui/src/app/stats-component/stats-component.html @@ -184,7 +184,6 @@ } - @case ('bookFormats') {

Book Formats

@@ -195,7 +194,6 @@
} - @case ('languageDistribution') {

Language Distribution

@@ -206,7 +204,6 @@
} - @case ('bookMetadataScore') {

Book Metadata Score

@@ -217,7 +214,6 @@
} - @case ('topAuthors') {

Top 25 Authors (with Reading Breakdown)

@@ -228,7 +224,6 @@
} - @case ('topCategories') {

Top 25 Categories (with Reading Breakdown)

@@ -239,7 +234,6 @@
} - @case ('topBooksBySize') {

Top 20 Largest Books by File Size

@@ -250,7 +244,6 @@
} - @case ('monthlyReadingPatterns') {

Monthly Reading Patterns

@@ -261,7 +254,6 @@
} - @case ('readingProgress') {

Reading Progress

@@ -272,7 +264,6 @@
} - @case ('externalRating') {

External Rating Distribution

@@ -283,7 +274,6 @@
} - @case ('personalRating') {

Personal Rating Distribution

@@ -294,7 +284,6 @@
} - @case ('pageCount') {

Page Count Distribution

@@ -305,7 +294,6 @@
} - @case ('readingVelocityTimeline') {

Reading Velocity Timeline

@@ -316,7 +304,6 @@
} - @case ('topSeries') {

Top 20 Series by Book Count

@@ -327,7 +314,6 @@
} - @case ('publicationYear') {

Publication Year Timeline

@@ -339,19 +325,6 @@
} - - @case ('finishedBooksTimeline') { -

Books Finished Timeline (Per Month)

-
- - -
- } - @case ('readingDNA') {

Your Reading DNA Profile

@@ -384,7 +357,6 @@ }
} - @case ('readingHabits') {

Reading Habits Analysis

@@ -417,6 +389,16 @@ }
} + @case ('readingHeatmap') { +

Books Finished per Month

+
+ + +
+ } } } diff --git a/booklore-ui/src/app/stats-component/stats-component.scss b/booklore-ui/src/app/stats-component/stats-component.scss index cf18d9cc..1d50a7d8 100644 --- a/booklore-ui/src/app/stats-component/stats-component.scss +++ b/booklore-ui/src/app/stats-component/stats-component.scss @@ -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 { diff --git a/booklore-ui/src/app/stats-component/stats-component.ts b/booklore-ui/src/app/stats-component/stats-component.ts index 00421df1..bc21e554 100644 --- a/booklore-ui/src/app/stats-component/stats-component.ts +++ b/booklore-ui/src/app/stats-component/stats-component.ts @@ -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(); 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,