feat(stats): add 5 new user statistics charts (#2703)

* feat(stats): add 5 new user statistics charts

* fix(stats): use COALESCE for dateFinished in heartbeat and page turner queries

* refactor(stats): remove heartbeat, genre flow, reading seasons, and abandonment autopsy charts

* feat(stats): add 5 new user statistics charts

* feat(stats): add info tooltips to all 5 new charts

* perf(stats): limit completion race chart to 15 most recent books server-side

* fix(stats): limit completion race to 10 books and cap progress at 100%

* refactor(stats): replace streak bars with GitHub-style activity heatmap calendar

* fix(stats): widen chart info tooltips to prevent tall narrow box

* feat(stats): add info tooltip to Page Turner Score chart

* feat(stats): add info tooltips to 7 existing charts

* refactor(stats): deduplicate chart-info-icon style into global styles.scss

* refactor(stats): fix scoring bugs and improve Reading DNA and Reading Habits charts

* refactor(stats): rebalance Series Progress Tracker layout and unify heatmap footer pills

* feat(stats): add info tooltips to 4 remaining charts

* fix(stats): fix session duration and overlap bugs in Reading Session Timeline
This commit is contained in:
ACX
2026-02-12 00:11:49 -07:00
committed by GitHub
parent 7af21e079e
commit 8128269310
58 changed files with 3356 additions and 1718 deletions

View File

@@ -135,4 +135,40 @@ public class UserStatsController {
List<BookCompletionHeatmapResponse> heatmapData = readingSessionService.getBookCompletionHeatmap();
return ResponseEntity.ok(heatmapData);
}
@Operation(summary = "Get page turner scores", description = "Returns engagement/grip scores for completed books based on reading session patterns")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "Page turner scores retrieved successfully"),
@ApiResponse(responseCode = "401", description = "Unauthorized")
})
@GetMapping("/page-turner-scores")
@PreAuthorize("@securityUtil.canAccessUserStats() or @securityUtil.isAdmin()")
public ResponseEntity<List<PageTurnerScoreResponse>> getPageTurnerScores() {
List<PageTurnerScoreResponse> scores = readingSessionService.getPageTurnerScores();
return ResponseEntity.ok(scores);
}
@Operation(summary = "Get completion race data", description = "Returns reading session progress data for completed books in a given year, for visualizing completion races")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "Completion race data retrieved successfully"),
@ApiResponse(responseCode = "401", description = "Unauthorized")
})
@GetMapping("/completion-race")
@PreAuthorize("@securityUtil.canAccessUserStats() or @securityUtil.isAdmin()")
public ResponseEntity<List<CompletionRaceResponse>> getCompletionRace(@RequestParam int year) {
List<CompletionRaceResponse> data = readingSessionService.getCompletionRace(year);
return ResponseEntity.ok(data);
}
@Operation(summary = "Get all reading dates", description = "Returns daily reading session counts across all time for the authenticated user")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "Reading dates retrieved successfully"),
@ApiResponse(responseCode = "401", description = "Unauthorized")
})
@GetMapping("/reading-dates")
@PreAuthorize("@securityUtil.canAccessUserStats() or @securityUtil.isAdmin()")
public ResponseEntity<List<ReadingSessionHeatmapResponse>> getReadingDates() {
List<ReadingSessionHeatmapResponse> data = readingSessionService.getReadingDates();
return ResponseEntity.ok(data);
}
}

View File

@@ -0,0 +1,10 @@
package org.booklore.model.dto;
import java.time.Instant;
public interface CompletionRaceSessionDto {
Long getBookId();
String getBookTitle();
Instant getSessionDate();
Float getEndProgress();
}

View File

@@ -0,0 +1,14 @@
package org.booklore.model.dto;
import java.time.Instant;
public interface PageTurnerSessionDto {
Long getBookId();
String getBookTitle();
Integer getPageCount();
Integer getPersonalRating();
Instant getDateFinished();
Instant getStartTime();
Instant getEndTime();
Integer getDurationSeconds();
}

View File

@@ -0,0 +1,19 @@
package org.booklore.model.dto.response;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.Instant;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class CompletionRaceResponse {
private Long bookId;
private String bookTitle;
private Instant sessionDate;
private Float endProgress;
}

View File

@@ -0,0 +1,26 @@
package org.booklore.model.dto.response;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class PageTurnerScoreResponse {
private Long bookId;
private String bookTitle;
private List<String> categories;
private Integer pageCount;
private Integer personalRating;
private Integer gripScore;
private Long totalSessions;
private Double avgSessionDurationSeconds;
private Double sessionAcceleration;
private Double gapReduction;
private Boolean finishBurst;
}

View File

@@ -1,6 +1,9 @@
package org.booklore.repository;
import org.booklore.model.dto.*;
import org.booklore.model.dto.CompletionRaceSessionDto;
import org.booklore.model.dto.PageTurnerSessionDto;
import org.booklore.model.entity.ReadingSessionEntity;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
@@ -136,4 +139,52 @@ public interface ReadingSessionRepository extends JpaRepository<ReadingSessionEn
@Param("userId") Long userId,
@Param("bookId") Long bookId,
Pageable pageable);
@Query("""
SELECT
b.id as bookId,
COALESCE(b.metadata.title, 'Unknown Book') as bookTitle,
b.metadata.pageCount as pageCount,
ubp.personalRating as personalRating,
COALESCE(ubp.dateFinished, ubp.readStatusModifiedTime, ubp.lastReadTime) as dateFinished,
rs.startTime as startTime,
rs.endTime as endTime,
rs.durationSeconds as durationSeconds
FROM ReadingSessionEntity rs
JOIN rs.book b
JOIN UserBookProgressEntity ubp ON ubp.book.id = b.id AND ubp.user.id = rs.user.id
WHERE rs.user.id = :userId
AND ubp.readStatus = org.booklore.model.enums.ReadStatus.READ
AND COALESCE(ubp.dateFinished, ubp.readStatusModifiedTime, ubp.lastReadTime) IS NOT NULL
ORDER BY b.id, rs.startTime ASC
""")
List<PageTurnerSessionDto> findPageTurnerSessionsByUser(@Param("userId") Long userId);
@Query("""
SELECT
b.id as bookId,
COALESCE(b.metadata.title, 'Unknown Book') as bookTitle,
rs.startTime as sessionDate,
rs.endProgress as endProgress
FROM ReadingSessionEntity rs
JOIN rs.book b
JOIN UserBookProgressEntity ubp ON ubp.book.id = b.id AND ubp.user.id = rs.user.id
WHERE rs.user.id = :userId
AND ubp.readStatus = org.booklore.model.enums.ReadStatus.READ
AND YEAR(COALESCE(ubp.dateFinished, ubp.readStatusModifiedTime, ubp.lastReadTime)) = :year
AND rs.endProgress IS NOT NULL
ORDER BY b.id, rs.startTime ASC
""")
List<CompletionRaceSessionDto> findCompletionRaceSessionsByUserAndYear(
@Param("userId") Long userId,
@Param("year") int year);
@Query("""
SELECT CAST(rs.startTime AS LocalDate) as date, COUNT(rs) as count
FROM ReadingSessionEntity rs
WHERE rs.user.id = :userId
GROUP BY CAST(rs.startTime AS LocalDate)
ORDER BY date
""")
List<ReadingSessionCountDto> findAllSessionCountsByUser(@Param("userId") Long userId);
}

View File

@@ -3,18 +3,24 @@ package org.booklore.service;
import org.booklore.config.security.service.AuthenticationService;
import org.booklore.exception.ApiError;
import org.booklore.model.dto.BookLoreUser;
import org.booklore.model.dto.CompletionRaceSessionDto;
import org.booklore.model.dto.request.ReadingSessionRequest;
import org.booklore.model.dto.PageTurnerSessionDto;
import org.booklore.model.dto.response.BookCompletionHeatmapResponse;
import org.booklore.model.dto.response.CompletionRaceResponse;
import org.booklore.model.dto.response.CompletionTimelineResponse;
import org.booklore.model.dto.response.FavoriteReadingDaysResponse;
import org.booklore.model.dto.response.GenreStatisticsResponse;
import org.booklore.model.dto.response.PageTurnerScoreResponse;
import org.booklore.model.dto.response.PeakReadingHoursResponse;
import org.booklore.model.dto.response.ReadingSessionHeatmapResponse;
import org.booklore.model.dto.response.ReadingSessionResponse;
import org.booklore.model.dto.response.ReadingSessionTimelineResponse;
import org.booklore.model.dto.response.ReadingSpeedResponse;
import org.booklore.model.entity.BookEntity;
import org.booklore.model.entity.BookLoreUserEntity;
import org.booklore.model.entity.CategoryEntity;
import org.booklore.model.entity.ReadingSessionEntity;
import org.booklore.model.enums.ReadStatus;
import org.booklore.repository.BookRepository;
@@ -31,15 +37,14 @@ import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.DayOfWeek;
import java.time.Instant;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.temporal.ChronoUnit;
import java.time.temporal.TemporalAdjusters;
import java.time.temporal.WeekFields;
import java.util.EnumMap;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.*;
import java.util.stream.Collectors;
@Slf4j
@@ -287,4 +292,152 @@ public class ReadingSessionService {
.build())
.collect(Collectors.toList());
}
@Transactional(readOnly = true)
public List<PageTurnerScoreResponse> getPageTurnerScores() {
BookLoreUser authenticatedUser = authenticationService.getAuthenticatedUser();
Long userId = authenticatedUser.getId();
var sessions = readingSessionRepository.findPageTurnerSessionsByUser(userId);
Map<Long, List<PageTurnerSessionDto>> sessionsByBook = sessions.stream()
.collect(Collectors.groupingBy(PageTurnerSessionDto::getBookId, LinkedHashMap::new, Collectors.toList()));
Set<Long> bookIds = sessionsByBook.keySet();
Map<Long, List<String>> bookCategories = new HashMap<>();
if (!bookIds.isEmpty()) {
bookRepository.findAllWithMetadataByIds(bookIds).forEach(book -> {
List<String> categories = book.getMetadata() != null && book.getMetadata().getCategories() != null
? book.getMetadata().getCategories().stream()
.map(CategoryEntity::getName)
.sorted()
.collect(Collectors.toList())
: List.of();
bookCategories.put(book.getId(), categories);
});
}
return sessionsByBook.entrySet().stream()
.filter(entry -> entry.getValue().size() >= 2)
.map(entry -> {
Long bookId = entry.getKey();
List<PageTurnerSessionDto> bookSessions = entry.getValue();
PageTurnerSessionDto first = bookSessions.getFirst();
List<Double> durations = bookSessions.stream()
.map(s -> s.getDurationSeconds() != null ? s.getDurationSeconds().doubleValue() : 0.0)
.collect(Collectors.toList());
List<Double> gaps = new ArrayList<>();
for (int i = 1; i < bookSessions.size(); i++) {
Instant prevEnd = bookSessions.get(i - 1).getEndTime();
Instant currStart = bookSessions.get(i).getStartTime();
if (prevEnd != null && currStart != null) {
gaps.add((double) ChronoUnit.HOURS.between(prevEnd, currStart));
}
}
double sessionAcceleration = linearRegressionSlope(durations);
double gapReduction = gaps.size() >= 2 ? linearRegressionSlope(gaps) : 0.0;
int totalSessions = bookSessions.size();
int lastQuarterStart = (int) Math.floor(totalSessions * 0.75);
double firstThreeQuartersAvg = durations.subList(0, lastQuarterStart).stream()
.mapToDouble(Double::doubleValue).average().orElse(0);
double lastQuarterAvg = durations.subList(lastQuarterStart, totalSessions).stream()
.mapToDouble(Double::doubleValue).average().orElse(0);
boolean finishBurst = lastQuarterAvg > firstThreeQuartersAvg;
double accelScore = Math.min(1.0, Math.max(0.0, (sessionAcceleration + 50) / 100.0));
double gapScore = Math.min(1.0, Math.max(0.0, (-gapReduction + 50) / 100.0));
double burstScore = finishBurst ? 1.0 : 0.0;
int gripScore = (int) Math.round(
Math.min(100, Math.max(0, accelScore * 35 + gapScore * 35 + burstScore * 30)));
double avgDuration = durations.stream().mapToDouble(Double::doubleValue).average().orElse(0);
return PageTurnerScoreResponse.builder()
.bookId(bookId)
.bookTitle(first.getBookTitle())
.categories(bookCategories.getOrDefault(bookId, List.of()))
.pageCount(first.getPageCount())
.personalRating(first.getPersonalRating())
.gripScore(gripScore)
.totalSessions((long) totalSessions)
.avgSessionDurationSeconds(Math.round(avgDuration * 100.0) / 100.0)
.sessionAcceleration(Math.round(sessionAcceleration * 100.0) / 100.0)
.gapReduction(Math.round(gapReduction * 100.0) / 100.0)
.finishBurst(finishBurst)
.build();
})
.sorted(Comparator.comparingInt(PageTurnerScoreResponse::getGripScore).reversed())
.collect(Collectors.toList());
}
private static final int COMPLETION_RACE_BOOK_LIMIT = 10;
@Transactional(readOnly = true)
public List<CompletionRaceResponse> getCompletionRace(int year) {
BookLoreUser authenticatedUser = authenticationService.getAuthenticatedUser();
Long userId = authenticatedUser.getId();
var allSessions = readingSessionRepository.findCompletionRaceSessionsByUserAndYear(userId, year);
// Collect unique book IDs in order of appearance, take last N (most recently finished)
LinkedHashSet<Long> allBookIds = allSessions.stream()
.map(CompletionRaceSessionDto::getBookId)
.collect(Collectors.toCollection(LinkedHashSet::new));
Set<Long> limitedBookIds;
if (allBookIds.size() > COMPLETION_RACE_BOOK_LIMIT) {
limitedBookIds = allBookIds.stream()
.skip(allBookIds.size() - COMPLETION_RACE_BOOK_LIMIT)
.collect(Collectors.toSet());
} else {
limitedBookIds = allBookIds;
}
return allSessions.stream()
.filter(dto -> limitedBookIds.contains(dto.getBookId()))
.map(dto -> CompletionRaceResponse.builder()
.bookId(dto.getBookId())
.bookTitle(dto.getBookTitle())
.sessionDate(dto.getSessionDate())
.endProgress(dto.getEndProgress())
.build())
.collect(Collectors.toList());
}
@Transactional(readOnly = true)
public List<ReadingSessionHeatmapResponse> getReadingDates() {
BookLoreUser authenticatedUser = authenticationService.getAuthenticatedUser();
Long userId = authenticatedUser.getId();
return readingSessionRepository.findAllSessionCountsByUser(userId)
.stream()
.map(dto -> ReadingSessionHeatmapResponse.builder()
.date(dto.getDate())
.count(dto.getCount())
.build())
.collect(Collectors.toList());
}
private double linearRegressionSlope(List<Double> values) {
int n = values.size();
if (n < 2) return 0.0;
double sumX = 0, sumY = 0, sumXY = 0, sumX2 = 0;
for (int i = 0; i < n; i++) {
sumX += i;
sumY += values.get(i);
sumXY += i * values.get(i);
sumX2 += (double) i * i;
}
double denominator = n * sumX2 - sumX * sumX;
if (denominator == 0) return 0.0;
return (n * sumXY - sumX * sumY) / denominator;
}
}

View File

@@ -49,6 +49,27 @@ export interface PeakHoursResponse {
totalDurationSeconds: number;
}
export interface PageTurnerScoreResponse {
bookId: number;
bookTitle: string;
categories: string[];
pageCount: number;
personalRating: number;
gripScore: number;
totalSessions: number;
avgSessionDurationSeconds: number;
sessionAcceleration: number;
gapReduction: number;
finishBurst: boolean;
}
export interface CompletionRaceResponse {
bookId: number;
bookTitle: string;
sessionDate: string;
endProgress: number;
}
@Injectable({
providedIn: 'root'
})
@@ -112,4 +133,23 @@ export class UserStatsService {
{params}
);
}
getPageTurnerScores(): Observable<PageTurnerScoreResponse[]> {
return this.http.get<PageTurnerScoreResponse[]>(
`${this.readingSessionsUrl}/page-turner-scores`
);
}
getCompletionRace(year: number): Observable<CompletionRaceResponse[]> {
return this.http.get<CompletionRaceResponse[]>(
`${this.readingSessionsUrl}/completion-race`,
{params: {year: year.toString()}}
);
}
getReadingDates(): Observable<ReadingSessionHeatmapResponse[]> {
return this.http.get<ReadingSessionHeatmapResponse[]>(
`${this.readingSessionsUrl}/reading-dates`
);
}
}

View File

@@ -0,0 +1,43 @@
<div class="book-length-container">
<div class="chart-header">
<div class="chart-title">
<h3>
<i class="pi pi-book book-length-icon"></i>
Book Length Sweet Spot
<i class="pi pi-question-circle chart-info-icon"
pTooltip="Each dot is a book you've rated, plotted by its page count (X-axis) and your personal rating (Y-axis). Colors indicate read status: green = read, blue = reading, red = abandoned. The trend line shows whether you tend to rate longer or shorter books higher. The 'sweet spot' is the page-count range where your average rating is highest."
tooltipPosition="bottom"
tooltipStyleClass="chart-info-tooltip"
[appendTo]="'body'"></i>
</h3>
<p class="chart-description">How book length relates to your personal ratings</p>
</div>
@if (totalRatedBooks > 0) {
<div class="stats-badge">
<span class="badge-value">{{ totalRatedBooks }}</span>
<span class="badge-label">books analyzed</span>
</div>
}
</div>
@if (totalRatedBooks > 0) {
<div class="stats-row">
<div class="stat-card">
<span class="stat-value-sm">{{ sweetSpot }}</span>
<span class="stat-label">Sweet Spot</span>
</div>
<div class="stat-card">
<span class="stat-value-sm">{{ highestRatedLength }}</span>
<span class="stat-label">Highest Rated</span>
</div>
</div>
}
<div class="chart-wrapper">
<canvas baseChart
[data]="(chartData$ | async) ?? {datasets: []}"
[options]="chartOptions"
[type]="chartType">
</canvas>
</div>
</div>

View File

@@ -0,0 +1,150 @@
.book-length-container {
width: 100%;
}
.chart-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 0.5rem;
flex-wrap: wrap;
gap: 1rem;
.chart-title {
flex: 1;
min-width: 200px;
h3 {
color: var(--text-color, #ffffff);
font-size: 1.25rem;
font-weight: 500;
margin: 0 0 0.5rem 0;
display: flex;
align-items: center;
gap: 0.5rem;
}
.chart-description {
color: var(--text-secondary-color);
font-size: 0.9rem;
margin: 0;
line-height: 1.4;
}
}
.stats-badge {
display: flex;
flex-direction: column;
align-items: center;
background: rgba(0, 188, 212, 0.15);
border: 1px solid rgba(0, 188, 212, 0.3);
border-radius: 8px;
padding: 0.5rem 1rem;
.badge-value {
font-size: 1.5rem;
font-weight: 600;
color: #00bcd4;
}
.badge-label {
font-size: 0.75rem;
color: var(--text-secondary-color);
text-transform: uppercase;
letter-spacing: 0.5px;
}
}
}
.book-length-icon {
font-size: 1.5rem;
color: #00bcd4;
}
.stats-row {
display: flex;
gap: 0.75rem;
margin-bottom: 1rem;
flex-wrap: wrap;
.stat-card {
display: flex;
flex-direction: column;
align-items: center;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
padding: 0.5rem 1rem;
flex: 1;
.stat-value-sm {
font-size: 0.9rem;
font-weight: 500;
color: var(--text-color, #ffffff);
text-align: center;
}
.stat-label {
font-size: 0.75rem;
color: var(--text-secondary-color);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-top: 0.25rem;
}
}
}
.chart-wrapper {
position: relative;
height: 400px;
width: 100%;
}
.no-data-message {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 3rem;
text-align: center;
color: var(--text-secondary-color);
i {
font-size: 2.5rem;
margin-bottom: 1rem;
opacity: 0.5;
}
p {
font-size: 1rem;
margin: 0 0 0.5rem 0;
color: var(--text-color, #ffffff);
}
small {
font-size: 0.85rem;
opacity: 0.7;
}
}
@media (max-width: 768px) {
.chart-wrapper {
height: 350px;
}
}
@media (max-width: 480px) {
.chart-header {
.chart-title h3 {
font-size: 1rem;
}
.stats-badge .badge-value {
font-size: 1.25rem;
}
}
.chart-wrapper {
height: 300px;
}
}

View File

@@ -0,0 +1,301 @@
import {Component, inject, OnDestroy, OnInit} from '@angular/core';
import {CommonModule} from '@angular/common';
import {BaseChartDirective} from 'ng2-charts';
import {Tooltip} from 'primeng/tooltip';
import {BehaviorSubject, EMPTY, Observable, Subject} from 'rxjs';
import {catchError, filter, first, takeUntil} from 'rxjs/operators';
import {ChartConfiguration, ChartData, ScatterDataPoint} from 'chart.js';
import {BookService} from '../../../../../book/service/book.service';
import {BookState} from '../../../../../book/model/state/book-state.model';
import {Book, ReadStatus} from '../../../../../book/model/book.model';
interface BookScatterPoint extends ScatterDataPoint {
bookTitle: string;
readStatus: string;
}
type LengthChartData = ChartData<'scatter', BookScatterPoint[], string>;
const STATUS_COLORS: Record<string, { bg: string; border: string }> = {
'Read': {bg: 'rgba(76, 175, 80, 0.7)', border: '#4caf50'},
'Reading': {bg: 'rgba(33, 150, 243, 0.7)', border: '#2196f3'},
'Abandoned': {bg: 'rgba(244, 67, 54, 0.7)', border: '#f44336'},
'Other': {bg: 'rgba(158, 158, 158, 0.7)', border: '#9e9e9e'}
};
const PAGE_RANGES = [
{label: '0-100', min: 0, max: 100},
{label: '101-200', min: 101, max: 200},
{label: '201-300', min: 201, max: 300},
{label: '301-400', min: 301, max: 400},
{label: '401-500', min: 401, max: 500},
{label: '501+', min: 501, max: Infinity}
];
@Component({
selector: 'app-book-length-chart',
standalone: true,
imports: [CommonModule, BaseChartDirective, Tooltip],
templateUrl: './book-length-chart.component.html',
styleUrls: ['./book-length-chart.component.scss']
})
export class BookLengthChartComponent implements OnInit, OnDestroy {
private readonly bookService = inject(BookService);
private readonly destroy$ = new Subject<void>();
public readonly chartType = 'scatter' as const;
public sweetSpot = '';
public highestRatedLength = '';
public totalRatedBooks = 0;
public readonly chartOptions: ChartConfiguration<'scatter'>['options'] = {
responsive: true,
maintainAspectRatio: false,
layout: {
padding: {top: 10, right: 20, bottom: 10, left: 10}
},
scales: {
x: {
title: {
display: true,
text: 'Page Count',
color: '#ffffff',
font: {family: "'Inter', sans-serif", size: 12, weight: 'bold'}
},
ticks: {
color: 'rgba(255, 255, 255, 0.8)',
font: {family: "'Inter', sans-serif", size: 11}
},
grid: {color: 'rgba(255, 255, 255, 0.1)'}
},
y: {
min: 0,
max: 10,
title: {
display: true,
text: 'Personal Rating',
color: '#ffffff',
font: {family: "'Inter', sans-serif", size: 12, weight: 'bold'}
},
ticks: {
color: 'rgba(255, 255, 255, 0.8)',
stepSize: 1,
font: {family: "'Inter', sans-serif", size: 11}
},
grid: {color: 'rgba(255, 255, 255, 0.1)'}
}
},
plugins: {
legend: {
display: true,
position: 'top',
labels: {
color: '#ffffff',
font: {family: "'Inter', sans-serif", size: 11},
usePointStyle: true,
pointStyle: 'circle',
padding: 15
}
},
tooltip: {
enabled: true,
backgroundColor: 'rgba(0, 0, 0, 0.95)',
titleColor: '#ffffff',
bodyColor: '#ffffff',
borderColor: '#00bcd4',
borderWidth: 2,
cornerRadius: 8,
padding: 12,
titleFont: {size: 13, weight: 'bold'},
bodyFont: {size: 11},
callbacks: {
title: (context) => {
const point = context[0].raw as BookScatterPoint;
return point.bookTitle || 'Unknown Book';
},
label: (context) => {
const point = context.raw as BookScatterPoint;
return [
`Pages: ${point.x}`,
`Rating: ${point.y}/10`,
`Status: ${point.readStatus}`
];
}
}
},
datalabels: {display: false}
},
elements: {
point: {
radius: 6,
hoverRadius: 9,
borderWidth: 2
}
}
};
private readonly chartDataSubject = new BehaviorSubject<LengthChartData>({datasets: []});
public readonly chartData$: Observable<LengthChartData> = this.chartDataSubject.asObservable();
ngOnInit(): void {
this.bookService.bookState$
.pipe(
filter(state => state.loaded),
first(),
catchError((error) => {
console.error('Error processing book length data:', error);
return EMPTY;
}),
takeUntil(this.destroy$)
)
.subscribe(() => this.processData());
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
private processData(): void {
const currentState = this.bookService.getCurrentBookState();
if (!this.isValidBookState(currentState)) return;
const books = currentState.books!;
const ratedBooks = books.filter(b =>
b.personalRating != null && b.personalRating > 0 &&
b.metadata?.pageCount != null && b.metadata.pageCount > 0
);
this.totalRatedBooks = ratedBooks.length;
if (this.totalRatedBooks === 0) return;
// Group by status
const grouped = new Map<string, BookScatterPoint[]>();
for (const book of ratedBooks) {
const statusLabel = this.getStatusLabel(book.readStatus);
if (!grouped.has(statusLabel)) grouped.set(statusLabel, []);
grouped.get(statusLabel)!.push({
x: book.metadata!.pageCount!,
y: book.personalRating!,
bookTitle: book.metadata?.title || book.fileName || 'Unknown',
readStatus: statusLabel
});
}
const datasets = Array.from(grouped.entries()).map(([label, points]) => {
const colors = STATUS_COLORS[label] || STATUS_COLORS['Other'];
return {
label: `${label} (${points.length})`,
data: points,
backgroundColor: colors.bg,
borderColor: colors.border,
pointRadius: 6,
pointHoverRadius: 9,
pointBorderWidth: 2
};
});
// Add trend line
const allPoints = ratedBooks.map(b => ({x: b.metadata!.pageCount!, y: b.personalRating!}));
const trend = this.computeTrendLine(allPoints);
if (trend) {
datasets.push({
label: 'Trend',
data: trend as BookScatterPoint[],
backgroundColor: 'transparent',
borderColor: 'rgba(255, 255, 255, 0.4)',
pointRadius: 0,
pointHoverRadius: 0,
pointBorderWidth: 0
});
}
this.chartDataSubject.next({datasets});
// Compute sweet spot
this.computeStats(ratedBooks);
}
private getStatusLabel(status?: ReadStatus): string {
if (!status) return 'Other';
switch (status) {
case ReadStatus.READ:
case ReadStatus.PARTIALLY_READ:
return 'Read';
case ReadStatus.READING:
case ReadStatus.RE_READING:
return 'Reading';
case ReadStatus.ABANDONED:
case ReadStatus.WONT_READ:
return 'Abandoned';
default:
return 'Other';
}
}
private computeStats(books: Book[]): void {
// Find range with highest average rating
let bestRange = '';
let bestAvg = 0;
let bestPageCount = 0;
let bestRating = 0;
for (const range of PAGE_RANGES) {
const rangeBooks = books.filter(b =>
b.metadata!.pageCount! >= range.min && b.metadata!.pageCount! <= range.max
);
if (rangeBooks.length >= 2) {
const avg = rangeBooks.reduce((s, b) => s + b.personalRating!, 0) / rangeBooks.length;
if (avg > bestAvg) {
bestAvg = avg;
bestRange = range.label;
}
}
}
this.sweetSpot = bestRange ? `${bestRange} pages (avg ${bestAvg.toFixed(1)})` : '-';
// Highest rated length
const highestRated = books.reduce((a, b) => (a.personalRating! >= b.personalRating! ? a : b));
this.highestRatedLength = `${highestRated.metadata!.pageCount} pages`;
}
private computeTrendLine(points: { x: number; y: number }[]): { x: number; y: number }[] | null {
if (points.length < 2) return null;
const n = points.length;
let sumX = 0, sumY = 0, sumXY = 0, sumX2 = 0;
for (const p of points) {
sumX += p.x;
sumY += p.y;
sumXY += p.x * p.y;
sumX2 += p.x * p.x;
}
const denominator = n * sumX2 - sumX * sumX;
if (denominator === 0) return null;
const slope = (n * sumXY - sumX * sumY) / denominator;
const intercept = (sumY - slope * sumX) / n;
const minX = Math.min(...points.map(p => p.x));
const maxX = Math.max(...points.map(p => p.x));
return [
{x: minX, y: Math.max(0, Math.min(10, slope * minX + intercept))},
{x: maxX, y: Math.max(0, Math.min(10, slope * maxX + intercept))}
];
}
private isValidBookState(state: unknown): state is BookState {
return (
typeof state === 'object' &&
state !== null &&
'loaded' in state &&
typeof (state as { loaded: boolean }).loaded === 'boolean' &&
'books' in state &&
Array.isArray((state as { books: unknown }).books) &&
(state as { books: Book[] }).books.length > 0
);
}
}

View File

@@ -0,0 +1,68 @@
<div class="completion-race-container">
<div class="chart-header">
<div class="chart-title">
<h3>
<i class="pi pi-flag completion-race-icon"></i>
Reading Completion Race
<i class="pi pi-question-circle chart-info-icon"
pTooltip="Each line represents a completed book's reading journey. The X-axis shows days since you started that book, and the Y-axis shows your progress from 0% to 100%. Steeper lines mean faster reads. Only books marked as 'Read' with tracked sessions are included."
tooltipPosition="bottom"
tooltipStyleClass="chart-info-tooltip"
[appendTo]="'body'"></i>
</h3>
<p class="chart-description">Compare how quickly you finished different books</p>
</div>
<div class="year-selector">
<button type="button"
class="year-nav-btn"
(click)="changeYear(-1)"
title="Previous year">
<i class="pi pi-chevron-left"></i>
</button>
<span class="current-year">{{ currentYear }}</span>
<button type="button"
class="year-nav-btn"
(click)="changeYear(1)"
title="Next year">
<i class="pi pi-chevron-right"></i>
</button>
</div>
</div>
@if (totalBooks > 0) {
<div class="stats-row">
<div class="stat-card">
<span class="stat-value">{{ totalBooks }}</span>
<span class="stat-label">Books</span>
</div>
<div class="stat-card">
<span class="stat-value">{{ avgDaysToFinish }}d</span>
<span class="stat-label">Avg Days</span>
</div>
<div class="stat-card fastest">
<span class="stat-value-sm">{{ fastestCompletion }}</span>
<span class="stat-label">Fastest</span>
</div>
<div class="stat-card slowest">
<span class="stat-value-sm">{{ slowestCompletion }}</span>
<span class="stat-label">Slowest</span>
</div>
</div>
}
<div class="chart-wrapper">
<canvas baseChart
[data]="(chartData$ | async) ?? {datasets: []}"
[options]="chartOptions"
[type]="chartType">
</canvas>
</div>
@if (totalBooks === 0) {
<div class="no-data-message">
<i class="pi pi-info-circle"></i>
<p>No completed books with reading sessions found for {{ currentYear }}.</p>
<small>Complete some books with tracked reading sessions to see your completion races.</small>
</div>
}
</div>

View File

@@ -0,0 +1,198 @@
.completion-race-container {
width: 100%;
}
.chart-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 0.5rem;
gap: 1rem;
.chart-title {
flex: 1;
h3 {
color: var(--text-color, #ffffff);
font-size: 1.25rem;
font-weight: 500;
margin: 0 0 0.5rem 0;
display: flex;
align-items: center;
gap: 0.5rem;
}
.chart-description {
color: var(--text-secondary-color);
font-size: 0.9rem;
margin: 0;
line-height: 1.4;
}
}
}
.completion-race-icon {
font-size: 1.5rem;
color: #4caf50;
}
.year-selector {
display: flex;
align-items: center;
gap: 0.5rem;
flex-shrink: 0;
.year-nav-btn {
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 6px;
padding: 0.6rem;
color: #ffffff;
cursor: pointer;
transition: all 0.3s ease;
display: flex;
align-items: center;
justify-content: center;
&:hover {
background: rgba(255, 255, 255, 0.2);
border-color: rgba(255, 255, 255, 0.3);
transform: translateY(-1px);
}
i {
font-size: 0.875rem;
}
}
.current-year {
font-size: 1.125rem;
font-weight: 600;
color: #ffffff;
min-width: 4.5rem;
text-align: center;
}
}
.stats-row {
display: flex;
gap: 0.75rem;
margin-bottom: 1rem;
flex-wrap: wrap;
.stat-card {
display: flex;
flex-direction: column;
align-items: center;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
padding: 0.5rem 1rem;
min-width: 80px;
flex: 1;
.stat-value {
font-size: 1.5rem;
font-weight: 600;
color: #4caf50;
}
.stat-value-sm {
font-size: 0.85rem;
font-weight: 500;
color: var(--text-color, #ffffff);
text-align: center;
line-height: 1.3;
}
.stat-label {
font-size: 0.75rem;
color: var(--text-secondary-color);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-top: 0.25rem;
}
&.fastest .stat-value-sm {
color: #4caf50;
}
&.slowest .stat-value-sm {
color: #ff9800;
}
}
}
.chart-wrapper {
position: relative;
height: 400px;
width: 100%;
}
.no-data-message {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 3rem;
text-align: center;
color: var(--text-secondary-color);
i {
font-size: 2.5rem;
margin-bottom: 1rem;
opacity: 0.5;
}
p {
font-size: 1rem;
margin: 0 0 0.5rem 0;
color: var(--text-color, #ffffff);
}
small {
font-size: 0.85rem;
opacity: 0.7;
}
}
@media (max-width: 768px) {
.chart-header {
flex-direction: column;
align-items: stretch;
}
.stats-row {
.stat-card {
min-width: 70px;
}
}
.chart-wrapper {
height: 350px;
}
}
@media (max-width: 480px) {
.chart-header {
.chart-title h3 {
font-size: 1rem;
}
.chart-description {
font-size: 0.8rem;
}
}
.stats-row {
gap: 0.5rem;
.stat-card {
padding: 0.4rem 0.5rem;
.stat-value {
font-size: 1.2rem;
}
}
}
}

View File

@@ -0,0 +1,222 @@
import {Component, inject, Input, OnDestroy, OnInit} from '@angular/core';
import {CommonModule} from '@angular/common';
import {BaseChartDirective} from 'ng2-charts';
import {Tooltip} from 'primeng/tooltip';
import {ChartConfiguration, ChartData} from 'chart.js';
import {BehaviorSubject, EMPTY, Observable, Subject} from 'rxjs';
import {catchError, takeUntil} from 'rxjs/operators';
import {CompletionRaceResponse, UserStatsService} from '../../../../../settings/user-management/user-stats.service';
interface BookRace {
bookId: number;
bookTitle: string;
sessions: { dayNumber: number; progress: number; date: string }[];
totalDays: number;
}
type RaceChartData = ChartData<'line', { x: number; y: number }[], number>;
const LINE_COLORS = [
'#4caf50', '#2196f3', '#ff9800', '#e91e63', '#9c27b0',
'#00bcd4', '#ff5722', '#8bc34a', '#3f51b5', '#ffc107',
'#795548', '#607d8b', '#f44336', '#009688', '#cddc39'
];
@Component({
selector: 'app-completion-race-chart',
standalone: true,
imports: [CommonModule, BaseChartDirective, Tooltip],
templateUrl: './completion-race-chart.component.html',
styleUrls: ['./completion-race-chart.component.scss']
})
export class CompletionRaceChartComponent implements OnInit, OnDestroy {
@Input() initialYear: number = new Date().getFullYear();
public currentYear: number = new Date().getFullYear();
public readonly chartType = 'line' as const;
public readonly chartData$: Observable<RaceChartData>;
public chartOptions: ChartConfiguration<'line'>['options'];
public totalBooks = 0;
public fastestCompletion = '';
public slowestCompletion = '';
public avgDaysToFinish = 0;
private readonly userStatsService = inject(UserStatsService);
private readonly destroy$ = new Subject<void>();
private readonly chartDataSubject: BehaviorSubject<RaceChartData>;
constructor() {
this.chartDataSubject = new BehaviorSubject<RaceChartData>({labels: [], datasets: []});
this.chartData$ = this.chartDataSubject.asObservable();
this.chartOptions = {
responsive: true,
maintainAspectRatio: false,
layout: {
padding: {top: 10, bottom: 10, left: 10, right: 10}
},
plugins: {
legend: {
display: true,
position: 'top',
labels: {
color: '#ffffff',
font: {family: "'Inter', sans-serif", size: 11},
boxWidth: 12,
padding: 8,
usePointStyle: true,
pointStyle: 'line'
}
},
tooltip: {
enabled: true,
backgroundColor: 'rgba(0, 0, 0, 0.9)',
titleColor: '#ffffff',
bodyColor: '#ffffff',
borderColor: '#ffffff',
borderWidth: 1,
cornerRadius: 6,
padding: 12,
titleFont: {size: 13, weight: 'bold'},
bodyFont: {size: 11},
callbacks: {
title: (context) => context[0].dataset.label || '',
label: (context) => {
const progress = (context.parsed.y ?? 0).toFixed(1);
const day = context.parsed.x;
return `Day ${day}: ${progress}% progress`;
}
}
},
datalabels: {display: false}
},
scales: {
x: {
type: 'linear',
title: {
display: true,
text: 'Days Since First Session',
color: '#ffffff',
font: {family: "'Inter', sans-serif", size: 12, weight: 'bold'}
},
ticks: {
color: '#ffffff',
font: {family: "'Inter', sans-serif", size: 11},
stepSize: 1
},
grid: {color: 'rgba(255, 255, 255, 0.1)'},
border: {display: false}
},
y: {
min: 0,
max: 100,
title: {
display: true,
text: 'Progress (%)',
color: '#ffffff',
font: {family: "'Inter', sans-serif", size: 12, weight: 'bold'}
},
ticks: {
color: '#ffffff',
font: {family: "'Inter', sans-serif", size: 11},
callback: (value) => `${value}%`
},
grid: {color: 'rgba(255, 255, 255, 0.1)'},
border: {display: false}
}
},
interaction: {
mode: 'nearest',
intersect: false
}
};
}
ngOnInit(): void {
this.currentYear = this.initialYear;
this.loadData(this.currentYear);
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
public changeYear(delta: number): void {
this.currentYear += delta;
this.loadData(this.currentYear);
}
private loadData(year: number): void {
this.userStatsService.getCompletionRace(year)
.pipe(
takeUntil(this.destroy$),
catchError((error) => {
console.error('Error loading completion race:', error);
return EMPTY;
})
)
.subscribe((data) => this.processData(data));
}
private processData(data: CompletionRaceResponse[]): void {
const bookMap = new Map<number, { title: string; sessions: { date: Date; progress: number }[] }>();
for (const item of data) {
if (!bookMap.has(item.bookId)) {
bookMap.set(item.bookId, {title: item.bookTitle, sessions: []});
}
bookMap.get(item.bookId)!.sessions.push({
date: new Date(item.sessionDate),
progress: Math.min(item.endProgress * 100, 100)
});
}
const races: BookRace[] = [];
bookMap.forEach((value, bookId) => {
if (value.sessions.length === 0) return;
const firstDate = value.sessions[0].date;
const sessionPoints = value.sessions.map(s => ({
dayNumber: Math.floor((s.date.getTime() - firstDate.getTime()) / (1000 * 60 * 60 * 24)),
progress: s.progress,
date: s.date.toLocaleDateString()
}));
const totalDays = sessionPoints.length > 0 ? sessionPoints[sessionPoints.length - 1].dayNumber : 0;
races.push({bookId, bookTitle: value.title, sessions: sessionPoints, totalDays});
});
this.totalBooks = races.length;
if (races.length > 0) {
const days = races.map(r => r.totalDays);
const fastest = races.reduce((a, b) => a.totalDays <= b.totalDays ? a : b);
const slowest = races.reduce((a, b) => a.totalDays >= b.totalDays ? a : b);
this.fastestCompletion = `${fastest.bookTitle.substring(0, 25)}${fastest.bookTitle.length > 25 ? '...' : ''} (${fastest.totalDays}d)`;
this.slowestCompletion = `${slowest.bookTitle.substring(0, 25)}${slowest.bookTitle.length > 25 ? '...' : ''} (${slowest.totalDays}d)`;
this.avgDaysToFinish = Math.round(days.reduce((a, b) => a + b, 0) / days.length);
} else {
this.fastestCompletion = '-';
this.slowestCompletion = '-';
this.avgDaysToFinish = 0;
}
const datasets = races.map((race, i) => {
const color = LINE_COLORS[i % LINE_COLORS.length];
return {
label: race.bookTitle.length > 30 ? race.bookTitle.substring(0, 30) + '...' : race.bookTitle,
data: race.sessions.map(s => ({x: s.dayNumber, y: s.progress})),
borderColor: color,
backgroundColor: color,
fill: false,
tension: 0.3,
stepped: 'before' as const,
pointRadius: 3,
pointHoverRadius: 5,
borderWidth: 2
};
});
this.chartDataSubject.next({datasets});
}
}

View File

@@ -4,6 +4,11 @@
<h3>
<i class="pi pi-calendar-plus favorite-days-icon"></i>
Favorite Reading Days
<i class="pi pi-question-circle chart-info-icon"
pTooltip="Bars show how many reading sessions and total reading time you logged on each day of the week. Filter by year and month to spot seasonal patterns. Helps you find your most productive reading days."
tooltipPosition="bottom"
tooltipStyleClass="chart-info-tooltip"
[appendTo]="'body'"></i>
</h3>
<p class="chart-description">Reading sessions and duration by day of the week</p>
</div>

View File

@@ -5,6 +5,7 @@ import {ChartConfiguration, ChartData} from 'chart.js';
import {BehaviorSubject, EMPTY, Observable, Subject} from 'rxjs';
import {catchError, takeUntil} from 'rxjs/operators';
import {Select} from 'primeng/select';
import {Tooltip} from 'primeng/tooltip';
import {FormsModule} from '@angular/forms';
import {FavoriteDaysResponse, UserStatsService} from '../../../../../settings/user-management/user-stats.service';
@@ -13,7 +14,7 @@ type FavoriteDaysChartData = ChartData<'bar', number[], string>;
@Component({
selector: 'app-favorite-days-chart',
standalone: true,
imports: [CommonModule, BaseChartDirective, Select, FormsModule],
imports: [CommonModule, BaseChartDirective, Select, FormsModule, Tooltip],
templateUrl: './favorite-days-chart.component.html',
styleUrls: ['./favorite-days-chart.component.scss']
})

View File

@@ -4,6 +4,11 @@
<h3>
<i class="pi pi-chart-bar genre-title-icon"></i>
Reading by Genre
<i class="pi pi-question-circle chart-info-icon"
pTooltip="Ranks your top genres by total reading time. Each bar shows how many hours you've spent reading books in that genre, so you can see where most of your reading time actually goes."
tooltipPosition="bottom"
tooltipStyleClass="chart-info-tooltip"
[appendTo]="'body'"></i>
</h3>
<p class="chart-description">Top genres by total reading time</p>
</div>

View File

@@ -4,6 +4,7 @@ import {BaseChartDirective} from 'ng2-charts';
import {ChartConfiguration, ChartData} from 'chart.js';
import {BehaviorSubject, EMPTY, Observable, Subject} from 'rxjs';
import {catchError, takeUntil} from 'rxjs/operators';
import {Tooltip} from 'primeng/tooltip';
import {GenreStatsResponse, UserStatsService} from '../../../../../settings/user-management/user-stats.service';
type GenreChartData = ChartData<'bar', number[], string>;
@@ -11,7 +12,7 @@ type GenreChartData = ChartData<'bar', number[], string>;
@Component({
selector: 'app-genre-stats-chart',
standalone: true,
imports: [CommonModule, BaseChartDirective],
imports: [CommonModule, BaseChartDirective, Tooltip],
templateUrl: './genre-stats-chart.component.html',
styleUrls: ['./genre-stats-chart.component.scss']
})

View File

@@ -0,0 +1,66 @@
<div class="page-turner-container">
<div class="chart-header">
<div class="chart-title">
<h3>
<i class="pi pi-bolt page-turner-icon"></i>
Page Turner Score
<i class="pi pi-question-circle chart-info-icon"
pTooltip="Each book gets a 'grip score' (0-100) based on how your reading behavior changed over time. It measures whether your sessions got longer, whether gaps between sessions shrank, and whether you had a burst of reading near the end. Higher scores mean the book pulled you in more as you read."
tooltipPosition="bottom"
tooltipStyleClass="chart-info-tooltip"
[appendTo]="'body'"></i>
</h3>
<p class="chart-description">How gripping each book was based on your reading patterns</p>
</div>
</div>
<div class="stats-row">
<div class="stat-item">
<span class="stat-value" [title]="stats.mostGripping">{{ stats.mostGripping | slice:0:18 }}</span>
<span class="stat-label">Most Gripping</span>
</div>
<div class="stat-item">
<span class="stat-value">{{ stats.avgGripScore }}</span>
<span class="stat-label">Avg Grip Score</span>
</div>
<div class="stat-item">
<span class="stat-value" [title]="stats.guiltyPleasure">{{ stats.guiltyPleasure | slice:0:18 }}</span>
<span class="stat-label">Guilty Pleasure</span>
</div>
</div>
@if (topBooks.length > 0) {
<div class="top-books">
@for (book of topBooks; track book.bookId; let i = $index) {
<div class="top-book-card">
<div class="book-rank">#{{ i + 1 }}</div>
<div class="book-info">
<span class="book-name">{{ book.bookTitle | slice:0:30 }}</span>
<div class="book-indicators">
<span class="indicator" [title]="'Session duration trend'">
<i class="pi pi-clock"></i> {{ getAccelerationLabel(book.sessionAcceleration) }}
</span>
<span class="indicator" [title]="'Gap between sessions trend'">
<i class="pi pi-calendar"></i> {{ getGapLabel(book.gapReduction) }}
</span>
@if (book.finishBurst) {
<span class="indicator burst">
<i class="pi pi-forward"></i> Finish Burst
</span>
}
</div>
</div>
<div class="book-score">{{ book.gripScore }}</div>
</div>
}
</div>
}
<div class="chart-wrapper">
<canvas baseChart
[data]="(chartData$ | async) ?? {labels: [], datasets: []}"
[options]="chartOptions"
[type]="chartType">
</canvas>
</div>
</div>

View File

@@ -0,0 +1,173 @@
.page-turner-container {
width: 100%;
}
.chart-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 0.5rem;
.chart-title {
flex: 1;
h3 {
color: var(--text-color, #ffffff);
font-size: 1.25rem;
font-weight: 500;
margin: 0 0 0.5rem 0;
display: flex;
align-items: center;
gap: 0.5rem;
}
.chart-description {
color: var(--text-secondary-color);
font-size: 0.9rem;
margin: 0;
line-height: 1.4;
}
}
}
.page-turner-icon {
font-size: 1.5rem;
color: rgba(251, 146, 60, 1);
}
.stats-row {
display: flex;
gap: 1rem;
margin-bottom: 0.75rem;
flex-wrap: wrap;
.stat-item {
display: flex;
flex-direction: column;
gap: 0.15rem;
background: rgba(255, 255, 255, 0.05);
border-radius: 8px;
padding: 0.5rem 0.75rem;
flex: 1;
min-width: 100px;
.stat-value {
color: #ffffff;
font-size: 0.95rem;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.stat-label {
color: var(--text-secondary-color);
font-size: 0.75rem;
}
}
}
.top-books {
display: flex;
gap: 0.75rem;
margin-bottom: 0.75rem;
flex-wrap: wrap;
.top-book-card {
display: flex;
align-items: center;
gap: 0.5rem;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
padding: 0.5rem 0.75rem;
flex: 1;
min-width: 180px;
.book-rank {
color: rgba(251, 146, 60, 1);
font-weight: 700;
font-size: 1rem;
}
.book-info {
flex: 1;
overflow: hidden;
.book-name {
color: #ffffff;
font-size: 0.85rem;
font-weight: 500;
display: block;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.book-indicators {
display: flex;
gap: 0.4rem;
margin-top: 0.2rem;
flex-wrap: wrap;
.indicator {
color: var(--text-secondary-color);
font-size: 0.7rem;
display: flex;
align-items: center;
gap: 0.2rem;
&.burst {
color: rgba(251, 146, 60, 0.9);
}
i {
font-size: 0.65rem;
}
}
}
}
.book-score {
color: rgba(251, 146, 60, 1);
font-size: 1.25rem;
font-weight: 700;
flex-shrink: 0;
}
}
}
.chart-wrapper {
position: relative;
height: 400px;
width: 100%;
}
@media (max-width: 768px) {
.top-books {
flex-direction: column;
.top-book-card {
min-width: unset;
}
}
}
@media (max-width: 480px) {
.chart-header .chart-title h3 {
font-size: 1.1rem;
}
.stats-row {
gap: 0.5rem;
.stat-item {
min-width: 80px;
padding: 0.4rem 0.5rem;
}
}
.chart-wrapper {
height: 350px;
}
}

View File

@@ -0,0 +1,185 @@
import {Component, inject, OnDestroy, OnInit} from '@angular/core';
import {CommonModule} from '@angular/common';
import {BaseChartDirective} from 'ng2-charts';
import {Tooltip} from 'primeng/tooltip';
import {BehaviorSubject, EMPTY, Observable, Subject} from 'rxjs';
import {catchError, takeUntil} from 'rxjs/operators';
import {ChartConfiguration, ChartData} from 'chart.js';
import {PageTurnerScoreResponse, UserStatsService} from '../../../../../settings/user-management/user-stats.service';
type PageTurnerChartData = ChartData<'bar', number[], string>;
@Component({
selector: 'app-page-turner-chart',
standalone: true,
imports: [CommonModule, BaseChartDirective, Tooltip],
templateUrl: './page-turner-chart.component.html',
styleUrls: ['./page-turner-chart.component.scss']
})
export class PageTurnerChartComponent implements OnInit, OnDestroy {
public readonly chartType = 'bar' as const;
public readonly chartData$: Observable<PageTurnerChartData>;
public readonly chartOptions: ChartConfiguration['options'];
public stats = {mostGripping: '', avgGripScore: 0, guiltyPleasure: ''};
public topBooks: PageTurnerScoreResponse[] = [];
private readonly userStatsService = inject(UserStatsService);
private readonly destroy$ = new Subject<void>();
private readonly chartDataSubject: BehaviorSubject<PageTurnerChartData>;
constructor() {
this.chartDataSubject = new BehaviorSubject<PageTurnerChartData>({labels: [], datasets: []});
this.chartData$ = this.chartDataSubject.asObservable();
this.chartOptions = {
indexAxis: 'y',
responsive: true,
maintainAspectRatio: false,
layout: {padding: {top: 10, bottom: 10, left: 10, right: 10}},
plugins: {
legend: {display: false},
tooltip: {
enabled: true,
backgroundColor: 'rgba(0, 0, 0, 0.95)',
titleColor: '#ffffff',
bodyColor: '#ffffff',
borderColor: 'rgba(251, 146, 60, 0.8)',
borderWidth: 2,
cornerRadius: 8,
displayColors: false,
padding: 16,
titleFont: {size: 14, weight: 'bold'},
bodyFont: {size: 13},
callbacks: {
label: () => ''
}
},
datalabels: {display: false}
},
scales: {
x: {
title: {
display: true,
text: 'Grip Score (0-100)',
color: '#ffffff',
font: {family: "'Inter', sans-serif", size: 13, weight: 'bold'}
},
min: 0,
max: 100,
ticks: {
color: '#ffffff',
font: {family: "'Inter', sans-serif", size: 11}
},
grid: {color: 'rgba(255, 255, 255, 0.1)'},
border: {display: false}
},
y: {
ticks: {
color: '#ffffff',
font: {family: "'Inter', sans-serif", size: 11}
},
grid: {display: false},
border: {display: false}
}
}
};
}
ngOnInit(): void {
this.loadData();
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
private loadData(): void {
this.userStatsService.getPageTurnerScores()
.pipe(
takeUntil(this.destroy$),
catchError((error) => {
console.error('Error loading page turner scores:', error);
return EMPTY;
})
)
.subscribe((data) => {
this.updateStats(data);
this.updateChartData(data);
});
}
private updateStats(data: PageTurnerScoreResponse[]): void {
if (data.length === 0) {
this.stats = {mostGripping: '-', avgGripScore: 0, guiltyPleasure: '-'};
this.topBooks = [];
return;
}
this.stats.mostGripping = data[0].bookTitle;
this.stats.avgGripScore = Math.round(data.reduce((sum, d) => sum + d.gripScore, 0) / data.length);
const guiltyPleasure = data.find(d => d.gripScore >= 60 && d.personalRating != null && d.personalRating <= 3);
this.stats.guiltyPleasure = guiltyPleasure?.bookTitle || '-';
this.topBooks = data.slice(0, 3);
}
private updateChartData(data: PageTurnerScoreResponse[]): void {
const top15 = data.slice(0, 15);
const labels = top15.map(d => d.bookTitle.length > 25 ? d.bookTitle.substring(0, 25) + '...' : d.bookTitle);
const values = top15.map(d => d.gripScore);
const bgColors = top15.map(d => {
const t = d.gripScore / 100;
const r = Math.round(59 + t * (239 - 59));
const g = Math.round(130 + t * (68 - 130));
const b = Math.round(246 + t * (68 - 246));
return `rgba(${r}, ${g}, ${b}, 0.85)`;
});
if (this.chartOptions?.plugins?.tooltip?.callbacks) {
this.chartOptions.plugins.tooltip.callbacks.label = (context) => {
const idx = context.dataIndex;
const item = top15[idx];
if (!item) return '';
const lines = [
`Grip Score: ${item.gripScore}/100`,
`Sessions: ${item.totalSessions}`,
`Avg Session: ${Math.round(item.avgSessionDurationSeconds / 60)}min`,
];
if (item.personalRating) {
lines.push(`Rating: ${item.personalRating}/10`);
}
return lines;
};
}
this.chartDataSubject.next({
labels,
datasets: [{
label: 'Grip Score',
data: values,
backgroundColor: bgColors,
borderColor: bgColors.map(c => c.replace('0.85', '1')),
borderWidth: 1,
borderRadius: 4,
barPercentage: 0.8,
categoryPercentage: 0.7
}]
});
}
getAccelerationLabel(value: number): string {
if (value > 5) return 'Increasing';
if (value < -5) return 'Decreasing';
return 'Steady';
}
getGapLabel(value: number): string {
if (value < -2) return 'Shrinking';
if (value > 2) return 'Growing';
return 'Steady';
}
}

View File

@@ -4,6 +4,11 @@
<h3>
<i class="pi pi-clock peak-hours-icon"></i>
Peak Reading Hours
<i class="pi pi-question-circle chart-info-icon"
pTooltip="Shows the number of reading sessions and total reading time for each hour of the day (midnight to 11pm). Filter by year and month to see how your habits shift over time. The peak hour highlights when you read the most."
tooltipPosition="bottom"
tooltipStyleClass="chart-info-tooltip"
[appendTo]="'body'"></i>
</h3>
<p class="chart-description">Reading activity by hour of the day</p>
</div>

View File

@@ -5,6 +5,7 @@ import {ChartConfiguration, ChartData} from 'chart.js';
import {BehaviorSubject, EMPTY, Observable, Subject} from 'rxjs';
import {catchError, takeUntil} from 'rxjs/operators';
import {Select} from 'primeng/select';
import {Tooltip} from 'primeng/tooltip';
import {FormsModule} from '@angular/forms';
import {PeakHoursResponse, UserStatsService} from '../../../../../settings/user-management/user-stats.service';
@@ -13,7 +14,7 @@ type PeakHoursChartData = ChartData<'line', number[], string>;
@Component({
selector: 'app-peak-hours-chart',
standalone: true,
imports: [CommonModule, BaseChartDirective, Select, FormsModule],
imports: [CommonModule, BaseChartDirective, Select, FormsModule, Tooltip],
templateUrl: './peak-hours-chart.component.html',
styleUrls: ['./peak-hours-chart.component.scss']
})

View File

@@ -4,6 +4,11 @@
<h3>
<i class="pi pi-star-fill personal-rating-icon"></i>
Personal Rating Distribution
<i class="pi pi-question-circle chart-info-icon"
pTooltip="Shows how many books you've given each rating from 1 to 10. Helps you see whether you tend to rate generously, harshly, or cluster around a particular score. Only books with a personal rating are included."
tooltipPosition="bottom"
tooltipStyleClass="chart-info-tooltip"
[appendTo]="'body'"></i>
</h3>
<p class="chart-description">Books by your rating (110)</p>
</div>

View File

@@ -4,6 +4,7 @@ import {BaseChartDirective} from 'ng2-charts';
import {BehaviorSubject, EMPTY, Observable, Subject} from 'rxjs';
import {catchError, filter, first, takeUntil} from 'rxjs/operators';
import {ChartConfiguration, ChartData} from 'chart.js';
import {Tooltip} from 'primeng/tooltip';
import {BookService} from '../../../../../book/service/book.service';
import {BookState} from '../../../../../book/model/state/book-state.model';
import {Book} from '../../../../../book/model/book.model';
@@ -52,7 +53,7 @@ type RatingChartData = ChartData<'bar', number[], string>;
@Component({
selector: 'app-personal-rating-chart',
standalone: true,
imports: [CommonModule, BaseChartDirective],
imports: [CommonModule, BaseChartDirective, Tooltip],
templateUrl: './personal-rating-chart.component.html',
styleUrls: ['./personal-rating-chart.component.scss']
})

View File

@@ -4,6 +4,11 @@
<h3>
<i class="pi pi-chart-scatter rating-taste-icon"></i>
Rating Taste Comparison
<i class="pi pi-question-circle chart-info-icon"
pTooltip="Each dot is a book plotted by its external rating (X-axis, e.g. Goodreads/Amazon) versus your personal rating (Y-axis). Books on the diagonal mean you agree with the crowd. Above the line means you rated higher; below means you rated lower. Quadrants show your taste patterns."
tooltipPosition="bottom"
tooltipStyleClass="chart-info-tooltip"
[appendTo]="'body'"></i>
</h3>
<p class="chart-description">Compare your personal ratings against external ratings (Goodreads, Amazon, etc.)</p>
</div>
@@ -70,12 +75,4 @@
</div>
</div>
}
@if (totalRatedBooks === 0) {
<div class="no-data-message">
<i class="pi pi-info-circle"></i>
<p>No books with both personal and external ratings found.</p>
<small>Rate your books and ensure they have external ratings from Goodreads, Amazon, etc.</small>
</div>
}
</div>

View File

@@ -1,6 +1,7 @@
import {Component, inject, OnDestroy, OnInit} from '@angular/core';
import {CommonModule} from '@angular/common';
import {BaseChartDirective} from 'ng2-charts';
import {Tooltip} from 'primeng/tooltip';
import {BehaviorSubject, EMPTY, Observable, Subject} from 'rxjs';
import {catchError, filter, first, switchMap, takeUntil} from 'rxjs/operators';
import {ChartConfiguration, ChartData, ScatterDataPoint} from 'chart.js';
@@ -30,7 +31,7 @@ type RatingTasteChartData = ChartData<'scatter', BookDataPoint[], string>;
@Component({
selector: 'app-rating-taste-chart',
standalone: true,
imports: [CommonModule, BaseChartDirective],
imports: [CommonModule, BaseChartDirective, Tooltip],
templateUrl: './rating-taste-chart.component.html',
styleUrls: ['./rating-taste-chart.component.scss']
})

View File

@@ -4,6 +4,11 @@
<h3>
<i class="pi pi-book read-status-icon"></i>
Reading Status Distribution
<i class="pi pi-question-circle chart-info-icon"
pTooltip="Breaks down your entire library by reading status: read, reading, partially read, paused, abandoned, won't read, and unread. Gives you a quick snapshot of how much of your collection you've worked through."
tooltipPosition="bottom"
tooltipStyleClass="chart-info-tooltip"
[appendTo]="'body'"></i>
</h3>
<p class="chart-description">Books by their reading status</p>
</div>

View File

@@ -4,6 +4,7 @@ import {BaseChartDirective} from 'ng2-charts';
import {BehaviorSubject, EMPTY, Observable, Subject} from 'rxjs';
import {catchError, filter, first, takeUntil} from 'rxjs/operators';
import {ChartConfiguration, ChartData, Chart, TooltipItem} from 'chart.js';
import {Tooltip} from 'primeng/tooltip';
import {BookService} from '../../../../../book/service/book.service';
import {BookState} from '../../../../../book/model/state/book-state.model';
import {Book, ReadStatus} from '../../../../../book/model/book.model';
@@ -38,7 +39,7 @@ type StatusChartData = ChartData<'doughnut', number[], string>;
@Component({
selector: 'app-read-status-chart',
standalone: true,
imports: [CommonModule, BaseChartDirective],
imports: [CommonModule, BaseChartDirective, Tooltip],
templateUrl: './read-status-chart.component.html',
styleUrls: ['./read-status-chart.component.scss']
})

View File

@@ -1,186 +0,0 @@
<div class="backlog-container">
<div class="chart-header">
<div class="chart-title">
<h3>
<i class="pi pi-clock backlog-icon"></i>
Reading Backlog Analysis
</h3>
<p class="chart-description">Track how long books sit in your library and your reading patterns over time</p>
</div>
@if (stats) {
<div class="health-score" [class]="getHealthScoreClass()">
<div class="score-circle">
<span class="score-value">{{ stats.backlogHealthScore }}</span>
</div>
<div class="score-info">
<span class="score-label">Backlog Health</span>
<span class="score-status">{{ getHealthScoreLabel() }}</span>
</div>
</div>
}
</div>
@if (stats) {
<div class="stats-overview">
<div class="stat-card">
<div class="stat-icon books-icon">
<i class="pi pi-book"></i>
</div>
<div class="stat-content">
<span class="stat-value">{{ stats.totalBooks }}</span>
<span class="stat-label">Total Books</span>
</div>
</div>
<div class="stat-card">
<div class="stat-icon unread-icon">
<i class="pi pi-inbox"></i>
</div>
<div class="stat-content">
<span class="stat-value">{{ stats.unreadBooks }}</span>
<span class="stat-label">In Backlog</span>
</div>
</div>
<div class="stat-card">
<div class="stat-icon age-icon">
<i class="pi pi-calendar"></i>
</div>
<div class="stat-content">
<span class="stat-value">{{ formatDays(stats.avgBacklogAge) }}</span>
<span class="stat-label">Avg. Library Age</span>
</div>
</div>
<div class="stat-card">
<div class="stat-icon velocity-icon">
<i class="pi pi-bolt"></i>
</div>
<div class="stat-content">
<span class="stat-value">{{ stats.readingVelocity }}</span>
<span class="stat-label">Completed (6mo)</span>
</div>
</div>
</div>
}
<div class="chart-wrapper">
<canvas baseChart
[data]="(chartData$ | async) ?? {labels: [], datasets: []}"
[options]="chartOptions"
[type]="chartType">
</canvas>
</div>
@if (stats) {
<div class="insights-section">
<h4>Key Insights</h4>
<div class="insights-grid">
@if (stats.quickestRead) {
<div class="insight-card quickest">
<div class="insight-icon">
<i class="pi pi-forward"></i>
</div>
<div class="insight-content">
<span class="insight-title">Quickest Read</span>
<span class="insight-book">{{ stats.quickestRead.title }}</span>
<span class="insight-detail">Read in {{ formatDays(stats.quickestRead.daysToComplete!) }}</span>
</div>
</div>
}
@if (stats.longestWait) {
<div class="insight-card longest">
<div class="insight-icon">
<i class="pi pi-hourglass"></i>
</div>
<div class="insight-content">
<span class="insight-title">Longest Wait</span>
<span class="insight-book">{{ stats.longestWait.title }}</span>
<span class="insight-detail">Waited {{ formatDays(stats.longestWait.daysToComplete!) }} to read</span>
</div>
</div>
}
@if (stats.oldestUnread) {
<div class="insight-card oldest">
<div class="insight-icon">
<i class="pi pi-history"></i>
</div>
<div class="insight-content">
<span class="insight-title">Oldest Unread</span>
<span class="insight-book">{{ stats.oldestUnread.title }}</span>
<span class="insight-detail">Waiting for {{ formatDays(stats.oldestUnread.daysInLibrary) }}</span>
</div>
</div>
}
@if (stats.avgDaysToRead > 0) {
<div class="insight-card average">
<div class="insight-icon">
<i class="pi pi-chart-line"></i>
</div>
<div class="insight-content">
<span class="insight-title">Average Time to Read</span>
<span class="insight-value-large">{{ formatDays(stats.avgDaysToRead) }}</span>
<span class="insight-detail">From acquisition to completion</span>
</div>
</div>
}
</div>
</div>
<div class="bucket-details">
<h4>Backlog Breakdown</h4>
<div class="bucket-list">
@for (bucket of buckets; track bucket.label) {
@if (bucket.unread + bucket.reading + bucket.completed > 0) {
<div class="bucket-item">
<div class="bucket-header">
<span class="bucket-label">{{ bucket.label }}</span>
<span class="bucket-range">{{ bucket.range }}</span>
<span class="bucket-total">{{ bucket.unread + bucket.reading + bucket.completed }} books</span>
</div>
<div class="bucket-bars">
<div class="bucket-bar-container">
@if (bucket.unread > 0) {
<div class="bucket-bar unread"
[style.flex]="bucket.unread"
[title]="bucket.unread + ' unread'">
{{ bucket.unread }}
</div>
}
@if (bucket.reading > 0) {
<div class="bucket-bar reading"
[style.flex]="bucket.reading"
[title]="bucket.reading + ' reading'">
{{ bucket.reading }}
</div>
}
@if (bucket.completed > 0) {
<div class="bucket-bar completed"
[style.flex]="bucket.completed"
[title]="bucket.completed + ' completed'">
{{ bucket.completed }}
</div>
}
</div>
</div>
@if (bucket.avgDaysToRead !== null) {
<span class="bucket-avg">Avg. {{ formatDays(bucket.avgDaysToRead) }} to read</span>
}
</div>
}
}
</div>
</div>
}
@if (!stats) {
<div class="no-data-message">
<i class="pi pi-info-circle"></i>
<p>No books with addition dates found.</p>
<small>Backlog analysis requires books with "Added On" dates in your library.</small>
</div>
}
</div>

View File

@@ -1,485 +0,0 @@
.backlog-container {
width: 100%;
}
.chart-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 1.5rem;
flex-wrap: wrap;
gap: 1rem;
.chart-title {
flex: 1;
min-width: 250px;
h3 {
color: var(--text-color, #ffffff);
font-size: 1.25rem;
font-weight: 500;
margin: 0 0 0.5rem 0;
display: flex;
align-items: center;
gap: 0.5rem;
}
.chart-description {
color: var(--text-secondary-color);
font-size: 0.9rem;
margin: 0;
line-height: 1.4;
}
}
.health-score {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
border-radius: 12px;
background: rgba(255, 255, 255, 0.05);
border: 2px solid;
&.healthy {
border-color: rgba(76, 175, 80, 0.5);
background: rgba(76, 175, 80, 0.1);
.score-circle {
background: linear-gradient(135deg, #4caf50, #81c784);
}
}
&.moderate {
border-color: rgba(255, 193, 7, 0.5);
background: rgba(255, 193, 7, 0.1);
.score-circle {
background: linear-gradient(135deg, #ffc107, #ffca28);
}
}
&.unhealthy {
border-color: rgba(239, 83, 80, 0.5);
background: rgba(239, 83, 80, 0.1);
.score-circle {
background: linear-gradient(135deg, #ef5350, #e57373);
}
}
.score-circle {
width: 48px;
height: 48px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
.score-value {
font-size: 1.1rem;
font-weight: 700;
color: #ffffff;
}
}
.score-info {
display: flex;
flex-direction: column;
.score-label {
font-size: 0.75rem;
color: var(--text-secondary-color);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.score-status {
font-size: 0.9rem;
font-weight: 600;
color: var(--text-color, #ffffff);
}
}
}
}
.backlog-icon {
font-size: 1.5rem;
color: #2196f3;
}
.stats-overview {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 1rem;
margin-bottom: 1.5rem;
.stat-card {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 1rem;
background: rgba(255, 255, 255, 0.05);
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.1);
.stat-icon {
width: 40px;
height: 40px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.1rem;
&.books-icon {
background: rgba(33, 150, 243, 0.2);
color: #2196f3;
}
&.unread-icon {
background: rgba(239, 83, 80, 0.2);
color: #ef5350;
}
&.age-icon {
background: rgba(156, 39, 176, 0.2);
color: #9c27b0;
}
&.velocity-icon {
background: rgba(76, 175, 80, 0.2);
color: #4caf50;
}
}
.stat-content {
display: flex;
flex-direction: column;
.stat-value {
font-size: 1.25rem;
font-weight: 600;
color: var(--text-color, #ffffff);
}
.stat-label {
font-size: 0.75rem;
color: var(--text-secondary-color);
}
}
}
}
.chart-wrapper {
height: 280px;
width: 100%;
margin-bottom: 1.5rem;
}
.insights-section {
margin-bottom: 1.5rem;
h4 {
color: var(--text-color, #ffffff);
font-size: 1rem;
font-weight: 500;
margin: 0 0 1rem 0;
}
.insights-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
}
.insight-card {
display: flex;
gap: 0.75rem;
padding: 1rem;
background: rgba(255, 255, 255, 0.05);
border-radius: 10px;
border-left: 3px solid;
&.quickest {
border-left-color: #4caf50;
.insight-icon {
color: #4caf50;
background: rgba(76, 175, 80, 0.15);
}
}
&.longest {
border-left-color: #ff9800;
.insight-icon {
color: #ff9800;
background: rgba(255, 152, 0, 0.15);
}
}
&.oldest {
border-left-color: #ef5350;
.insight-icon {
color: #ef5350;
background: rgba(239, 83, 80, 0.15);
}
}
&.average {
border-left-color: #2196f3;
.insight-icon {
color: #2196f3;
background: rgba(33, 150, 243, 0.15);
}
}
.insight-icon {
width: 36px;
height: 36px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.insight-content {
display: flex;
flex-direction: column;
min-width: 0;
.insight-title {
font-size: 0.7rem;
color: var(--text-secondary-color);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 0.25rem;
}
.insight-book {
font-size: 0.9rem;
font-weight: 500;
color: var(--text-color, #ffffff);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.insight-value-large {
font-size: 1.1rem;
font-weight: 600;
color: var(--text-color, #ffffff);
}
.insight-detail {
font-size: 0.8rem;
color: var(--text-secondary-color);
margin-top: 0.15rem;
}
}
}
}
.bucket-details {
h4 {
color: var(--text-color, #ffffff);
font-size: 1rem;
font-weight: 500;
margin: 0 0 1rem 0;
}
.bucket-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.bucket-item {
padding: 0.75rem 1rem;
background: rgba(255, 255, 255, 0.03);
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.08);
.bucket-header {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 0.5rem;
.bucket-label {
font-weight: 500;
color: var(--text-color, #ffffff);
font-size: 0.9rem;
}
.bucket-range {
font-size: 0.75rem;
color: var(--text-secondary-color);
padding: 0.15rem 0.5rem;
background: rgba(255, 255, 255, 0.1);
border-radius: 4px;
}
.bucket-total {
margin-left: auto;
font-size: 0.8rem;
color: var(--text-secondary-color);
}
}
.bucket-bars {
.bucket-bar-container {
display: flex;
height: 24px;
border-radius: 4px;
overflow: hidden;
background: rgba(255, 255, 255, 0.05);
}
.bucket-bar {
display: flex;
align-items: center;
justify-content: center;
font-size: 0.75rem;
font-weight: 500;
color: #ffffff;
min-width: 24px;
transition: flex 0.3s ease;
&.unread {
background: rgba(239, 83, 80, 0.8);
}
&.reading {
background: rgba(255, 193, 7, 0.8);
}
&.completed {
background: rgba(76, 175, 80, 0.8);
}
}
}
.bucket-avg {
display: block;
margin-top: 0.5rem;
font-size: 0.75rem;
color: var(--text-secondary-color);
}
}
}
.no-data-message {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 3rem;
text-align: center;
color: var(--text-secondary-color);
i {
font-size: 2.5rem;
margin-bottom: 1rem;
opacity: 0.5;
}
p {
font-size: 1rem;
margin: 0 0 0.5rem 0;
color: var(--text-color, #ffffff);
}
small {
font-size: 0.85rem;
opacity: 0.7;
}
}
@media (max-width: 768px) {
.chart-header {
.health-score {
.score-circle {
width: 40px;
height: 40px;
.score-value {
font-size: 0.95rem;
}
}
}
}
.stats-overview {
grid-template-columns: repeat(2, 1fr);
.stat-card {
.stat-content {
.stat-value {
font-size: 1.1rem;
}
}
}
}
.chart-wrapper {
height: 250px;
}
.insights-section {
.insights-grid {
grid-template-columns: 1fr;
}
}
}
@media (max-width: 480px) {
.chart-header {
.chart-title {
h3 {
font-size: 1rem;
}
.chart-description {
font-size: 0.8rem;
}
}
}
.stats-overview {
grid-template-columns: 1fr 1fr;
gap: 0.75rem;
.stat-card {
padding: 0.75rem;
.stat-icon {
width: 32px;
height: 32px;
font-size: 0.9rem;
}
}
}
.chart-wrapper {
height: 220px;
}
.bucket-details {
.bucket-item {
.bucket-header {
flex-wrap: wrap;
.bucket-total {
width: 100%;
margin-left: 0;
margin-top: 0.25rem;
}
}
}
}
}

View File

@@ -1,470 +0,0 @@
import {Component, inject, OnDestroy, OnInit} from '@angular/core';
import {CommonModule} from '@angular/common';
import {BaseChartDirective} from 'ng2-charts';
import {BehaviorSubject, EMPTY, Observable, Subject} from 'rxjs';
import {catchError, filter, first, switchMap, takeUntil} from 'rxjs/operators';
import {ChartConfiguration, ChartData} from 'chart.js';
import {Book, ReadStatus} from '../../../../../book/model/book.model';
import {BookService} from '../../../../../book/service/book.service';
import {LibraryFilterService} from '../../../library-stats/service/library-filter.service';
import {BookState} from '../../../../../book/model/state/book-state.model';
interface BacklogBucket {
label: string;
range: string;
unread: number;
reading: number;
completed: number;
avgDaysToRead: number | null;
books: BookBacklogInfo[];
}
interface BookBacklogInfo {
title: string;
addedOn: Date;
daysInLibrary: number;
daysToComplete: number | null;
status: ReadStatus;
}
interface BacklogStats {
totalBooks: number;
unreadBooks: number;
avgBacklogAge: number;
avgDaysToRead: number;
oldestUnread: BookBacklogInfo | null;
quickestRead: BookBacklogInfo | null;
longestWait: BookBacklogInfo | null;
readingVelocity: number;
backlogHealthScore: number;
}
type BacklogChartData = ChartData<'bar', number[], string>;
@Component({
selector: 'app-reading-backlog-chart',
standalone: true,
imports: [CommonModule, BaseChartDirective],
templateUrl: './reading-backlog-chart.component.html',
styleUrls: ['./reading-backlog-chart.component.scss']
})
export class ReadingBacklogChartComponent implements OnInit, OnDestroy {
private readonly bookService = inject(BookService);
private readonly libraryFilterService = inject(LibraryFilterService);
private readonly destroy$ = new Subject<void>();
public readonly chartType = 'bar' as const;
public buckets: BacklogBucket[] = [];
public stats: BacklogStats | null = null;
public readonly chartOptions: ChartConfiguration<'bar'>['options'] = {
responsive: true,
maintainAspectRatio: false,
indexAxis: 'y',
layout: {
padding: {top: 10, right: 20, bottom: 10, left: 10}
},
scales: {
x: {
stacked: true,
title: {
display: true,
text: 'Number of Books',
color: '#ffffff',
font: {
family: "'Inter', sans-serif",
size: 12,
weight: 500
}
},
ticks: {
color: 'rgba(255, 255, 255, 0.8)',
font: {
family: "'Inter', sans-serif",
size: 11
},
stepSize: 1
},
grid: {
color: 'rgba(255, 255, 255, 0.1)'
}
},
y: {
stacked: true,
title: {
display: true,
text: 'Time in Library',
color: '#ffffff',
font: {
family: "'Inter', sans-serif",
size: 12,
weight: 500
}
},
ticks: {
color: 'rgba(255, 255, 255, 0.8)',
font: {
family: "'Inter', sans-serif",
size: 11
}
},
grid: {
color: 'rgba(255, 255, 255, 0.1)'
}
}
},
plugins: {
legend: {
display: true,
position: 'top',
labels: {
color: '#ffffff',
font: {
family: "'Inter', sans-serif",
size: 11
},
usePointStyle: true,
pointStyle: 'rect',
padding: 15
}
},
tooltip: {
enabled: true,
backgroundColor: 'rgba(0, 0, 0, 0.95)',
titleColor: '#ffffff',
bodyColor: '#ffffff',
borderColor: '#2196f3',
borderWidth: 2,
cornerRadius: 8,
padding: 12,
titleFont: {size: 13, weight: 'bold'},
bodyFont: {size: 11},
callbacks: {
title: (context) => {
const bucket = this.buckets[context[0].dataIndex];
return bucket ? `${bucket.label} (${bucket.range})` : '';
},
afterBody: (context) => {
const bucket = this.buckets[context[0].dataIndex];
if (!bucket) return [];
const lines = [];
if (bucket.avgDaysToRead !== null) {
lines.push(`Avg. days to read: ${bucket.avgDaysToRead.toFixed(0)}`);
}
return lines;
}
}
}
}
};
private readonly chartDataSubject = new BehaviorSubject<BacklogChartData>({
labels: [],
datasets: []
});
public readonly chartData$: Observable<BacklogChartData> = this.chartDataSubject.asObservable();
ngOnInit(): void {
this.bookService.bookState$
.pipe(
filter(state => state.loaded),
first(),
switchMap(() =>
this.libraryFilterService.selectedLibrary$.pipe(
takeUntil(this.destroy$)
)
),
catchError((error) => {
console.error('Error processing backlog data:', error);
return EMPTY;
})
)
.subscribe(() => {
this.calculateAndUpdateChart();
});
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
private calculateAndUpdateChart(): void {
const currentState = this.bookService.getCurrentBookState();
const selectedLibraryId = this.libraryFilterService.getCurrentSelectedLibrary();
if (!this.isValidBookState(currentState)) {
this.chartDataSubject.next({labels: [], datasets: []});
this.buckets = [];
this.stats = null;
return;
}
const filteredBooks = this.filterBooksByLibrary(currentState.books!, selectedLibraryId);
const booksWithDates = this.getBooksWithAddedDate(filteredBooks);
if (booksWithDates.length === 0) {
this.chartDataSubject.next({labels: [], datasets: []});
this.buckets = [];
this.stats = null;
return;
}
this.buckets = this.calculateBacklogBuckets(booksWithDates);
this.stats = this.calculateBacklogStats(booksWithDates);
this.updateChartData();
}
private isValidBookState(state: unknown): state is BookState {
return (
typeof state === 'object' &&
state !== null &&
'loaded' in state &&
typeof (state as { loaded: boolean }).loaded === 'boolean' &&
'books' in state &&
Array.isArray((state as { books: unknown }).books) &&
(state as { books: Book[] }).books.length > 0
);
}
private filterBooksByLibrary(books: Book[], selectedLibraryId: string | number | null): Book[] {
return selectedLibraryId
? books.filter(book => book.libraryId === selectedLibraryId)
: books;
}
private getBooksWithAddedDate(books: Book[]): Book[] {
return books.filter(book => book.addedOn);
}
private calculateBacklogBuckets(books: Book[]): BacklogBucket[] {
const now = new Date();
const bucketDefs = [
{label: 'Fresh', range: '< 1 week', minDays: 0, maxDays: 7},
{label: 'Recent', range: '1-4 weeks', minDays: 7, maxDays: 30},
{label: 'Settling', range: '1-3 months', minDays: 30, maxDays: 90},
{label: 'Established', range: '3-6 months', minDays: 90, maxDays: 180},
{label: 'Seasoned', range: '6-12 months', minDays: 180, maxDays: 365},
{label: 'Vintage', range: '1-2 years', minDays: 365, maxDays: 730},
{label: 'Archive', range: '2+ years', minDays: 730, maxDays: Infinity}
];
return bucketDefs.map(def => {
const bucketBooks: BookBacklogInfo[] = [];
let unread = 0;
let reading = 0;
let completed = 0;
let totalDaysToRead = 0;
let completedCount = 0;
books.forEach(book => {
const addedDate = new Date(book.addedOn!);
const daysInLibrary = Math.floor((now.getTime() - addedDate.getTime()) / (1000 * 60 * 60 * 24));
if (daysInLibrary >= def.minDays && daysInLibrary < def.maxDays) {
let daysToComplete: number | null = null;
if (book.dateFinished) {
const finishedDate = new Date(book.dateFinished);
daysToComplete = Math.floor((finishedDate.getTime() - addedDate.getTime()) / (1000 * 60 * 60 * 24));
if (daysToComplete < 0) daysToComplete = 0;
}
const bookInfo: BookBacklogInfo = {
title: book.metadata?.title || book.fileName || 'Unknown',
addedOn: addedDate,
daysInLibrary,
daysToComplete,
status: book.readStatus || ReadStatus.UNSET
};
bucketBooks.push(bookInfo);
switch (book.readStatus) {
case ReadStatus.READING:
case ReadStatus.RE_READING:
reading++;
break;
case ReadStatus.READ:
completed++;
if (daysToComplete !== null) {
totalDaysToRead += daysToComplete;
completedCount++;
}
break;
case ReadStatus.UNREAD:
case ReadStatus.UNSET:
case ReadStatus.PAUSED:
case ReadStatus.WONT_READ:
case ReadStatus.ABANDONED:
case ReadStatus.PARTIALLY_READ:
default:
unread++;
break;
}
}
});
return {
label: def.label,
range: def.range,
unread,
reading,
completed,
avgDaysToRead: completedCount > 0 ? totalDaysToRead / completedCount : null,
books: bucketBooks
};
});
}
private calculateBacklogStats(books: Book[]): BacklogStats {
const now = new Date();
let totalDaysInLibrary = 0;
let totalDaysToRead = 0;
let completedWithDates = 0;
let unreadBooks = 0;
let oldestUnread: BookBacklogInfo | null = null;
let quickestRead: BookBacklogInfo | null = null;
let longestWait: BookBacklogInfo | null = null;
let recentlyCompleted = 0;
const sixMonthsAgo = new Date();
sixMonthsAgo.setMonth(sixMonthsAgo.getMonth() - 6);
books.forEach(book => {
const addedDate = new Date(book.addedOn!);
const daysInLibrary = Math.floor((now.getTime() - addedDate.getTime()) / (1000 * 60 * 60 * 24));
totalDaysInLibrary += daysInLibrary;
let daysToComplete: number | null = null;
if (book.dateFinished) {
const finishedDate = new Date(book.dateFinished);
daysToComplete = Math.floor((finishedDate.getTime() - addedDate.getTime()) / (1000 * 60 * 60 * 24));
if (daysToComplete < 0) daysToComplete = 0;
if (finishedDate > sixMonthsAgo) {
recentlyCompleted++;
}
}
const bookInfo: BookBacklogInfo = {
title: book.metadata?.title || book.fileName || 'Unknown',
addedOn: addedDate,
daysInLibrary,
daysToComplete,
status: book.readStatus || ReadStatus.UNSET
};
const isUnread = book.readStatus === ReadStatus.UNREAD ||
book.readStatus === ReadStatus.UNSET ||
book.readStatus === ReadStatus.PAUSED ||
!book.readStatus;
if (isUnread) {
unreadBooks++;
if (!oldestUnread || daysInLibrary > oldestUnread.daysInLibrary) {
oldestUnread = bookInfo;
}
}
if (book.readStatus === ReadStatus.READ && daysToComplete !== null) {
totalDaysToRead += daysToComplete;
completedWithDates++;
if (!quickestRead || daysToComplete < (quickestRead.daysToComplete || Infinity)) {
quickestRead = bookInfo;
}
if (!longestWait || daysToComplete > (longestWait.daysToComplete || 0)) {
longestWait = bookInfo;
}
}
});
const avgBacklogAge = books.length > 0 ? totalDaysInLibrary / books.length : 0;
const avgDaysToRead = completedWithDates > 0 ? totalDaysToRead / completedWithDates : 0;
const readingVelocity = recentlyCompleted; // Books completed in last 6 months
// Backlog health score (0-100)
// Lower unread percentage = better
// Lower avg backlog age = better
// Higher reading velocity = better
const unreadPercentage = books.length > 0 ? (unreadBooks / books.length) * 100 : 0;
const ageScore = Math.max(0, 100 - (avgBacklogAge / 365) * 50); // Penalize old backlogs
const velocityScore = Math.min(100, readingVelocity * 10); // Reward active reading
const completionScore = 100 - unreadPercentage;
const backlogHealthScore = Math.round((ageScore * 0.3 + velocityScore * 0.3 + completionScore * 0.4));
return {
totalBooks: books.length,
unreadBooks,
avgBacklogAge,
avgDaysToRead,
oldestUnread,
quickestRead,
longestWait,
readingVelocity,
backlogHealthScore: Math.min(100, Math.max(0, backlogHealthScore))
};
}
private updateChartData(): void {
const labels = this.buckets.map(b => b.label);
this.chartDataSubject.next({
labels,
datasets: [
{
label: 'Unread',
data: this.buckets.map(b => b.unread),
backgroundColor: 'rgba(239, 83, 80, 0.8)',
borderColor: '#ef5350',
borderWidth: 1,
borderRadius: 4
},
{
label: 'Reading',
data: this.buckets.map(b => b.reading),
backgroundColor: 'rgba(255, 193, 7, 0.8)',
borderColor: '#ffc107',
borderWidth: 1,
borderRadius: 4
},
{
label: 'Completed',
data: this.buckets.map(b => b.completed),
backgroundColor: 'rgba(76, 175, 80, 0.8)',
borderColor: '#4caf50',
borderWidth: 1,
borderRadius: 4
}
]
});
}
getHealthScoreClass(): string {
if (!this.stats) return '';
if (this.stats.backlogHealthScore >= 70) return 'healthy';
if (this.stats.backlogHealthScore >= 40) return 'moderate';
return 'unhealthy';
}
getHealthScoreLabel(): string {
if (!this.stats) return '';
if (this.stats.backlogHealthScore >= 70) return 'Healthy';
if (this.stats.backlogHealthScore >= 40) return 'Moderate';
return 'Needs Attention';
}
formatDays(days: number): string {
const roundedDays = Math.round(days);
if (roundedDays < 7) return `${roundedDays} day${roundedDays !== 1 ? 's' : ''}`;
const weeks = Math.round(roundedDays / 7);
if (roundedDays < 30) return `${weeks} week${weeks !== 1 ? 's' : ''}`;
const months = Math.round(roundedDays / 30);
if (roundedDays < 365) return `${months} month${months !== 1 ? 's' : ''}`;
const years = Math.round((roundedDays / 365) * 10) / 10; // Round to 1 decimal
return `${years} year${years >= 2 ? 's' : ''}`;
}
}

View File

@@ -0,0 +1,49 @@
<div class="reading-clock-container">
<div class="chart-header">
<div class="chart-title">
<h3>
<i class="pi pi-clock reading-clock-icon"></i>
Reading Clock
<i class="pi pi-question-circle chart-info-icon"
pTooltip="Each segment of the clock represents one hour of the day (12am-11pm). The size of each segment shows how many total minutes you've spent reading during that hour across all time. Warm colors indicate your busiest hours. You're classified as a Night Owl (most reading 8pm-2am), Early Bird (5am-11am), or Balanced."
tooltipPosition="bottom"
tooltipStyleClass="chart-info-tooltip"
[appendTo]="'body'"></i>
</h3>
<p class="chart-description">When do you read throughout the day</p>
</div>
</div>
@if (hasData) {
<div class="stats-row">
<div class="stat-card peak">
<span class="stat-value">{{ peakHour }}</span>
<span class="stat-label">Peak Hour</span>
</div>
<div class="stat-card total">
<span class="stat-value">{{ totalHoursRead }}h</span>
<span class="stat-label">Total Read</span>
</div>
<div class="stat-card type">
<span class="stat-value-sm">{{ readerType }}</span>
<span class="stat-label">Reader Type</span>
</div>
</div>
}
<div class="chart-wrapper">
<canvas baseChart
[data]="(chartData$ | async) ?? {labels: [], datasets: []}"
[options]="chartOptions"
[type]="chartType">
</canvas>
</div>
@if (!hasData) {
<div class="no-data-message">
<i class="pi pi-info-circle"></i>
<p>No reading session data found.</p>
<small>Track your reading sessions to see your reading clock.</small>
</div>
}
</div>

View File

@@ -0,0 +1,148 @@
.reading-clock-container {
width: 100%;
}
.chart-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 0.5rem;
.chart-title {
flex: 1;
h3 {
color: var(--text-color, #ffffff);
font-size: 1.25rem;
font-weight: 500;
margin: 0 0 0.5rem 0;
display: flex;
align-items: center;
gap: 0.5rem;
}
.chart-description {
color: var(--text-secondary-color);
font-size: 0.9rem;
margin: 0;
line-height: 1.4;
}
}
}
.reading-clock-icon {
font-size: 1.5rem;
color: #42a5f5;
}
.stats-row {
display: flex;
gap: 0.75rem;
margin-bottom: 1rem;
flex-wrap: wrap;
.stat-card {
display: flex;
flex-direction: column;
align-items: center;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
padding: 0.5rem 1rem;
flex: 1;
min-width: 80px;
.stat-value {
font-size: 1.5rem;
font-weight: 600;
color: var(--text-color, #ffffff);
}
.stat-value-sm {
font-size: 1rem;
font-weight: 500;
color: var(--text-color, #ffffff);
}
.stat-label {
font-size: 0.75rem;
color: var(--text-secondary-color);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-top: 0.25rem;
}
&.peak .stat-value {
color: #ff9800;
}
&.total .stat-value {
color: #42a5f5;
}
&.type .stat-value-sm {
color: #9c27b0;
}
}
}
.chart-wrapper {
position: relative;
height: 350px;
width: 100%;
}
.no-data-message {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 3rem;
text-align: center;
color: var(--text-secondary-color);
i {
font-size: 2.5rem;
margin-bottom: 1rem;
opacity: 0.5;
}
p {
font-size: 1rem;
margin: 0 0 0.5rem 0;
color: var(--text-color, #ffffff);
}
small {
font-size: 0.85rem;
opacity: 0.7;
}
}
@media (max-width: 768px) {
.chart-wrapper {
height: 300px;
}
}
@media (max-width: 480px) {
.chart-header .chart-title h3 {
font-size: 1rem;
}
.stats-row {
gap: 0.5rem;
.stat-card {
padding: 0.4rem 0.5rem;
.stat-value {
font-size: 1.2rem;
}
}
}
.chart-wrapper {
height: 280px;
}
}

View File

@@ -0,0 +1,168 @@
import {Component, inject, OnDestroy, OnInit} from '@angular/core';
import {CommonModule} from '@angular/common';
import {BaseChartDirective} from 'ng2-charts';
import {Tooltip} from 'primeng/tooltip';
import {BehaviorSubject, EMPTY, Observable, Subject} from 'rxjs';
import {catchError, takeUntil} from 'rxjs/operators';
import {ChartConfiguration, ChartData} from 'chart.js';
import {PeakHoursResponse, UserStatsService} from '../../../../../settings/user-management/user-stats.service';
type ClockChartData = ChartData<'polarArea', number[], string>;
const HOUR_LABELS = [
'12am', '1am', '2am', '3am', '4am', '5am',
'6am', '7am', '8am', '9am', '10am', '11am',
'12pm', '1pm', '2pm', '3pm', '4pm', '5pm',
'6pm', '7pm', '8pm', '9pm', '10pm', '11pm'
];
@Component({
selector: 'app-reading-clock-chart',
standalone: true,
imports: [CommonModule, BaseChartDirective, Tooltip],
templateUrl: './reading-clock-chart.component.html',
styleUrls: ['./reading-clock-chart.component.scss']
})
export class ReadingClockChartComponent implements OnInit, OnDestroy {
private readonly userStatsService = inject(UserStatsService);
private readonly destroy$ = new Subject<void>();
public readonly chartType = 'polarArea' as const;
public peakHour = '';
public totalHoursRead = 0;
public readerType = '';
public hasData = false;
public readonly chartOptions: ChartConfiguration<'polarArea'>['options'] = {
responsive: true,
maintainAspectRatio: false,
layout: {
padding: {top: 10, bottom: 10}
},
plugins: {
legend: {display: false},
tooltip: {
enabled: true,
backgroundColor: 'rgba(0, 0, 0, 0.9)',
titleColor: '#ffffff',
bodyColor: '#ffffff',
borderColor: '#ffffff',
borderWidth: 1,
cornerRadius: 6,
padding: 12,
titleFont: {size: 13, weight: 'bold'},
bodyFont: {size: 12},
callbacks: {
title: (context) => HOUR_LABELS[context[0].dataIndex],
label: (context) => {
const minutes = context.parsed.r;
if (minutes >= 60) {
const hrs = Math.floor(minutes / 60);
const mins = Math.round(minutes % 60);
return `${hrs}h ${mins}m of reading`;
}
return `${Math.round(minutes)}m of reading`;
}
}
},
datalabels: {display: false}
},
scales: {
r: {
ticks: {display: false},
grid: {color: 'rgba(255, 255, 255, 0.1)'},
pointLabels: {
display: true,
color: 'rgba(255, 255, 255, 0.7)',
font: {family: "'Inter', sans-serif", size: 10}
}
}
}
};
private readonly chartDataSubject = new BehaviorSubject<ClockChartData>({
labels: [],
datasets: []
});
public readonly chartData$: Observable<ClockChartData> = this.chartDataSubject.asObservable();
ngOnInit(): void {
this.userStatsService.getPeakHours()
.pipe(
takeUntil(this.destroy$),
catchError((error) => {
console.error('Error loading peak hours:', error);
return EMPTY;
})
)
.subscribe((data) => this.processData(data));
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
private processData(data: PeakHoursResponse[]): void {
if (!data || data.length === 0) {
this.hasData = false;
return;
}
this.hasData = true;
// Build 24-hour array
const hourMinutes = new Array(24).fill(0);
let totalSeconds = 0;
let peakIdx = 0;
let peakVal = 0;
for (const entry of data) {
const minutes = entry.totalDurationSeconds / 60;
hourMinutes[entry.hourOfDay] = minutes;
totalSeconds += entry.totalDurationSeconds;
if (minutes > peakVal) {
peakVal = minutes;
peakIdx = entry.hourOfDay;
}
}
this.peakHour = HOUR_LABELS[peakIdx];
this.totalHoursRead = Math.round(totalSeconds / 3600);
// Night owl vs early bird
const nightHours = [20, 21, 22, 23, 0, 1, 2];
const morningHours = [5, 6, 7, 8, 9, 10, 11];
const nightTotal = nightHours.reduce((sum, h) => sum + hourMinutes[h], 0);
const morningTotal = morningHours.reduce((sum, h) => sum + hourMinutes[h], 0);
if (nightTotal > morningTotal * 1.2) {
this.readerType = 'Night Owl';
} else if (morningTotal > nightTotal * 1.2) {
this.readerType = 'Early Bird';
} else {
this.readerType = 'Balanced';
}
// Generate colors: cool blues for low, warm oranges for peak
const maxMinutes = Math.max(...hourMinutes, 1);
const colors = hourMinutes.map(minutes => {
const ratio = minutes / maxMinutes;
if (ratio >= 0.7) return 'rgba(255, 152, 0, 0.8)'; // Warm orange
if (ratio >= 0.4) return 'rgba(255, 193, 7, 0.7)'; // Yellow
if (ratio >= 0.15) return 'rgba(100, 181, 246, 0.6)'; // Light blue
return 'rgba(66, 133, 244, 0.35)'; // Cool blue
});
this.chartDataSubject.next({
labels: HOUR_LABELS,
datasets: [{
data: hourMinutes,
backgroundColor: colors,
borderColor: colors.map(c => c.replace(/[\d.]+\)$/, '1)')),
borderWidth: 1
}]
});
}
}

View File

@@ -4,8 +4,13 @@
<h3>
<i class="pi pi-chart-line reading-dna-icon"></i>
Reading DNA Profile
<i class="pi pi-question-circle chart-info-icon"
pTooltip="A radar chart profiling your reading personality across 8 dimensions. Scores are computed from your library's genres, ratings, page counts, publication dates, and reading progress. Each trait uses non-overlapping criteria for accurate results."
tooltipPosition="bottom"
tooltipStyleClass="chart-info-tooltip"
[appendTo]="'body'"></i>
</h3>
<p class="chart-description">Your unique reading personality across 8 key dimensions</p>
<p class="chart-description">Your unique reading personality across 8 dimensions</p>
</div>
</div>
<div class="chart-wrapper">

View File

@@ -4,6 +4,7 @@ import {BaseChartDirective} from 'ng2-charts';
import {BehaviorSubject, EMPTY, Observable, Subject} from 'rxjs';
import {catchError, filter, first, takeUntil} from 'rxjs/operators';
import {ChartConfiguration, ChartData} from 'chart.js';
import {Tooltip} from 'primeng/tooltip';
import {BookService} from '../../../../../book/service/book.service';
import {BookState} from '../../../../../book/model/state/book-state.model';
import {Book, ReadStatus} from '../../../../../book/model/book.model';
@@ -31,7 +32,7 @@ type ReadingDNAChartData = ChartData<'radar', number[], string>;
@Component({
selector: 'app-reading-dna-chart',
standalone: true,
imports: [CommonModule, BaseChartDirective],
imports: [CommonModule, BaseChartDirective, Tooltip],
templateUrl: './reading-dna-chart.component.html',
styleUrls: ['./reading-dna-chart.component.scss']
})
@@ -117,7 +118,7 @@ export class ReadingDNAChartComponent implements OnInit, OnDestroy {
const insight = this.personalityInsights.find(i => i.trait === context.label);
return [
`Score: ${score.toFixed(1)}/100`,
`Score: ${score}/100`,
'',
insight ? insight.description : 'Your reading personality trait'
];
@@ -177,56 +178,49 @@ export class ReadingDNAChartComponent implements OnInit, OnDestroy {
}
private updateChartData(profile: ReadingDNAProfile | null): void {
try {
if (!profile) {
this.chartDataSubject.next({
labels: [],
datasets: []
});
this.personalityInsights = [];
return;
}
const data = [
profile.adventurous,
profile.perfectionist,
profile.intellectual,
profile.emotional,
profile.patient,
profile.social,
profile.nostalgic,
profile.ambitious
];
const gradientColors = [
'#e91e63', '#2196f3', '#00bcd4', '#ff9800',
'#9c27b0', '#3f51b5', '#673ab7', '#009688'
];
this.chartDataSubject.next({
labels: [
'Adventurous', 'Perfectionist', 'Intellectual', 'Emotional',
'Patient', 'Social', 'Nostalgic', 'Ambitious'
],
datasets: [{
label: 'Reading DNA Profile',
data,
backgroundColor: 'rgba(233, 30, 99, 0.2)',
borderColor: '#e91e63',
borderWidth: 3,
pointBackgroundColor: gradientColors,
pointBorderColor: '#ffffff',
pointBorderWidth: 3,
pointRadius: 5,
pointHoverRadius: 8,
fill: true
}]
});
this.personalityInsights = this.convertToPersonalityInsights(profile);
} catch (error) {
console.error('Error updating reading DNA chart data:', error);
if (!profile) {
this.chartDataSubject.next({labels: [], datasets: []});
this.personalityInsights = [];
return;
}
const data = [
profile.adventurous,
profile.perfectionist,
profile.intellectual,
profile.emotional,
profile.patient,
profile.social,
profile.nostalgic,
profile.ambitious
];
const gradientColors = [
'#e91e63', '#2196f3', '#00bcd4', '#ff9800',
'#9c27b0', '#3f51b5', '#673ab7', '#009688'
];
this.chartDataSubject.next({
labels: [
'Adventurous', 'Perfectionist', 'Intellectual', 'Emotional',
'Patient', 'Social', 'Nostalgic', 'Ambitious'
],
datasets: [{
label: 'Reading DNA Profile',
data,
backgroundColor: 'rgba(233, 30, 99, 0.2)',
borderColor: '#e91e63',
borderWidth: 3,
pointBackgroundColor: gradientColors,
pointBorderColor: '#ffffff',
pointBorderWidth: 3,
pointRadius: 5,
pointHoverRadius: 8,
fill: true
}]
});
this.personalityInsights = this.buildPersonalityInsights(profile);
}
private calculateReadingDNAData(): ReadingDNAProfile | null {
@@ -251,9 +245,9 @@ export class ReadingDNAChartComponent implements OnInit, OnDestroy {
);
}
private analyzeReadingDNA(books: Book[]): ReadingDNAProfile {
private analyzeReadingDNA(books: Book[]): ReadingDNAProfile | null {
if (books.length === 0) {
return this.getDefaultProfile();
return null;
}
return {
@@ -268,65 +262,49 @@ export class ReadingDNAChartComponent implements OnInit, OnDestroy {
};
}
// Genre diversity + language variety
private calculateAdventurousScore(books: Book[]): number {
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);
if (book.primaryFile?.bookType) formats.add(book.primaryFile.bookType);
});
const genreScore = Math.min(60, genres.size * 4);
const languageScore = Math.min(20, languages.size * 10);
const formatScore = Math.min(20, formats.size * 7);
// Having unique genres equal to 40% of book count = max genre diversity
const diversityRatio = genres.size / Math.max(1, books.length * 0.4);
const genreScore = Math.min(75, diversityRatio * 75);
return genreScore + languageScore + formatScore;
// Each language beyond the first adds 12.5 pts
const languageScore = Math.min(25, Math.max(0, languages.size - 1) * 12.5);
return Math.min(100, Math.round(genreScore + languageScore));
}
// Completion rate + high personal ratings
private calculatePerfectionistScore(books: Book[]): number {
const completedBooks = books.filter(b => b.readStatus === ReadStatus.READ);
const completionRate = completedBooks.length / books.length;
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) ||
(book.personalRating && book.personalRating >= 4);
});
const ratedBooks = books.filter(book => book.personalRating);
const highRatedBooks = ratedBooks.filter(book => book.personalRating! >= 4);
const highRatingRate = ratedBooks.length > 0 ? highRatedBooks.length / ratedBooks.length : 0;
const qualityRate = qualityBooks.length / books.length;
const completionScore = completionRate * 60;
const qualityScore = qualityRate * 40;
return Math.min(100, completionScore + qualityScore);
return Math.min(100, Math.round(completionRate * 60 + highRatingRate * 40));
}
// Non-fiction/academic genre proportion + long books
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'
'philosophy', 'history', 'biography', 'politics', 'psychology',
'economics', 'mathematics', 'engineering', 'medicine', 'law',
'education', 'sociology', 'nonfiction', 'non-fiction', 'academic'
];
const intellectualBooks = books.filter(book => {
if (!book.metadata?.categories) return false;
return book.metadata.categories.some(cat =>
intellectualGenres.some(genre => cat.toLowerCase().includes(genre))
);
});
const intellectualBooks = books.filter(book =>
this.bookMatchesGenres(book, intellectualGenres)
);
const longBooks = books.filter(book =>
book.metadata?.pageCount && book.metadata.pageCount > 400
@@ -335,42 +313,28 @@ export class ReadingDNAChartComponent implements OnInit, OnDestroy {
const intellectualRate = intellectualBooks.length / books.length;
const longBookRate = longBooks.length / books.length;
return Math.min(100, (intellectualRate * 70) + (longBookRate * 30));
return Math.min(100, Math.round(intellectualRate * 70 + longBookRate * 30));
}
// Emotionally-driven genre proportion + rating engagement
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'
'romance', 'memoir', 'poetry', 'drama', 'self-help',
'autobiography', 'literary fiction', 'coming of age'
];
const emotionalBooks = books.filter(book => {
if (!book.metadata?.categories) return false;
return book.metadata.categories.some(cat =>
emotionalGenres.some(genre => cat.toLowerCase().includes(genre))
);
});
const personallyRatedBooks = books.filter(book => book.personalRating);
const emotionalBooks = books.filter(book =>
this.bookMatchesGenres(book, emotionalGenres)
);
const ratedBooks = books.filter(book => book.personalRating);
const ratingEngagement = ratedBooks.length / books.length;
const emotionalRate = emotionalBooks.length / books.length;
const ratingEngagement = personallyRatedBooks.length / books.length;
return Math.min(100, (emotionalRate * 60) + (ratingEngagement * 40));
return Math.min(100, Math.round(emotionalRate * 70 + ratingEngagement * 30));
}
// Long books + series reading + in-progress commitment
private calculatePatienceScore(books: Book[]): number {
const longBooks = books.filter(book =>
book.metadata?.pageCount && book.metadata.pageCount > 500
@@ -380,64 +344,41 @@ export class ReadingDNAChartComponent implements OnInit, OnDestroy {
book.metadata?.seriesName && book.metadata?.seriesNumber
);
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,
book.koboProgress?.percentage || 0
);
return progress > 50;
});
const progressBooks = books.filter(book => this.getBookProgress(book) > 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));
return Math.min(100, Math.round(longBookRate * 40 + seriesRate * 35 + progressRate * 25));
}
// Popular/mainstream genre proportion + high review counts
private calculateSocialScore(books: Book[]): number {
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'
'thriller', 'mystery', 'crime', 'suspense', 'horror',
'fantasy', 'science fiction', 'adventure', 'true crime',
'humor', '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 mainstreamBooks = books.filter(book =>
this.bookMatchesGenres(book, mainstreamGenres)
);
const popularBooks = books.filter(book => {
const m = book.metadata;
if (!m) return false;
return (m.goodreadsReviewCount && m.goodreadsReviewCount > 10000) ||
(m.amazonReviewCount && m.amazonReviewCount > 2000);
});
const popularRate = popularBooks.length / books.length;
const mainstreamRate = mainstreamBooks.length / books.length;
const popularRate = popularBooks.length / books.length;
return Math.min(100, (popularRate * 50) + (mainstreamRate * 50));
return Math.min(100, Math.round(mainstreamRate * 50 + popularRate * 50));
}
// Old publication dates + classic genre proportion
private calculateNostalgicScore(books: Book[]): number {
const currentYear = new Date().getFullYear();
const classicThreshold = currentYear - 30;
@@ -445,37 +386,28 @@ export class ReadingDNAChartComponent implements OnInit, OnDestroy {
const oldBooks = books.filter(book => {
if (!book.metadata?.publishedDate) return false;
const pubYear = new Date(book.metadata.publishedDate).getFullYear();
return pubYear < classicThreshold;
return pubYear > 0 && 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'
'classic', 'mythology', 'folklore', 'fairy tale',
'ancient', 'medieval', 'victorian', 'gothic'
];
const oldBookRate = oldBooks.length / books.length;
const classicRate = classicGenres.length / books.length;
const classicBooks = books.filter(book =>
this.bookMatchesGenres(book, classicGenres)
);
return Math.min(100, (oldBookRate * 60) + (classicRate * 40));
const oldBookRate = oldBooks.length / books.length;
const classicRate = classicBooks.length / books.length;
return Math.min(100, Math.round(oldBookRate * 60 + classicRate * 40));
}
// Library volume + challenging book proportion + completion of challenging books
private calculateAmbitiousScore(books: Book[]): number {
const totalBooks = books.length;
const volumeScore = Math.min(40, totalBooks * 2);
// Need ~100 books to max out the volume component
const volumeScore = Math.min(40, books.length * 0.4);
const challengingBooks = books.filter(book =>
book.metadata?.pageCount && book.metadata.pageCount > 600
@@ -489,75 +421,95 @@ export class ReadingDNAChartComponent implements OnInit, OnDestroy {
const completionRate = challengingBooks.length > 0 ?
completedChallenging.length / challengingBooks.length : 0;
const challengingScore = challengingRate * 35;
const completionBonus = completionRate * 25;
return Math.min(100, volumeScore + challengingScore + completionBonus);
return Math.min(100, Math.round(volumeScore + challengingRate * 35 + completionRate * 25));
}
private getDefaultProfile(): ReadingDNAProfile {
return {
adventurous: 50,
perfectionist: 50,
intellectual: 50,
emotional: 50,
patient: 50,
social: 50,
nostalgic: 50,
ambitious: 50
private bookMatchesGenres(book: Book, genres: string[]): boolean {
if (!book.metadata?.categories) return false;
return book.metadata.categories.some(cat =>
genres.some(genre => cat.toLowerCase().includes(genre))
);
}
private getBookProgress(book: Book): number {
return Math.max(
book.epubProgress?.percentage || 0,
book.pdfProgress?.percentage || 0,
book.cbxProgress?.percentage || 0,
book.koreaderProgress?.percentage || 0,
book.koboProgress?.percentage || 0
);
}
private getTraitDescription(trait: string, score: number): string {
const descriptions: Record<string, [string, string, string]> = {
'Adventurous': [
'You tend to stick to familiar genres and formats',
'You enjoy a healthy mix of genres and styles',
'You explore a wide variety of genres, languages, and formats'
],
'Perfectionist': [
'You keep many books in progress or unfinished',
'You finish most books and favor quality reads',
'You almost always finish what you start and seek top-rated books'
],
'Intellectual': [
'Your reading leans toward lighter subjects',
'You balance entertainment with educational reading',
'You gravitate heavily toward non-fiction and scholarly material'
],
'Emotional': [
'You tend toward plot-driven or factual reading',
'You enjoy a mix of emotional and analytical reads',
'You connect deeply with memoirs, poetry, and emotionally rich stories'
],
'Patient': [
'You prefer shorter, quicker reads',
'You occasionally tackle longer works and series',
'You regularly take on epic novels and multi-book series'
],
'Social': [
'You prefer niche or lesser-known titles',
'You read a mix of popular and niche titles',
'Your library is packed with bestsellers and widely-discussed books'
],
'Nostalgic': [
'You mostly read contemporary publications',
'You appreciate a mix of classic and modern works',
'You have a deep love for classic literature and older works'
],
'Ambitious': [
'You read at a casual pace with shorter books',
'You maintain a solid reading volume with some challenging picks',
'You push yourself with large volumes of challenging, lengthy books'
]
};
const levels = descriptions[trait];
if (!levels) return '';
if (score < 33) return levels[0];
if (score < 67) return levels[1];
return levels[2];
}
private convertToPersonalityInsights(profile: ReadingDNAProfile): PersonalityInsight[] {
return [
{
trait: 'Adventurous',
score: profile.adventurous,
description: 'You explore diverse genres and experimental content',
color: '#e91e63'
},
{
trait: 'Perfectionist',
score: profile.perfectionist,
description: 'You prefer high-quality books and finish what you start',
color: '#2196f3'
},
{
trait: 'Intellectual',
score: profile.intellectual,
description: 'You gravitate toward complex, educational material',
color: '#00bcd4'
},
{
trait: 'Emotional',
score: profile.emotional,
description: 'You connect emotionally with fiction and personal stories',
color: '#ff9800'
},
{
trait: 'Patient',
score: profile.patient,
description: 'You tackle long books and complete series',
color: '#9c27b0'
},
{
trait: 'Social',
score: profile.social,
description: 'You enjoy popular, widely-discussed books',
color: '#3f51b5'
},
{
trait: 'Nostalgic',
score: profile.nostalgic,
description: 'You appreciate classic literature and older works',
color: '#673ab7'
},
{
trait: 'Ambitious',
score: profile.ambitious,
description: 'You challenge yourself with volume and difficulty',
color: '#009688'
}
private buildPersonalityInsights(profile: ReadingDNAProfile): PersonalityInsight[] {
const traits: { key: keyof ReadingDNAProfile; trait: string; color: string }[] = [
{key: 'adventurous', trait: 'Adventurous', color: '#e91e63'},
{key: 'perfectionist', trait: 'Perfectionist', color: '#2196f3'},
{key: 'intellectual', trait: 'Intellectual', color: '#00bcd4'},
{key: 'emotional', trait: 'Emotional', color: '#ff9800'},
{key: 'patient', trait: 'Patient', color: '#9c27b0'},
{key: 'social', trait: 'Social', color: '#3f51b5'},
{key: 'nostalgic', trait: 'Nostalgic', color: '#673ab7'},
{key: 'ambitious', trait: 'Ambitious', color: '#009688'}
];
return traits.map(({key, trait, color}) => ({
trait,
score: profile[key],
description: this.getTraitDescription(trait, profile[key]),
color
}));
}
}

View File

@@ -4,8 +4,13 @@
<h3>
<i class="pi pi-chart-line reading-habits-icon"></i>
Reading Habits Analysis
<i class="pi pi-question-circle chart-info-icon"
pTooltip="A radar chart analyzing your reading behavior across 8 dimensions. Consistency measures regularity of completions over time. Completionism tracks finish vs. abandonment rate. Methodology checks if you read series in order. Scores are derived from your reading activity, dates, and progress data."
tooltipPosition="bottom"
tooltipStyleClass="chart-info-tooltip"
[appendTo]="'body'"></i>
</h3>
<p class="chart-description">Your behavioral reading patterns across 8 key dimensions</p>
<p class="chart-description">Your behavioral reading patterns across 8 dimensions</p>
</div>
</div>
<div class="chart-wrapper">

View File

@@ -4,6 +4,7 @@ import {BaseChartDirective} from 'ng2-charts';
import {BehaviorSubject, EMPTY, Observable, Subject} from 'rxjs';
import {catchError, filter, first, takeUntil} from 'rxjs/operators';
import {ChartConfiguration, ChartData} from 'chart.js';
import {Tooltip} from 'primeng/tooltip';
import {BookService} from '../../../../../book/service/book.service';
import {BookState} from '../../../../../book/model/state/book-state.model';
import {Book, ReadStatus} from '../../../../../book/model/book.model';
@@ -31,7 +32,7 @@ type ReadingHabitsChartData = ChartData<'radar', number[], string>;
@Component({
selector: 'app-reading-habits-chart',
standalone: true,
imports: [CommonModule, BaseChartDirective],
imports: [CommonModule, BaseChartDirective, Tooltip],
templateUrl: './reading-habits-chart.component.html',
styleUrls: ['./reading-habits-chart.component.scss']
})
@@ -117,7 +118,7 @@ export class ReadingHabitsChartComponent implements OnInit, OnDestroy {
const insight = this.habitInsights.find(i => i.habit === context.label);
return [
`Score: ${score.toFixed(1)}/100`,
`Score: ${score}/100`,
'',
insight ? insight.description : 'Your reading habit pattern'
];
@@ -177,56 +178,49 @@ export class ReadingHabitsChartComponent implements OnInit, OnDestroy {
}
private updateChartData(profile: ReadingHabitsProfile | null): void {
try {
if (!profile) {
this.chartDataSubject.next({
labels: [],
datasets: []
});
this.habitInsights = [];
return;
}
const data = [
profile.consistency,
profile.multitasking,
profile.completionism,
profile.exploration,
profile.organization,
profile.intensity,
profile.methodology,
profile.momentum
];
const habitColors = [
'#9c27b0', '#e91e63', '#ff5722', '#ff9800',
'#ffc107', '#4caf50', '#2196f3', '#673ab7'
];
this.chartDataSubject.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
}]
});
this.habitInsights = this.convertToHabitInsights(profile);
} catch (error) {
console.error('Error updating reading habits chart data:', error);
if (!profile) {
this.chartDataSubject.next({labels: [], datasets: []});
this.habitInsights = [];
return;
}
const data = [
profile.consistency,
profile.multitasking,
profile.completionism,
profile.exploration,
profile.organization,
profile.intensity,
profile.methodology,
profile.momentum
];
const habitColors = [
'#9c27b0', '#e91e63', '#ff5722', '#ff9800',
'#ffc107', '#4caf50', '#2196f3', '#673ab7'
];
this.chartDataSubject.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
}]
});
this.habitInsights = this.buildHabitInsights(profile);
}
private calculateReadingHabitsData(): ReadingHabitsProfile | null {
@@ -251,9 +245,9 @@ export class ReadingHabitsChartComponent implements OnInit, OnDestroy {
);
}
private analyzeReadingHabits(books: Book[]): ReadingHabitsProfile {
private analyzeReadingHabits(books: Book[]): ReadingHabitsProfile | null {
if (books.length === 0) {
return this.getDefaultProfile();
return null;
}
return {
@@ -268,321 +262,322 @@ export class ReadingHabitsChartComponent implements OnInit, OnDestroy {
};
}
// Regularity of reading over time (coefficient of variation of gaps between completions)
private calculateConsistencyScore(books: Book[]): number {
const booksWithDates = books.filter(book => book.dateFinished || book.addedOn);
if (booksWithDates.length === 0) return 30;
const completedBooks = books.filter(book => book.readStatus === ReadStatus.READ && book.dateFinished);
if (completedBooks.length < 2) return 25;
let consistencyScore = 50;
const inProgress = books.filter(book =>
book.readStatus === ReadStatus.READING || book.readStatus === ReadStatus.RE_READING
);
const progressRate = inProgress.length / books.length;
consistencyScore += progressRate * 30;
const sortedByCompletion = completedBooks
const completedBooks = books
.filter(book => book.readStatus === ReadStatus.READ && book.dateFinished)
.sort((a, b) => new Date(a.dateFinished!).getTime() - new Date(b.dateFinished!).getTime());
if (sortedByCompletion.length >= 3) {
consistencyScore += 20;
if (completedBooks.length < 3) {
return Math.min(20, completedBooks.length * 10);
}
return Math.min(100, consistencyScore);
const dates = completedBooks.map(b => new Date(b.dateFinished!).getTime());
const gaps: number[] = [];
for (let i = 1; i < dates.length; i++) {
gaps.push((dates[i] - dates[i - 1]) / (1000 * 60 * 60 * 24));
}
const meanGap = gaps.reduce((a, b) => a + b, 0) / gaps.length;
if (meanGap === 0) return 50; // all finished same day — likely bulk import
const variance = gaps.reduce((sum, g) => sum + Math.pow(g - meanGap, 2), 0) / gaps.length;
const stdDev = Math.sqrt(variance);
const cv = stdDev / meanGap; // coefficient of variation: < 0.5 = very regular, > 2 = very irregular
const regularityScore = Math.max(0, Math.min(70, (1 - cv / 2) * 70));
const volumeBonus = Math.min(30, completedBooks.length * 1.5);
return Math.min(100, Math.round(regularityScore + volumeBonus));
}
// How many books are being read simultaneously
private calculateMultitaskingScore(books: Book[]): number {
// ...existing code from service...
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;
const activeBooks = books.filter(book =>
book.readStatus === ReadStatus.READING || book.readStatus === ReadStatus.RE_READING
).length;
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,
book.koboProgress?.percentage || 0
);
return progress > 0 && progress < 100;
// 1 active = 10, 2 = 30, 3 = 50, 4 = 65, 5+ = 75+
const activeScore = Math.min(75, activeBooks <= 1 ? activeBooks * 10 : 10 + (activeBooks - 1) * 20);
// Partial-progress books (started but not currently reading or finished)
const partialBooks = books.filter(book => {
if (book.readStatus === ReadStatus.READING || book.readStatus === ReadStatus.RE_READING) return false;
if (book.readStatus === ReadStatus.READ) return false;
const progress = this.getBookProgress(book);
return progress > 10 && progress < 90;
});
const partialScore = Math.min(25, partialBooks.length * 5);
const multitaskingScore = Math.min(60, activeBooks * 15);
const progressScore = Math.min(40, (booksWithProgress.length / books.length) * 80);
return Math.min(100, multitaskingScore + progressScore);
return Math.min(100, Math.round(activeScore + partialScore));
}
// Completion rate vs abandonment among started books
private calculateCompletionismScore(books: Book[]): number {
// ...existing code from service...
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 started = books.filter(b =>
b.readStatus === ReadStatus.READ ||
b.readStatus === ReadStatus.ABANDONED ||
b.readStatus === ReadStatus.READING ||
b.readStatus === ReadStatus.RE_READING ||
this.getBookProgress(b) > 0
);
const completionRate = completed.length / (books.length - unfinished.length);
const abandonmentRate = abandoned.length / books.length;
if (started.length === 0) return 0;
const completionScore = completionRate * 70;
const abandonmentPenalty = abandonmentRate * 30;
const completed = books.filter(b => b.readStatus === ReadStatus.READ);
const abandoned = books.filter(b => b.readStatus === ReadStatus.ABANDONED);
return Math.max(0, Math.min(100, completionScore - abandonmentPenalty + 30));
const completionRate = completed.length / started.length;
const abandonmentRate = abandoned.length / started.length;
const completionScore = completionRate * 75;
const loyaltyScore = (1 - abandonmentRate) * 25;
return Math.min(100, Math.round(completionScore + loyaltyScore));
}
// Author diversity relative to library size + publication era spread + languages
private calculateExplorationScore(books: Book[]): number {
// ...existing code from service...
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);
});
book.metadata?.authors?.forEach(a => authors.add(a.toLowerCase()));
});
const authorDiversityScore = Math.min(50, authors.size * 2);
const maxBooksPerAuthor = Math.max(...Array.from(authorCounts.values()));
const concentrationPenalty = Math.max(0, (maxBooksPerAuthor - 3) * 5);
// Unique authors relative to book count (1:1 ratio = max diversity)
const authorRatio = authors.size / Math.max(1, books.length);
const diversityScore = Math.min(60, authorRatio * 60);
const years = new Set<number>();
// Publication era spread
const years: number[] = [];
books.forEach(book => {
if (book.metadata?.publishedDate) {
years.add(new Date(book.metadata.publishedDate).getFullYear());
const year = new Date(book.metadata.publishedDate).getFullYear();
if (year > 0) years.push(year);
}
});
const temporalScore = Math.min(30, years.size * 2);
let temporalScore = 0;
if (years.length >= 2) {
const yearSpread = Math.max(...years) - Math.min(...years);
temporalScore = Math.min(25, yearSpread * 0.5);
}
// Language variety
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);
const languageScore = Math.min(15, Math.max(0, languages.size - 1) * 7.5);
return Math.max(10, Math.min(100, authorDiversityScore + temporalScore + languageScore - concentrationPenalty));
return Math.min(100, Math.round(diversityScore + temporalScore + languageScore));
}
// Library curation: rating discipline + read status management + series tracking
private calculateOrganizationScore(books: Book[]): number {
// ...existing code from service...
const seriesBooks = books.filter(book => book.metadata?.seriesName && book.metadata?.seriesNumber);
const seriesScore = (seriesBooks.length / books.length) * 40;
// Rating discipline: % of completed books with personal ratings
const completedBooks = books.filter(b => b.readStatus === ReadStatus.READ);
const ratedCompleted = completedBooks.filter(b => b.personalRating);
const ratingRate = completedBooks.length > 0 ? ratedCompleted.length / completedBooks.length : 0;
const ratingScore = ratingRate * 40;
const wellOrganizedBooks = books.filter(book => {
const metadata = book.metadata;
if (!metadata) return false;
// Read status discipline: % of books with a status set (not UNSET)
const statusSet = books.filter(b => b.readStatus && b.readStatus !== ReadStatus.UNSET);
const statusRate = statusSet.length / books.length;
const statusScore = statusRate * 35;
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;
// Series tracking: % of series books with series numbers
const seriesBooks = books.filter(b => b.metadata?.seriesName);
const numberedSeries = seriesBooks.filter(b => b.metadata?.seriesNumber);
const seriesRate = seriesBooks.length > 0 ? numberedSeries.length / seriesBooks.length : 1;
const seriesScore = seriesRate * 25;
return hasBasicInfo && (hasDetailedInfo || hasCategories);
});
const metadataScore = (wellOrganizedBooks.length / books.length) * 35;
const ratedBooks = books.filter(book => book.personalRating);
const ratingScore = (ratedBooks.length / books.length) * 25;
return Math.min(100, seriesScore + metadataScore + ratingScore);
return Math.min(100, Math.round(ratingScore + statusScore + seriesScore));
}
// Average book length + deep reading progress
private calculateIntensityScore(books: Book[]): number {
// ...existing code from service...
const booksWithPages = books.filter(book => book.metadata?.pageCount && book.metadata.pageCount > 0);
if (booksWithPages.length === 0) return 40;
const booksWithPages = books.filter(b => b.metadata?.pageCount && b.metadata.pageCount > 0);
if (booksWithPages.length === 0) return 0;
const averagePages = booksWithPages.reduce((sum, book) => sum + (book.metadata?.pageCount || 0), 0) / booksWithPages.length;
const intensityFromLength = Math.min(50, averagePages / 8);
const avgPages = booksWithPages.reduce((sum, b) => sum + (b.metadata?.pageCount || 0), 0) / booksWithPages.length;
// 200 avg = 20, 400 avg = 40, 600+ avg = 60
const lengthScore = Math.min(60, avgPages / 10);
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;
});
// Books read past 75% progress
const deepReaders = books.filter(b => this.getBookProgress(b) > 75);
const progressScore = books.length > 0 ? Math.min(40, (deepReaders.length / books.length) * 40) : 0;
const progressScore = (highProgressBooks.length / books.length) * 30;
const completedSeriesBooks = books.filter(book =>
book.metadata?.seriesName && book.readStatus === ReadStatus.READ
);
const seriesIntensityScore = (completedSeriesBooks.length / books.length) * 20;
return Math.min(100, intensityFromLength + progressScore + seriesIntensityScore);
return Math.min(100, Math.round(lengthScore + progressScore));
}
// Reading series in order + deep author dives + focused genre reading
private calculateMethodologyScore(books: Book[]): number {
// ...existing code from service...
const seriesBooks = books.filter(book => book.metadata?.seriesName);
// Series order discipline
const seriesBooks = books.filter(b => b.metadata?.seriesName && b.metadata?.seriesNumber);
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);
const name = book.metadata!.seriesName!.toLowerCase();
if (!seriesGroups.has(name)) seriesGroups.set(name, []);
seriesGroups.get(name)!.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;
}
}
let orderedSeries = 0;
let totalMultiBookSeries = 0;
seriesGroups.forEach(group => {
if (group.length < 2) return;
totalMultiBookSeries++;
const sorted = [...group].sort((a, b) =>
(a.metadata?.seriesNumber || 0) - (b.metadata?.seriesNumber || 0)
);
// Check if completion dates follow series number order
const datesInOrder = sorted.every((book, i) => {
if (i === 0) return true;
if (!book.dateFinished || !sorted[i - 1].dateFinished) return true;
return new Date(book.dateFinished) >= new Date(sorted[i - 1].dateFinished!);
});
if (datesInOrder) orderedSeries++;
});
const authorBooks = new Map<string, Book[]>();
const orderScore = totalMultiBookSeries > 0
? (orderedSeries / totalMultiBookSeries) * 50
: 25;
// Deep author dives (3+ books by same author)
const authorCounts = new Map<string, number>();
books.forEach(book => {
book.metadata?.authors?.forEach(author => {
const authorName = author.toLowerCase();
if (!authorBooks.has(authorName)) {
authorBooks.set(authorName, []);
}
authorBooks.get(authorName)!.push(book);
book.metadata?.authors?.forEach(a => {
const name = a.toLowerCase();
authorCounts.set(name, (authorCounts.get(name) || 0) + 1);
});
});
const deepDiveAuthors = Array.from(authorCounts.values()).filter(c => c >= 3).length;
const authorDepthScore = Math.min(30, deepDiveAuthors * 10);
const systematicAuthors = Array.from(authorBooks.values()).filter(books => books.length >= 2).length;
const authorMethodologyScore = Math.min(30, systematicAuthors * 5);
const categoryBooks = new Map<string, number>();
// Focused genre reading (5+ books in a genre)
const genreCounts = new Map<string, number>();
books.forEach(book => {
book.metadata?.categories?.forEach(category => {
const cat = category.toLowerCase();
categoryBooks.set(cat, (categoryBooks.get(cat) || 0) + 1);
book.metadata?.categories?.forEach(cat => {
genreCounts.set(cat.toLowerCase(), (genreCounts.get(cat.toLowerCase()) || 0) + 1);
});
});
const focusedGenres = Array.from(genreCounts.values()).filter(c => c >= 5).length;
const genreDepthScore = Math.min(20, focusedGenres * 5);
const majorCategories = Array.from(categoryBooks.values()).filter(count => count >= 3).length;
const categoryMethodologyScore = Math.min(25, majorCategories * 8);
const baseMethodologyScore = books.length >= 10 ? 15 : Math.max(5, books.length);
return Math.min(100, systematicSeriesScore + authorMethodologyScore + categoryMethodologyScore + baseMethodologyScore);
return Math.min(100, Math.round(orderScore + authorDepthScore + genreDepthScore));
}
// Recent reading activity + currently reading + acceleration
private calculateMomentumScore(books: Book[]): number {
// ...existing code from service...
const completedBooks = books.filter(book => book.readStatus === ReadStatus.READ && book.dateFinished);
if (completedBooks.length === 0) {
const activeBooks = books.filter(book =>
book.readStatus === ReadStatus.READING || book.readStatus === ReadStatus.RE_READING
);
return Math.min(40, activeBooks.length * 10);
}
const sortedBooks = completedBooks.sort((a, b) =>
new Date(a.dateFinished!).getTime() - new Date(b.dateFinished!).getTime()
);
let momentumScore = 20;
const sixMonthsAgo = new Date();
const now = new Date();
const threeMonthsAgo = new Date(now);
threeMonthsAgo.setMonth(threeMonthsAgo.getMonth() - 3);
const sixMonthsAgo = new Date(now);
sixMonthsAgo.setMonth(sixMonthsAgo.getMonth() - 6);
const recentCompletions = sortedBooks.filter(book =>
new Date(book.dateFinished!) > sixMonthsAgo
// Recent completions (last 6 months): ~1 per month = ~45pts
const recentCompletions = books.filter(b =>
b.readStatus === ReadStatus.READ && b.dateFinished &&
new Date(b.dateFinished) > sixMonthsAgo
);
const recentScore = Math.min(45, recentCompletions.length * 7.5);
momentumScore += Math.min(40, recentCompletions.length * 5);
const currentlyReading = books.filter(book =>
book.readStatus === ReadStatus.READING || book.readStatus === ReadStatus.RE_READING
// Currently reading
const activeBooks = books.filter(b =>
b.readStatus === ReadStatus.READING || b.readStatus === ReadStatus.RE_READING
);
const activeScore = Math.min(30, activeBooks.length * 10);
momentumScore += Math.min(25, currentlyReading.length * 8);
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;
// Almost-done books (>70% progress, not yet finished)
const almostDone = books.filter(b => {
const p = this.getBookProgress(b);
return p > 70 && p < 100 && b.readStatus !== ReadStatus.READ;
});
const progressScore = Math.min(25, almostDone.length * 8);
momentumScore += Math.min(15, highProgressBooks.length * 3);
return Math.min(100, momentumScore);
return Math.min(100, Math.round(recentScore + activeScore + progressScore));
}
private getDefaultProfile(): ReadingHabitsProfile {
return {
consistency: 40,
multitasking: 30,
completionism: 50,
exploration: 45,
organization: 35,
intensity: 40,
methodology: 35,
momentum: 30
private getBookProgress(book: Book): number {
return Math.max(
book.epubProgress?.percentage || 0,
book.pdfProgress?.percentage || 0,
book.cbxProgress?.percentage || 0,
book.koreaderProgress?.percentage || 0,
book.koboProgress?.percentage || 0
);
}
private getHabitDescription(habit: string, score: number): string {
const descriptions: Record<string, [string, string, string]> = {
'Consistency': [
'Your reading is sporadic with long gaps between books',
'You read at a fairly regular pace throughout the year',
'You maintain a very steady, disciplined reading rhythm'
],
'Multitasking': [
'You prefer focusing on one book at a time',
'You occasionally juggle a couple of books at once',
'You regularly read multiple books simultaneously'
],
'Completionism': [
'You often set books aside before finishing them',
'You finish most books you start, with a few exceptions',
'You almost never abandon a book once you start it'
],
'Exploration': [
'You tend to revisit favorite authors and familiar territory',
'You balance familiar authors with occasional new discoveries',
'You actively seek out new authors, eras, and languages'
],
'Organization': [
'Your library could use more rating and status tracking',
'You keep your library reasonably well curated',
'You diligently rate, categorize, and track every book'
],
'Intensity': [
'You lean toward shorter, lighter reads',
'You read a mix of short and longer books',
'You consistently tackle lengthy, immersive books'
],
'Methodology': [
'You pick books spontaneously without a system',
'You show some methodical patterns in your reading choices',
'You systematically work through series, authors, and genres'
],
'Momentum': [
'Your reading activity has been quiet recently',
'You have a steady reading pace going',
'You are on a strong active reading streak right now'
]
};
const levels = descriptions[habit];
if (!levels) return '';
if (score < 33) return levels[0];
if (score < 67) return levels[1];
return levels[2];
}
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'
}
private buildHabitInsights(profile: ReadingHabitsProfile): HabitInsight[] {
const habits: { key: keyof ReadingHabitsProfile; habit: string; color: string }[] = [
{key: 'consistency', habit: 'Consistency', color: '#9c27b0'},
{key: 'multitasking', habit: 'Multitasking', color: '#e91e63'},
{key: 'completionism', habit: 'Completionism', color: '#ff5722'},
{key: 'exploration', habit: 'Exploration', color: '#ff9800'},
{key: 'organization', habit: 'Organization', color: '#ffc107'},
{key: 'intensity', habit: 'Intensity', color: '#4caf50'},
{key: 'methodology', habit: 'Methodology', color: '#2196f3'},
{key: 'momentum', habit: 'Momentum', color: '#673ab7'}
];
return habits.map(({key, habit, color}) => ({
habit,
score: profile[key],
description: this.getHabitDescription(habit, profile[key]),
color
}));
}
}

View File

@@ -4,6 +4,11 @@
<h3>
<i class="pi pi-calendar reading-heatmap-icon"></i>
Reading Activity Heatmap
<i class="pi pi-question-circle chart-info-icon"
pTooltip="A 10-year grid showing how many books were added to your library each month. Darker cells mean more books. Helps you visualize long-term acquisition trends and seasonal reading bursts."
tooltipPosition="bottom"
tooltipStyleClass="chart-info-tooltip"
[appendTo]="'body'"></i>
</h3>
<p class="chart-description">Monthly reading activity over the past 10 years</p>
</div>

View File

@@ -1,6 +1,7 @@
import {Component, inject, OnDestroy, OnInit} from '@angular/core';
import {CommonModule} from '@angular/common';
import {BaseChartDirective} from 'ng2-charts';
import {Tooltip} from 'primeng/tooltip';
import {BehaviorSubject, EMPTY, Observable, Subject} from 'rxjs';
import {catchError, filter, first, takeUntil} from 'rxjs/operators';
import {ChartConfiguration, ChartData} from 'chart.js';
@@ -27,7 +28,7 @@ type HeatmapChartData = ChartData<'matrix', MatrixDataPoint[], string>;
@Component({
selector: 'app-reading-heatmap-chart',
standalone: true,
imports: [CommonModule, BaseChartDirective],
imports: [CommonModule, BaseChartDirective, Tooltip],
templateUrl: './reading-heatmap-chart.component.html',
styleUrls: ['./reading-heatmap-chart.component.scss']
})

View File

@@ -4,6 +4,11 @@
<h3>
<i class="pi pi-chart-bar reading-progress-icon"></i>
Reading Progress Distribution
<i class="pi pi-question-circle chart-info-icon"
pTooltip="Shows how many books fall into each progress bracket: not started (0%), just started (1-25%), halfway (26-50%), mostly done (51-75%), nearly finished (76-99%), and completed (100%). Progress is based on the furthest point reached across all reading sessions."
tooltipPosition="bottom"
tooltipStyleClass="chart-info-tooltip"
[appendTo]="'body'"></i>
</h3>
<p class="chart-description">Books by reading completion status</p>
</div>

View File

@@ -1,6 +1,7 @@
import {Component, inject, OnDestroy, OnInit} from '@angular/core';
import {CommonModule} from '@angular/common';
import {BaseChartDirective} from 'ng2-charts';
import {Tooltip} from 'primeng/tooltip';
import {BehaviorSubject, EMPTY, Observable, Subject} from 'rxjs';
import {catchError, filter, first, takeUntil} from 'rxjs/operators';
import {ChartConfiguration, ChartData} from 'chart.js';
@@ -44,7 +45,7 @@ type ProgressChartData = ChartData<'doughnut', number[], string>;
@Component({
selector: 'app-reading-progress-chart',
standalone: true,
imports: [CommonModule, BaseChartDirective],
imports: [CommonModule, BaseChartDirective, Tooltip],
templateUrl: './reading-progress-chart.component.html',
styleUrls: ['./reading-progress-chart.component.scss']
})

View File

@@ -4,6 +4,11 @@
<h3>
<i class="pi pi-calendar heatmap-title-icon"></i>
Reading Session Activity
<i class="pi pi-question-circle chart-info-icon"
pTooltip="A daily heatmap of your reading sessions for the selected year. Darker cells mean more sessions that day. Streak stats track consecutive reading days across all time. Milestones unlock as you hit streak or total-day thresholds."
tooltipPosition="bottom"
tooltipStyleClass="chart-info-tooltip"
[appendTo]="'body'"></i>
</h3>
<p class="chart-description">Daily overview of your reading sessions across the year</p>
</div>
@@ -24,6 +29,7 @@
</div>
<div class="header-spacer"></div>
</div>
<div class="chart-wrapper">
<canvas baseChart
[data]="(chartData$ | async) ?? {labels: [], datasets: []}"
@@ -31,4 +37,32 @@
[type]="chartType">
</canvas>
</div>
@if (hasStreakData) {
<div class="chart-footer">
<div class="footer-pills left">
<span class="pill current">
<span class="pill-value">{{ currentStreak }}</span> Current Streak
</span>
<span class="pill longest">
<span class="pill-value">{{ longestStreak }}</span> Longest Streak
</span>
<span class="pill total">
<span class="pill-value">{{ totalReadingDays }}</span> Total Days
</span>
<span class="pill consistency">
<span class="pill-value">{{ consistencyPercent }}%</span> Consistency
</span>
</div>
@if (milestones.length > 0) {
<div class="footer-pills right">
@for (milestone of milestones; track milestone.label) {
<span class="pill milestone" [class.unlocked]="milestone.unlocked" [class.locked]="!milestone.unlocked">
<span class="pill-icon">{{ milestone.icon }}</span> {{ milestone.label }}
</span>
}
</div>
}
</div>
}
</div>

View File

@@ -15,6 +15,9 @@
font-size: 1.25rem;
font-weight: 500;
margin: 0 0 0.5rem 0;
display: flex;
align-items: center;
gap: 0.5rem;
}
.chart-description {
@@ -91,6 +94,60 @@
margin-right: 0.25em;
}
.chart-footer {
display: flex;
align-items: flex-start;
justify-content: space-between;
flex-wrap: wrap;
gap: 0.75rem;
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid rgba(255, 255, 255, 0.08);
}
.footer-pills {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.5rem;
&.right {
justify-content: flex-end;
}
}
.pill {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.35rem 0.65rem;
border-radius: 6px;
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(255, 255, 255, 0.05);
font-size: 0.85rem;
color: var(--text-color, #ffffff);
font-weight: 500;
white-space: nowrap;
.pill-value {
font-weight: 700;
}
&.current .pill-value { color: #ff9800; }
&.longest .pill-value { color: #4caf50; }
&.total .pill-value { color: #2196f3; }
&.consistency .pill-value { color: #9c27b0; }
&.milestone.unlocked {
border-color: rgba(76, 175, 80, 0.3);
background: rgba(76, 175, 80, 0.1);
}
&.milestone.locked {
opacity: 0.4;
}
}
@media (max-width: 768px) {
.chart-header {
grid-template-columns: 1fr;
@@ -115,6 +172,11 @@
display: none;
}
}
.chart-footer {
flex-direction: column;
align-items: flex-start;
}
}
@media (max-width: 480px) {

View File

@@ -1,6 +1,7 @@
import {Component, inject, Input, OnDestroy, OnInit} from '@angular/core';
import {CommonModule} from '@angular/common';
import {BaseChartDirective} from 'ng2-charts';
import {Tooltip} from 'primeng/tooltip';
import {Chart, ChartConfiguration, ChartData, registerables} from 'chart.js';
import {MatrixController, MatrixElement} from 'chartjs-chart-matrix';
import {BehaviorSubject, EMPTY, Observable, Subject} from 'rxjs';
@@ -17,12 +18,20 @@ interface MatrixDataPoint {
date: string;
}
interface Milestone {
label: string;
icon: string;
requirement: number;
type: 'streak' | 'total';
unlocked: boolean;
}
type SessionHeatmapChartData = ChartData<'matrix', MatrixDataPoint[], string>;
@Component({
selector: 'app-reading-session-heatmap',
standalone: true,
imports: [CommonModule, BaseChartDirective],
imports: [CommonModule, BaseChartDirective, Tooltip],
templateUrl: './reading-session-heatmap.component.html',
styleUrls: ['./reading-session-heatmap.component.scss']
})
@@ -34,6 +43,13 @@ export class ReadingSessionHeatmapComponent implements OnInit, OnDestroy {
public readonly chartData$: Observable<SessionHeatmapChartData>;
public readonly chartOptions: ChartConfiguration['options'];
public currentStreak = 0;
public longestStreak = 0;
public totalReadingDays = 0;
public consistencyPercent = 0;
public milestones: Milestone[] = [];
public hasStreakData = false;
private readonly userStatsService = inject(UserStatsService);
private readonly destroy$ = new Subject<void>();
private readonly chartDataSubject: BehaviorSubject<SessionHeatmapChartData>;
@@ -130,6 +146,7 @@ export class ReadingSessionHeatmapComponent implements OnInit, OnDestroy {
Chart.register(...registerables, MatrixController, MatrixElement);
this.currentYear = this.initialYear;
this.loadYearData(this.currentYear);
this.loadStreakData();
}
ngOnDestroy(): void {
@@ -223,6 +240,100 @@ export class ReadingSessionHeatmapComponent implements OnInit, OnDestroy {
});
}
private loadStreakData(): void {
this.userStatsService.getReadingDates()
.pipe(
takeUntil(this.destroy$),
catchError((error) => {
console.error('Error loading reading dates:', error);
return EMPTY;
})
)
.subscribe((data) => this.processStreakData(data));
}
private processStreakData(data: ReadingSessionHeatmapResponse[]): void {
if (!data || data.length === 0) {
this.hasStreakData = false;
return;
}
this.hasStreakData = true;
this.totalReadingDays = data.length;
const sortedDates = Array.from(new Set(data.map(d => d.date))).sort();
const dateSet = new Set(sortedDates);
// Calculate all streaks
const streakLengths: number[] = [];
let streakStart: string | null = null;
let prevDate: string | null = null;
let lastStreakEnd: string | null = null;
let lastStreakLength = 0;
for (const dateStr of sortedDates) {
if (!prevDate || !this.isConsecutiveDay(prevDate, dateStr)) {
if (prevDate && streakStart) {
const len = this.daysBetween(streakStart, prevDate) + 1;
streakLengths.push(len);
lastStreakEnd = prevDate;
lastStreakLength = len;
}
streakStart = dateStr;
}
prevDate = dateStr;
}
if (prevDate && streakStart) {
const len = this.daysBetween(streakStart, prevDate) + 1;
streakLengths.push(len);
lastStreakEnd = prevDate;
lastStreakLength = len;
}
this.longestStreak = streakLengths.length > 0 ? Math.max(...streakLengths) : 0;
// Current streak
const today = this.toDateStr(new Date());
const yesterday = this.toDateStr(new Date(Date.now() - 86400000));
if ((dateSet.has(today) || dateSet.has(yesterday)) && (lastStreakEnd === today || lastStreakEnd === yesterday)) {
this.currentStreak = lastStreakLength;
} else {
this.currentStreak = 0;
}
// Consistency
if (sortedDates.length >= 2) {
const totalPossibleDays = this.daysBetween(sortedDates[0], today) + 1;
this.consistencyPercent = totalPossibleDays > 0
? Math.round((this.totalReadingDays / totalPossibleDays) * 100)
: 0;
}
// Milestones
this.milestones = [
{label: '7-Day Streak', icon: '\uD83D\uDD25', requirement: 7, type: 'streak', unlocked: this.longestStreak >= 7},
{label: '30-Day Streak', icon: '\u26A1', requirement: 30, type: 'streak', unlocked: this.longestStreak >= 30},
{label: '100 Reading Days', icon: '\uD83D\uDCDA', requirement: 100, type: 'total', unlocked: this.totalReadingDays >= 100},
{label: '365 Reading Days', icon: '\uD83C\uDFC6', requirement: 365, type: 'total', unlocked: this.totalReadingDays >= 365},
{label: 'Year of Reading', icon: '\uD83D\uDC51', requirement: 365, type: 'streak', unlocked: this.longestStreak >= 365},
];
}
private toDateStr(d: Date): string {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
private isConsecutiveDay(dateStr1: string, dateStr2: string): boolean {
const d1 = new Date(dateStr1);
const d2 = new Date(dateStr2);
return Math.abs(d2.getTime() - d1.getTime() - 86400000) < 3600000;
}
private daysBetween(dateStr1: string, dateStr2: string): number {
return Math.round((new Date(dateStr2).getTime() - new Date(dateStr1).getTime()) / 86400000);
}
private getDateFromWeek(year: number, week: number): Date {
const date = new Date(year, 0, 1);
date.setDate(date.getDate() + (week * 7) - date.getDay());

View File

@@ -4,6 +4,11 @@
<h3>
<i class="pi pi-clock timeline-title-icon"></i>
Reading Session Timeline
<i class="pi pi-question-circle chart-info-icon"
pTooltip="Shows your reading sessions laid out across each day of the week. Each block represents a session, sized by duration. Hover to see book title, pages read, and time spent. Use the week navigator to browse your reading history week by week."
tooltipPosition="bottom"
tooltipStyleClass="chart-info-tooltip"
[appendTo]="'body'"></i>
</h3>
<p class="timeline-subtitle">Weekly overview of your reading sessions and patterns</p>
</div>

View File

@@ -145,8 +145,8 @@ export class ReadingSessionTimelineComponent implements OnInit {
response.forEach((item) => {
const startTime = new Date(item.startDate);
const endTime = item.endDate ? new Date(item.endDate) : new Date(startTime.getTime() + item.totalDurationSeconds * 1000);
const duration = (endTime.getTime() - startTime.getTime()) / (1000 * 60);
const endTime = new Date(startTime.getTime() + item.totalDurationSeconds * 1000);
const duration = item.totalDurationSeconds / 60;
sessions.push({
startTime,
@@ -254,6 +254,8 @@ export class ReadingSessionTimelineComponent implements OnInit {
}
}
private static readonly MAX_TRACKS = 3;
private layoutSessionsForDay(sessions: ReadingSession[]): TimelineSession[] {
if (sessions.length === 0) {
return [];
@@ -279,7 +281,11 @@ export class ReadingSessionTimelineComponent implements OnInit {
}
}
if (!placed) {
tracks.push([session]);
if (tracks.length < ReadingSessionTimelineComponent.MAX_TRACKS) {
tracks.push([session]);
} else {
tracks[tracks.length - 1].push(session);
}
}
});

View File

@@ -0,0 +1,53 @@
<div class="reading-survival-container">
<div class="chart-header">
<div class="chart-title">
<h3>
<i class="pi pi-chart-line reading-survival-icon"></i>
Reading Survival Curve
<i class="pi pi-question-circle chart-info-icon"
pTooltip="Inspired by survival analysis: starting from all books you've begun reading, this curve shows what fraction made it to each progress milestone. A steep drop at a threshold reveals where you tend to abandon books. The 'danger zone' highlights the steepest single drop-off point."
tooltipPosition="bottom"
tooltipStyleClass="chart-info-tooltip"
[appendTo]="'body'"></i>
</h3>
<p class="chart-description">Of books started, what percentage reached each progress threshold</p>
</div>
</div>
@if (totalStarted > 0) {
<div class="stats-row">
<div class="stat-card">
<span class="stat-value">{{ totalStarted }}</span>
<span class="stat-label">Started</span>
</div>
<div class="stat-card">
<span class="stat-value">{{ completionRate }}%</span>
<span class="stat-label">Completion Rate</span>
</div>
<div class="stat-card">
<span class="stat-value-sm">{{ medianDropout }}</span>
<span class="stat-label">Median Dropout</span>
</div>
<div class="stat-card danger">
<span class="stat-value-sm">{{ dangerZone }}</span>
<span class="stat-label">Danger Zone</span>
</div>
</div>
}
<div class="chart-wrapper">
<canvas baseChart
[data]="(chartData$ | async) ?? {labels: [], datasets: []}"
[options]="chartOptions"
[type]="chartType">
</canvas>
</div>
@if (totalStarted === 0) {
<div class="no-data-message">
<i class="pi pi-info-circle"></i>
<p>No books with reading progress found.</p>
<small>Start reading some books to see your survival curve.</small>
</div>
}
</div>

View File

@@ -0,0 +1,141 @@
.reading-survival-container {
width: 100%;
}
.chart-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 0.5rem;
.chart-title {
flex: 1;
h3 {
color: var(--text-color, #ffffff);
font-size: 1.25rem;
font-weight: 500;
margin: 0 0 0.5rem 0;
display: flex;
align-items: center;
gap: 0.5rem;
}
.chart-description {
color: var(--text-secondary-color);
font-size: 0.9rem;
margin: 0;
line-height: 1.4;
}
}
}
.reading-survival-icon {
font-size: 1.5rem;
color: #e91e63;
}
.stats-row {
display: flex;
gap: 0.75rem;
margin-bottom: 1rem;
flex-wrap: wrap;
.stat-card {
display: flex;
flex-direction: column;
align-items: center;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
padding: 0.5rem 1rem;
flex: 1;
min-width: 80px;
.stat-value {
font-size: 1.5rem;
font-weight: 600;
color: #e91e63;
}
.stat-value-sm {
font-size: 0.85rem;
font-weight: 500;
color: var(--text-color, #ffffff);
text-align: center;
}
.stat-label {
font-size: 0.75rem;
color: var(--text-secondary-color);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-top: 0.25rem;
}
&.danger .stat-value-sm {
color: #ff5722;
}
}
}
.chart-wrapper {
position: relative;
height: 300px;
width: 100%;
}
.no-data-message {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 3rem;
text-align: center;
color: var(--text-secondary-color);
i {
font-size: 2.5rem;
margin-bottom: 1rem;
opacity: 0.5;
}
p {
font-size: 1rem;
margin: 0 0 0.5rem 0;
color: var(--text-color, #ffffff);
}
small {
font-size: 0.85rem;
opacity: 0.7;
}
}
@media (max-width: 768px) {
.stats-row .stat-card {
min-width: 70px;
}
.chart-wrapper {
height: 280px;
}
}
@media (max-width: 480px) {
.chart-header .chart-title h3 {
font-size: 1rem;
}
.stats-row {
gap: 0.5rem;
.stat-card {
padding: 0.4rem 0.5rem;
.stat-value {
font-size: 1.2rem;
}
}
}
}

View File

@@ -0,0 +1,201 @@
import {Component, inject, OnDestroy, OnInit} from '@angular/core';
import {CommonModule} from '@angular/common';
import {BaseChartDirective} from 'ng2-charts';
import {Tooltip} from 'primeng/tooltip';
import {BehaviorSubject, EMPTY, Observable, Subject} from 'rxjs';
import {catchError, filter, first, takeUntil} from 'rxjs/operators';
import {ChartConfiguration, ChartData} from 'chart.js';
import {BookService} from '../../../../../book/service/book.service';
import {BookState} from '../../../../../book/model/state/book-state.model';
import {Book} from '../../../../../book/model/book.model';
type SurvivalChartData = ChartData<'line', number[], string>;
const THRESHOLDS = [0, 10, 25, 50, 75, 90, 100];
@Component({
selector: 'app-reading-survival-chart',
standalone: true,
imports: [CommonModule, BaseChartDirective, Tooltip],
templateUrl: './reading-survival-chart.component.html',
styleUrls: ['./reading-survival-chart.component.scss']
})
export class ReadingSurvivalChartComponent implements OnInit, OnDestroy {
private readonly bookService = inject(BookService);
private readonly destroy$ = new Subject<void>();
public readonly chartType = 'line' as const;
public totalStarted = 0;
public completionRate = 0;
public medianDropout = '';
public dangerZone = '';
public readonly chartOptions: ChartConfiguration<'line'>['options'] = {
responsive: true,
maintainAspectRatio: false,
layout: {
padding: {top: 10, bottom: 10, left: 10, right: 10}
},
plugins: {
legend: {display: false},
tooltip: {
enabled: true,
backgroundColor: 'rgba(0, 0, 0, 0.9)',
titleColor: '#ffffff',
bodyColor: '#ffffff',
borderColor: '#e91e63',
borderWidth: 1,
cornerRadius: 6,
padding: 12,
titleFont: {size: 13, weight: 'bold'},
bodyFont: {size: 12},
callbacks: {
title: (context) => `${context[0].label} progress`,
label: (context) => `${(context.parsed.y ?? 0).toFixed(1)}% of books reached this point`
}
},
datalabels: {display: false}
},
scales: {
x: {
title: {
display: true,
text: 'Progress Threshold',
color: '#ffffff',
font: {family: "'Inter', sans-serif", size: 12, weight: 'bold'}
},
ticks: {
color: '#ffffff',
font: {family: "'Inter', sans-serif", size: 11}
},
grid: {color: 'rgba(255, 255, 255, 0.1)'},
border: {display: false}
},
y: {
min: 0,
max: 100,
title: {
display: true,
text: '% of Books Surviving',
color: '#ffffff',
font: {family: "'Inter', sans-serif", size: 12, weight: 'bold'}
},
ticks: {
color: '#ffffff',
font: {family: "'Inter', sans-serif", size: 11},
callback: (value) => `${value}%`
},
grid: {color: 'rgba(255, 255, 255, 0.1)'},
border: {display: false}
}
}
};
private readonly chartDataSubject = new BehaviorSubject<SurvivalChartData>({
labels: [],
datasets: []
});
public readonly chartData$: Observable<SurvivalChartData> = this.chartDataSubject.asObservable();
ngOnInit(): void {
this.bookService.bookState$
.pipe(
filter(state => state.loaded),
first(),
catchError((error) => {
console.error('Error processing survival data:', error);
return EMPTY;
}),
takeUntil(this.destroy$)
)
.subscribe(() => this.calculateSurvivalCurve());
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
private calculateSurvivalCurve(): void {
const currentState = this.bookService.getCurrentBookState();
if (!this.isValidBookState(currentState)) return;
const books = currentState.books!;
const startedBooks = books.filter(b => this.getBookProgress(b) > 0);
this.totalStarted = startedBooks.length;
if (this.totalStarted === 0) return;
const progresses = startedBooks.map(b => this.getBookProgress(b));
const survivalValues = THRESHOLDS.map(threshold => {
const survived = progresses.filter(p => p >= threshold).length;
return (survived / this.totalStarted) * 100;
});
// Completion rate
this.completionRate = Math.round(survivalValues[survivalValues.length - 1]);
// Median dropout: find where survival drops below 50%
let medianIdx = survivalValues.findIndex(v => v < 50);
if (medianIdx === -1) {
this.medianDropout = '100%+';
} else if (medianIdx === 0) {
this.medianDropout = `${THRESHOLDS[0]}%`;
} else {
this.medianDropout = `${THRESHOLDS[medianIdx - 1]}-${THRESHOLDS[medianIdx]}%`;
}
// Danger zone: steepest drop
let maxDrop = 0;
let dangerIdx = 0;
for (let i = 1; i < survivalValues.length; i++) {
const drop = survivalValues[i - 1] - survivalValues[i];
if (drop > maxDrop) {
maxDrop = drop;
dangerIdx = i;
}
}
this.dangerZone = `${THRESHOLDS[dangerIdx - 1]}-${THRESHOLDS[dangerIdx]}% (-${maxDrop.toFixed(0)}%)`;
const labels = THRESHOLDS.map(t => `${t}%`);
this.chartDataSubject.next({
labels,
datasets: [{
label: 'Survival Rate',
data: survivalValues,
borderColor: '#e91e63',
backgroundColor: 'rgba(233, 30, 99, 0.15)',
fill: true,
stepped: true,
pointRadius: 5,
pointHoverRadius: 7,
pointBackgroundColor: '#e91e63',
pointBorderColor: '#ffffff',
pointBorderWidth: 2,
borderWidth: 2
}]
});
}
private isValidBookState(state: unknown): state is BookState {
return (
typeof state === 'object' &&
state !== null &&
'loaded' in state &&
typeof (state as { loaded: boolean }).loaded === 'boolean' &&
'books' in state &&
Array.isArray((state as { books: unknown }).books) &&
(state as { books: Book[] }).books.length > 0
);
}
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;
if (book.koboProgress?.percentage) return book.koboProgress.percentage;
return 0;
}
}

View File

@@ -4,6 +4,11 @@
<h3>
<i class="pi pi-list series-icon"></i>
Series Progress Tracker
<i class="pi pi-question-circle chart-info-icon"
pTooltip="Shows each book series in your library as a stacked bar: read, reading, and unread volumes. Series are sorted by completion percentage. Use this to spot which series you've fallen behind on or are close to finishing."
tooltipPosition="bottom"
tooltipStyleClass="chart-info-tooltip"
[appendTo]="'body'"></i>
</h3>
<p class="chart-description">Track your reading progress across book series</p>
</div>

View File

@@ -110,7 +110,7 @@
}
.chart-wrapper {
height: 220px;
height: 300px;
width: 100%;
margin-bottom: 1rem;
}
@@ -211,8 +211,8 @@
.series-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
max-height: 400px;
gap: 0.4rem;
max-height: 360px;
overflow-y: auto;
&::-webkit-scrollbar {
@@ -290,7 +290,7 @@
}
.series-card {
padding: 0.65rem 0.85rem;
padding: 0.5rem 0.75rem;
background: rgba(255, 255, 255, 0.03);
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.08);
@@ -315,8 +315,8 @@
.series-main {
display: flex;
align-items: flex-start;
gap: 0.6rem;
margin-bottom: 0.5rem;
gap: 0.5rem;
margin-bottom: 0.35rem;
.series-status-icon {
font-size: 0.9rem;
@@ -408,8 +408,8 @@
}
.next-up {
margin-top: 0.5rem;
padding-top: 0.4rem;
margin-top: 0.35rem;
padding-top: 0.3rem;
border-top: 1px dashed rgba(255, 255, 255, 0.1);
font-size: 0.7rem;
@@ -470,7 +470,7 @@
}
.chart-wrapper {
height: 180px;
height: 240px;
}
.series-details {

View File

@@ -1,6 +1,7 @@
import {Component, inject, OnDestroy, OnInit} from '@angular/core';
import {CommonModule} from '@angular/common';
import {BaseChartDirective} from 'ng2-charts';
import {Tooltip} from 'primeng/tooltip';
import {BehaviorSubject, EMPTY, Observable, Subject} from 'rxjs';
import {catchError, filter, first, switchMap, takeUntil} from 'rxjs/operators';
import {ChartConfiguration, ChartData} from 'chart.js';
@@ -42,7 +43,7 @@ type SeriesChartData = ChartData<'bar', number[], string>;
@Component({
selector: 'app-series-progress-chart',
standalone: true,
imports: [CommonModule, BaseChartDirective],
imports: [CommonModule, BaseChartDirective, Tooltip],
templateUrl: './series-progress-chart.component.html',
styleUrls: ['./series-progress-chart.component.scss']
})

View File

@@ -14,7 +14,11 @@ import {RatingTasteChartComponent} from '../charts/rating-taste-chart/rating-tas
import {SeriesProgressChartComponent} from '../charts/series-progress-chart/series-progress-chart.component';
import {ReadingDNAChartComponent} from '../charts/reading-dna-chart/reading-dna-chart.component';
import {ReadingHabitsChartComponent} from '../charts/reading-habits-chart/reading-habits-chart.component';
import {ReadingBacklogChartComponent} from '../charts/reading-backlog-chart/reading-backlog-chart.component';
import {PageTurnerChartComponent} from '../charts/page-turner-chart/page-turner-chart.component';
import {CompletionRaceChartComponent} from '../charts/completion-race-chart/completion-race-chart.component';
import {ReadingSurvivalChartComponent} from '../charts/reading-survival-chart/reading-survival-chart.component';
import {ReadingClockChartComponent} from '../charts/reading-clock-chart/reading-clock-chart.component';
import {BookLengthChartComponent} from '../charts/book-length-chart/book-length-chart.component';
export interface UserChartConfig {
id: string;
@@ -42,11 +46,15 @@ export class UserChartConfigService {
{id: 'read-status', title: 'Reading Status Distribution', component: ReadStatusChartComponent, enabled: true, sizeClass: 'chart-small-square', order: 7},
{id: 'genre-stats', title: 'Genre Statistics', component: GenreStatsChartComponent, enabled: true, sizeClass: 'chart-medium', order: 8},
{id: 'completion-timeline', title: 'Completion Timeline', component: CompletionTimelineChartComponent, enabled: true, sizeClass: 'chart-medium', order: 9},
{id: 'rating-taste', title: 'Rating Taste Comparison', component: RatingTasteChartComponent, enabled: true, sizeClass: 'chart-medium', order: 10},
{id: 'series-progress', title: 'Series Progress Tracker', component: SeriesProgressChartComponent, enabled: true, sizeClass: 'chart-medium', order: 11},
{id: 'reading-dna', title: 'Reading DNA Profile', component: ReadingDNAChartComponent, enabled: true, sizeClass: 'chart-medium', order: 12},
{id: 'reading-habits', title: 'Reading Habits Analysis', component: ReadingHabitsChartComponent, enabled: true, sizeClass: 'chart-medium', order: 13},
{id: 'reading-backlog', title: 'Reading Backlog Analysis', component: ReadingBacklogChartComponent, enabled: true, sizeClass: 'chart-full', order: 14},
{id: 'reading-clock', title: 'Reading Clock', component: ReadingClockChartComponent, enabled: true, sizeClass: 'chart-medium', order: 10},
{id: 'page-turner', title: 'Page Turner Score', component: PageTurnerChartComponent, enabled: true, sizeClass: 'chart-medium', order: 11},
{id: 'completion-race', title: 'Reading Completion Race', component: CompletionRaceChartComponent, enabled: true, sizeClass: 'chart-full', order: 12},
{id: 'reading-survival', title: 'Reading Survival Curve', component: ReadingSurvivalChartComponent, enabled: true, sizeClass: 'chart-medium', order: 13},
{id: 'book-length', title: 'Book Length Sweet Spot', component: BookLengthChartComponent, enabled: true, sizeClass: 'chart-medium', order: 14},
{id: 'rating-taste', title: 'Rating Taste Comparison', component: RatingTasteChartComponent, enabled: true, sizeClass: 'chart-medium', order: 15},
{id: 'series-progress', title: 'Series Progress Tracker', component: SeriesProgressChartComponent, enabled: true, sizeClass: 'chart-medium', order: 16},
{id: 'reading-dna', title: 'Reading DNA Profile', component: ReadingDNAChartComponent, enabled: true, sizeClass: 'chart-medium', order: 17},
{id: 'reading-habits', title: 'Reading Habits Analysis', component: ReadingHabitsChartComponent, enabled: true, sizeClass: 'chart-medium', order: 18},
];
private chartsSubject = new BehaviorSubject<UserChartConfig[]>(this.loadChartConfig());

View File

@@ -107,12 +107,24 @@
@case ('rating-taste') {
<app-rating-taste-chart></app-rating-taste-chart>
}
@case ('reading-backlog') {
<app-reading-backlog-chart></app-reading-backlog-chart>
}
@case ('series-progress') {
<app-series-progress-chart></app-series-progress-chart>
}
@case ('page-turner') {
<app-page-turner-chart></app-page-turner-chart>
}
@case ('completion-race') {
<app-completion-race-chart [initialYear]="currentYear"></app-completion-race-chart>
}
@case ('reading-survival') {
<app-reading-survival-chart></app-reading-survival-chart>
}
@case ('reading-clock') {
<app-reading-clock-chart></app-reading-clock-chart>
}
@case ('book-length') {
<app-book-length-chart></app-book-length-chart>
}
}
</div>
}

View File

@@ -20,7 +20,11 @@ import {ReadingProgressChartComponent} from './charts/reading-progress-chart/rea
import {ReadStatusChartComponent} from './charts/read-status-chart/read-status-chart.component';
import {RatingTasteChartComponent} from './charts/rating-taste-chart/rating-taste-chart.component';
import {SeriesProgressChartComponent} from './charts/series-progress-chart/series-progress-chart.component';
import {ReadingBacklogChartComponent} from './charts/reading-backlog-chart/reading-backlog-chart.component';
import {PageTurnerChartComponent} from './charts/page-turner-chart/page-turner-chart.component';
import {CompletionRaceChartComponent} from './charts/completion-race-chart/completion-race-chart.component';
import {ReadingSurvivalChartComponent} from './charts/reading-survival-chart/reading-survival-chart.component';
import {ReadingClockChartComponent} from './charts/reading-clock-chart/reading-clock-chart.component';
import {BookLengthChartComponent} from './charts/book-length-chart/book-length-chart.component';
import {UserChartConfig, UserChartConfigService} from './service/user-chart-config.service';
@Component({
@@ -44,8 +48,12 @@ import {UserChartConfig, UserChartConfigService} from './service/user-chart-conf
ReadingProgressChartComponent,
ReadStatusChartComponent,
RatingTasteChartComponent,
ReadingBacklogChartComponent,
SeriesProgressChartComponent
SeriesProgressChartComponent,
PageTurnerChartComponent,
CompletionRaceChartComponent,
ReadingSurvivalChartComponent,
ReadingClockChartComponent,
BookLengthChartComponent
],
templateUrl: './user-stats.component.html',
styleUrls: ['./user-stats.component.scss']

View File

@@ -49,6 +49,21 @@ html {
scrollbar-color: rgba(128, 128, 128, 0.5) rgba(0, 0, 0, 0.1);
}
.chart-info-tooltip.p-tooltip {
--p-tooltip-max-width: 25rem;
}
.chart-info-icon {
color: rgba(255, 255, 255, 0.35);
font-size: 0.9rem;
cursor: help;
transition: color 0.2s ease;
&:hover {
color: rgba(255, 255, 255, 0.8);
}
}
.p-toast {
top: 4rem !important;
}