diff --git a/booklore-api/src/main/java/org/booklore/controller/UserStatsController.java b/booklore-api/src/main/java/org/booklore/controller/UserStatsController.java index 2c4f00c47f..0ac1a3fae2 100644 --- a/booklore-api/src/main/java/org/booklore/controller/UserStatsController.java +++ b/booklore-api/src/main/java/org/booklore/controller/UserStatsController.java @@ -135,4 +135,40 @@ public ResponseEntity> getBookCompletionHeat List 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> getPageTurnerScores() { + List 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> getCompletionRace(@RequestParam int year) { + List 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> getReadingDates() { + List data = readingSessionService.getReadingDates(); + return ResponseEntity.ok(data); + } } diff --git a/booklore-api/src/main/java/org/booklore/model/dto/CompletionRaceSessionDto.java b/booklore-api/src/main/java/org/booklore/model/dto/CompletionRaceSessionDto.java new file mode 100644 index 0000000000..8c2014a8cc --- /dev/null +++ b/booklore-api/src/main/java/org/booklore/model/dto/CompletionRaceSessionDto.java @@ -0,0 +1,10 @@ +package org.booklore.model.dto; + +import java.time.Instant; + +public interface CompletionRaceSessionDto { + Long getBookId(); + String getBookTitle(); + Instant getSessionDate(); + Float getEndProgress(); +} diff --git a/booklore-api/src/main/java/org/booklore/model/dto/PageTurnerSessionDto.java b/booklore-api/src/main/java/org/booklore/model/dto/PageTurnerSessionDto.java new file mode 100644 index 0000000000..90d1a0bb3c --- /dev/null +++ b/booklore-api/src/main/java/org/booklore/model/dto/PageTurnerSessionDto.java @@ -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(); +} diff --git a/booklore-api/src/main/java/org/booklore/model/dto/response/CompletionRaceResponse.java b/booklore-api/src/main/java/org/booklore/model/dto/response/CompletionRaceResponse.java new file mode 100644 index 0000000000..405d9298c5 --- /dev/null +++ b/booklore-api/src/main/java/org/booklore/model/dto/response/CompletionRaceResponse.java @@ -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; +} diff --git a/booklore-api/src/main/java/org/booklore/model/dto/response/PageTurnerScoreResponse.java b/booklore-api/src/main/java/org/booklore/model/dto/response/PageTurnerScoreResponse.java new file mode 100644 index 0000000000..51d4e58d3a --- /dev/null +++ b/booklore-api/src/main/java/org/booklore/model/dto/response/PageTurnerScoreResponse.java @@ -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 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; +} diff --git a/booklore-api/src/main/java/org/booklore/repository/ReadingSessionRepository.java b/booklore-api/src/main/java/org/booklore/repository/ReadingSessionRepository.java index 74d352999e..f08b78b273 100644 --- a/booklore-api/src/main/java/org/booklore/repository/ReadingSessionRepository.java +++ b/booklore-api/src/main/java/org/booklore/repository/ReadingSessionRepository.java @@ -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 @@ Page findByUserIdAndBookId( @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 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 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 findAllSessionCountsByUser(@Param("userId") Long userId); } diff --git a/booklore-api/src/main/java/org/booklore/service/ReadingSessionService.java b/booklore-api/src/main/java/org/booklore/service/ReadingSessionService.java index a353ec91a3..b2e8a63eb4 100644 --- a/booklore-api/src/main/java/org/booklore/service/ReadingSessionService.java +++ b/booklore-api/src/main/java/org/booklore/service/ReadingSessionService.java @@ -3,18 +3,24 @@ 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.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 List getBookCompletionHeatmap() { .build()) .collect(Collectors.toList()); } + + @Transactional(readOnly = true) + public List getPageTurnerScores() { + BookLoreUser authenticatedUser = authenticationService.getAuthenticatedUser(); + Long userId = authenticatedUser.getId(); + + var sessions = readingSessionRepository.findPageTurnerSessionsByUser(userId); + + Map> sessionsByBook = sessions.stream() + .collect(Collectors.groupingBy(PageTurnerSessionDto::getBookId, LinkedHashMap::new, Collectors.toList())); + + Set bookIds = sessionsByBook.keySet(); + Map> bookCategories = new HashMap<>(); + if (!bookIds.isEmpty()) { + bookRepository.findAllWithMetadataByIds(bookIds).forEach(book -> { + List 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 bookSessions = entry.getValue(); + PageTurnerSessionDto first = bookSessions.getFirst(); + + List durations = bookSessions.stream() + .map(s -> s.getDurationSeconds() != null ? s.getDurationSeconds().doubleValue() : 0.0) + .collect(Collectors.toList()); + + List 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 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 allBookIds = allSessions.stream() + .map(CompletionRaceSessionDto::getBookId) + .collect(Collectors.toCollection(LinkedHashSet::new)); + + Set 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 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 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; + } } diff --git a/booklore-ui/src/app/features/settings/user-management/user-stats.service.ts b/booklore-ui/src/app/features/settings/user-management/user-stats.service.ts index 914ab920c4..30392ca691 100644 --- a/booklore-ui/src/app/features/settings/user-management/user-stats.service.ts +++ b/booklore-ui/src/app/features/settings/user-management/user-stats.service.ts @@ -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 { + return this.http.get( + `${this.readingSessionsUrl}/page-turner-scores` + ); + } + + getCompletionRace(year: number): Observable { + return this.http.get( + `${this.readingSessionsUrl}/completion-race`, + {params: {year: year.toString()}} + ); + } + + getReadingDates(): Observable { + return this.http.get( + `${this.readingSessionsUrl}/reading-dates` + ); + } } diff --git a/booklore-ui/src/app/features/stats/component/user-stats/charts/book-length-chart/book-length-chart.component.html b/booklore-ui/src/app/features/stats/component/user-stats/charts/book-length-chart/book-length-chart.component.html new file mode 100644 index 0000000000..d0835738b5 --- /dev/null +++ b/booklore-ui/src/app/features/stats/component/user-stats/charts/book-length-chart/book-length-chart.component.html @@ -0,0 +1,43 @@ +
+
+
+

+ + Book Length Sweet Spot + +

+

How book length relates to your personal ratings

+
+ @if (totalRatedBooks > 0) { +
+ {{ totalRatedBooks }} + books analyzed +
+ } +
+ + @if (totalRatedBooks > 0) { +
+
+ {{ sweetSpot }} + Sweet Spot +
+
+ {{ highestRatedLength }} + Highest Rated +
+
+ } + +
+ + +
+
diff --git a/booklore-ui/src/app/features/stats/component/user-stats/charts/book-length-chart/book-length-chart.component.scss b/booklore-ui/src/app/features/stats/component/user-stats/charts/book-length-chart/book-length-chart.component.scss new file mode 100644 index 0000000000..5c4e416970 --- /dev/null +++ b/booklore-ui/src/app/features/stats/component/user-stats/charts/book-length-chart/book-length-chart.component.scss @@ -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; + } +} diff --git a/booklore-ui/src/app/features/stats/component/user-stats/charts/book-length-chart/book-length-chart.component.ts b/booklore-ui/src/app/features/stats/component/user-stats/charts/book-length-chart/book-length-chart.component.ts new file mode 100644 index 0000000000..f9ec07ae6c --- /dev/null +++ b/booklore-ui/src/app/features/stats/component/user-stats/charts/book-length-chart/book-length-chart.component.ts @@ -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 = { + '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(); + + 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({datasets: []}); + public readonly chartData$: Observable = 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(); + 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 + ); + } +} diff --git a/booklore-ui/src/app/features/stats/component/user-stats/charts/completion-race-chart/completion-race-chart.component.html b/booklore-ui/src/app/features/stats/component/user-stats/charts/completion-race-chart/completion-race-chart.component.html new file mode 100644 index 0000000000..1a87944aa6 --- /dev/null +++ b/booklore-ui/src/app/features/stats/component/user-stats/charts/completion-race-chart/completion-race-chart.component.html @@ -0,0 +1,68 @@ +
+
+
+

+ + Reading Completion Race + +

+

Compare how quickly you finished different books

+
+
+ + {{ currentYear }} + +
+
+ + @if (totalBooks > 0) { +
+
+ {{ totalBooks }} + Books +
+
+ {{ avgDaysToFinish }}d + Avg Days +
+
+ {{ fastestCompletion }} + Fastest +
+
+ {{ slowestCompletion }} + Slowest +
+
+ } + +
+ + +
+ + @if (totalBooks === 0) { +
+ +

No completed books with reading sessions found for {{ currentYear }}.

+ Complete some books with tracked reading sessions to see your completion races. +
+ } +
diff --git a/booklore-ui/src/app/features/stats/component/user-stats/charts/completion-race-chart/completion-race-chart.component.scss b/booklore-ui/src/app/features/stats/component/user-stats/charts/completion-race-chart/completion-race-chart.component.scss new file mode 100644 index 0000000000..0a56e891e1 --- /dev/null +++ b/booklore-ui/src/app/features/stats/component/user-stats/charts/completion-race-chart/completion-race-chart.component.scss @@ -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; + } + } + } +} diff --git a/booklore-ui/src/app/features/stats/component/user-stats/charts/completion-race-chart/completion-race-chart.component.ts b/booklore-ui/src/app/features/stats/component/user-stats/charts/completion-race-chart/completion-race-chart.component.ts new file mode 100644 index 0000000000..22c9dd6464 --- /dev/null +++ b/booklore-ui/src/app/features/stats/component/user-stats/charts/completion-race-chart/completion-race-chart.component.ts @@ -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; + 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(); + private readonly chartDataSubject: BehaviorSubject; + + constructor() { + this.chartDataSubject = new BehaviorSubject({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(); + + 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}); + } +} diff --git a/booklore-ui/src/app/features/stats/component/user-stats/charts/favorite-days-chart/favorite-days-chart.component.html b/booklore-ui/src/app/features/stats/component/user-stats/charts/favorite-days-chart/favorite-days-chart.component.html index 3552a1ac0f..d122541d6f 100644 --- a/booklore-ui/src/app/features/stats/component/user-stats/charts/favorite-days-chart/favorite-days-chart.component.html +++ b/booklore-ui/src/app/features/stats/component/user-stats/charts/favorite-days-chart/favorite-days-chart.component.html @@ -4,6 +4,11 @@

Favorite Reading Days +

Reading sessions and duration by day of the week

diff --git a/booklore-ui/src/app/features/stats/component/user-stats/charts/favorite-days-chart/favorite-days-chart.component.ts b/booklore-ui/src/app/features/stats/component/user-stats/charts/favorite-days-chart/favorite-days-chart.component.ts index 13300c5217..39191a32b6 100644 --- a/booklore-ui/src/app/features/stats/component/user-stats/charts/favorite-days-chart/favorite-days-chart.component.ts +++ b/booklore-ui/src/app/features/stats/component/user-stats/charts/favorite-days-chart/favorite-days-chart.component.ts @@ -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'] }) diff --git a/booklore-ui/src/app/features/stats/component/user-stats/charts/genre-stats-chart/genre-stats-chart.component.html b/booklore-ui/src/app/features/stats/component/user-stats/charts/genre-stats-chart/genre-stats-chart.component.html index ee415eb5a6..a4a5cf4112 100644 --- a/booklore-ui/src/app/features/stats/component/user-stats/charts/genre-stats-chart/genre-stats-chart.component.html +++ b/booklore-ui/src/app/features/stats/component/user-stats/charts/genre-stats-chart/genre-stats-chart.component.html @@ -4,6 +4,11 @@

Reading by Genre +

Top genres by total reading time

diff --git a/booklore-ui/src/app/features/stats/component/user-stats/charts/genre-stats-chart/genre-stats-chart.component.ts b/booklore-ui/src/app/features/stats/component/user-stats/charts/genre-stats-chart/genre-stats-chart.component.ts index bc42a6f3e0..53894eae77 100644 --- a/booklore-ui/src/app/features/stats/component/user-stats/charts/genre-stats-chart/genre-stats-chart.component.ts +++ b/booklore-ui/src/app/features/stats/component/user-stats/charts/genre-stats-chart/genre-stats-chart.component.ts @@ -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'] }) diff --git a/booklore-ui/src/app/features/stats/component/user-stats/charts/page-turner-chart/page-turner-chart.component.html b/booklore-ui/src/app/features/stats/component/user-stats/charts/page-turner-chart/page-turner-chart.component.html new file mode 100644 index 0000000000..dd0c362522 --- /dev/null +++ b/booklore-ui/src/app/features/stats/component/user-stats/charts/page-turner-chart/page-turner-chart.component.html @@ -0,0 +1,66 @@ +
+
+
+

+ + Page Turner Score + +

+

How gripping each book was based on your reading patterns

+
+
+ +
+
+ {{ stats.mostGripping | slice:0:18 }} + Most Gripping +
+
+ {{ stats.avgGripScore }} + Avg Grip Score +
+
+ {{ stats.guiltyPleasure | slice:0:18 }} + Guilty Pleasure +
+
+ + @if (topBooks.length > 0) { +
+ @for (book of topBooks; track book.bookId; let i = $index) { +
+
#{{ i + 1 }}
+
+ {{ book.bookTitle | slice:0:30 }} +
+ + {{ getAccelerationLabel(book.sessionAcceleration) }} + + + {{ getGapLabel(book.gapReduction) }} + + @if (book.finishBurst) { + + Finish Burst + + } +
+
+
{{ book.gripScore }}
+
+ } +
+ } + +
+ + +
+
diff --git a/booklore-ui/src/app/features/stats/component/user-stats/charts/page-turner-chart/page-turner-chart.component.scss b/booklore-ui/src/app/features/stats/component/user-stats/charts/page-turner-chart/page-turner-chart.component.scss new file mode 100644 index 0000000000..646c3208c3 --- /dev/null +++ b/booklore-ui/src/app/features/stats/component/user-stats/charts/page-turner-chart/page-turner-chart.component.scss @@ -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; + } +} diff --git a/booklore-ui/src/app/features/stats/component/user-stats/charts/page-turner-chart/page-turner-chart.component.ts b/booklore-ui/src/app/features/stats/component/user-stats/charts/page-turner-chart/page-turner-chart.component.ts new file mode 100644 index 0000000000..8a827b08ad --- /dev/null +++ b/booklore-ui/src/app/features/stats/component/user-stats/charts/page-turner-chart/page-turner-chart.component.ts @@ -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; + public readonly chartOptions: ChartConfiguration['options']; + + public stats = {mostGripping: '', avgGripScore: 0, guiltyPleasure: ''}; + public topBooks: PageTurnerScoreResponse[] = []; + + private readonly userStatsService = inject(UserStatsService); + private readonly destroy$ = new Subject(); + private readonly chartDataSubject: BehaviorSubject; + + constructor() { + this.chartDataSubject = new BehaviorSubject({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'; + } +} diff --git a/booklore-ui/src/app/features/stats/component/user-stats/charts/peak-hours-chart/peak-hours-chart.component.html b/booklore-ui/src/app/features/stats/component/user-stats/charts/peak-hours-chart/peak-hours-chart.component.html index 59df2e711e..ba25db2599 100644 --- a/booklore-ui/src/app/features/stats/component/user-stats/charts/peak-hours-chart/peak-hours-chart.component.html +++ b/booklore-ui/src/app/features/stats/component/user-stats/charts/peak-hours-chart/peak-hours-chart.component.html @@ -4,6 +4,11 @@

Peak Reading Hours +

Reading activity by hour of the day

diff --git a/booklore-ui/src/app/features/stats/component/user-stats/charts/peak-hours-chart/peak-hours-chart.component.ts b/booklore-ui/src/app/features/stats/component/user-stats/charts/peak-hours-chart/peak-hours-chart.component.ts index 27f002c4bb..5ee8817f0e 100644 --- a/booklore-ui/src/app/features/stats/component/user-stats/charts/peak-hours-chart/peak-hours-chart.component.ts +++ b/booklore-ui/src/app/features/stats/component/user-stats/charts/peak-hours-chart/peak-hours-chart.component.ts @@ -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'] }) diff --git a/booklore-ui/src/app/features/stats/component/user-stats/charts/personal-rating-chart/personal-rating-chart.component.html b/booklore-ui/src/app/features/stats/component/user-stats/charts/personal-rating-chart/personal-rating-chart.component.html index 95dd53258e..eb9c4f026f 100644 --- a/booklore-ui/src/app/features/stats/component/user-stats/charts/personal-rating-chart/personal-rating-chart.component.html +++ b/booklore-ui/src/app/features/stats/component/user-stats/charts/personal-rating-chart/personal-rating-chart.component.html @@ -4,6 +4,11 @@

Personal Rating Distribution +

Books by your rating (1–10)

diff --git a/booklore-ui/src/app/features/stats/component/user-stats/charts/personal-rating-chart/personal-rating-chart.component.ts b/booklore-ui/src/app/features/stats/component/user-stats/charts/personal-rating-chart/personal-rating-chart.component.ts index 0fdb4ed294..3f96f6fe36 100644 --- a/booklore-ui/src/app/features/stats/component/user-stats/charts/personal-rating-chart/personal-rating-chart.component.ts +++ b/booklore-ui/src/app/features/stats/component/user-stats/charts/personal-rating-chart/personal-rating-chart.component.ts @@ -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'] }) diff --git a/booklore-ui/src/app/features/stats/component/user-stats/charts/rating-taste-chart/rating-taste-chart.component.html b/booklore-ui/src/app/features/stats/component/user-stats/charts/rating-taste-chart/rating-taste-chart.component.html index ce2fbcef86..2a19d1f12e 100644 --- a/booklore-ui/src/app/features/stats/component/user-stats/charts/rating-taste-chart/rating-taste-chart.component.html +++ b/booklore-ui/src/app/features/stats/component/user-stats/charts/rating-taste-chart/rating-taste-chart.component.html @@ -4,6 +4,11 @@

Rating Taste Comparison +

Compare your personal ratings against external ratings (Goodreads, Amazon, etc.)

@@ -70,12 +75,4 @@

Your Taste Profile

} - - @if (totalRatedBooks === 0) { -
- -

No books with both personal and external ratings found.

- Rate your books and ensure they have external ratings from Goodreads, Amazon, etc. -
- } diff --git a/booklore-ui/src/app/features/stats/component/user-stats/charts/rating-taste-chart/rating-taste-chart.component.ts b/booklore-ui/src/app/features/stats/component/user-stats/charts/rating-taste-chart/rating-taste-chart.component.ts index 7e9fefef63..52a3ed4f97 100644 --- a/booklore-ui/src/app/features/stats/component/user-stats/charts/rating-taste-chart/rating-taste-chart.component.ts +++ b/booklore-ui/src/app/features/stats/component/user-stats/charts/rating-taste-chart/rating-taste-chart.component.ts @@ -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'] }) diff --git a/booklore-ui/src/app/features/stats/component/user-stats/charts/read-status-chart/read-status-chart.component.html b/booklore-ui/src/app/features/stats/component/user-stats/charts/read-status-chart/read-status-chart.component.html index c0cbfc5c9f..6a47d45ad1 100644 --- a/booklore-ui/src/app/features/stats/component/user-stats/charts/read-status-chart/read-status-chart.component.html +++ b/booklore-ui/src/app/features/stats/component/user-stats/charts/read-status-chart/read-status-chart.component.html @@ -4,6 +4,11 @@

Reading Status Distribution +

Books by their reading status

diff --git a/booklore-ui/src/app/features/stats/component/user-stats/charts/read-status-chart/read-status-chart.component.ts b/booklore-ui/src/app/features/stats/component/user-stats/charts/read-status-chart/read-status-chart.component.ts index 4acebb2c2d..521fdea7e6 100644 --- a/booklore-ui/src/app/features/stats/component/user-stats/charts/read-status-chart/read-status-chart.component.ts +++ b/booklore-ui/src/app/features/stats/component/user-stats/charts/read-status-chart/read-status-chart.component.ts @@ -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'] }) diff --git a/booklore-ui/src/app/features/stats/component/user-stats/charts/reading-backlog-chart/reading-backlog-chart.component.html b/booklore-ui/src/app/features/stats/component/user-stats/charts/reading-backlog-chart/reading-backlog-chart.component.html deleted file mode 100644 index d08339a091..0000000000 --- a/booklore-ui/src/app/features/stats/component/user-stats/charts/reading-backlog-chart/reading-backlog-chart.component.html +++ /dev/null @@ -1,186 +0,0 @@ -
-
-
-

- - Reading Backlog Analysis -

-

Track how long books sit in your library and your reading patterns over time

-
- @if (stats) { -
-
- {{ stats.backlogHealthScore }} -
-
- Backlog Health - {{ getHealthScoreLabel() }} -
-
- } -
- - @if (stats) { -
-
-
- -
-
- {{ stats.totalBooks }} - Total Books -
-
- -
-
- -
-
- {{ stats.unreadBooks }} - In Backlog -
-
- -
-
- -
-
- {{ formatDays(stats.avgBacklogAge) }} - Avg. Library Age -
-
- -
-
- -
-
- {{ stats.readingVelocity }} - Completed (6mo) -
-
-
- } - -
- - -
- - @if (stats) { -
-

Key Insights

-
- @if (stats.quickestRead) { -
-
- -
-
- Quickest Read - {{ stats.quickestRead.title }} - Read in {{ formatDays(stats.quickestRead.daysToComplete!) }} -
-
- } - - @if (stats.longestWait) { -
-
- -
-
- Longest Wait - {{ stats.longestWait.title }} - Waited {{ formatDays(stats.longestWait.daysToComplete!) }} to read -
-
- } - - @if (stats.oldestUnread) { -
-
- -
-
- Oldest Unread - {{ stats.oldestUnread.title }} - Waiting for {{ formatDays(stats.oldestUnread.daysInLibrary) }} -
-
- } - - @if (stats.avgDaysToRead > 0) { -
-
- -
-
- Average Time to Read - {{ formatDays(stats.avgDaysToRead) }} - From acquisition to completion -
-
- } -
-
- -
-

Backlog Breakdown

-
- @for (bucket of buckets; track bucket.label) { - @if (bucket.unread + bucket.reading + bucket.completed > 0) { -
-
- {{ bucket.label }} - {{ bucket.range }} - {{ bucket.unread + bucket.reading + bucket.completed }} books -
-
-
- @if (bucket.unread > 0) { -
- {{ bucket.unread }} -
- } - @if (bucket.reading > 0) { -
- {{ bucket.reading }} -
- } - @if (bucket.completed > 0) { -
- {{ bucket.completed }} -
- } -
-
- @if (bucket.avgDaysToRead !== null) { - Avg. {{ formatDays(bucket.avgDaysToRead) }} to read - } -
- } - } -
-
- } - - @if (!stats) { -
- -

No books with addition dates found.

- Backlog analysis requires books with "Added On" dates in your library. -
- } -
diff --git a/booklore-ui/src/app/features/stats/component/user-stats/charts/reading-backlog-chart/reading-backlog-chart.component.scss b/booklore-ui/src/app/features/stats/component/user-stats/charts/reading-backlog-chart/reading-backlog-chart.component.scss deleted file mode 100644 index 743c98de93..0000000000 --- a/booklore-ui/src/app/features/stats/component/user-stats/charts/reading-backlog-chart/reading-backlog-chart.component.scss +++ /dev/null @@ -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; - } - } - } - } -} diff --git a/booklore-ui/src/app/features/stats/component/user-stats/charts/reading-backlog-chart/reading-backlog-chart.component.ts b/booklore-ui/src/app/features/stats/component/user-stats/charts/reading-backlog-chart/reading-backlog-chart.component.ts deleted file mode 100644 index 2db544799f..0000000000 --- a/booklore-ui/src/app/features/stats/component/user-stats/charts/reading-backlog-chart/reading-backlog-chart.component.ts +++ /dev/null @@ -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(); - - 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({ - labels: [], - datasets: [] - }); - - public readonly chartData$: Observable = 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' : ''}`; - } -} diff --git a/booklore-ui/src/app/features/stats/component/user-stats/charts/reading-clock-chart/reading-clock-chart.component.html b/booklore-ui/src/app/features/stats/component/user-stats/charts/reading-clock-chart/reading-clock-chart.component.html new file mode 100644 index 0000000000..83f61dfff6 --- /dev/null +++ b/booklore-ui/src/app/features/stats/component/user-stats/charts/reading-clock-chart/reading-clock-chart.component.html @@ -0,0 +1,49 @@ +
+
+
+

+ + Reading Clock + +

+

When do you read throughout the day

+
+
+ + @if (hasData) { +
+
+ {{ peakHour }} + Peak Hour +
+
+ {{ totalHoursRead }}h + Total Read +
+
+ {{ readerType }} + Reader Type +
+
+ } + +
+ + +
+ + @if (!hasData) { +
+ +

No reading session data found.

+ Track your reading sessions to see your reading clock. +
+ } +
diff --git a/booklore-ui/src/app/features/stats/component/user-stats/charts/reading-clock-chart/reading-clock-chart.component.scss b/booklore-ui/src/app/features/stats/component/user-stats/charts/reading-clock-chart/reading-clock-chart.component.scss new file mode 100644 index 0000000000..29864612f5 --- /dev/null +++ b/booklore-ui/src/app/features/stats/component/user-stats/charts/reading-clock-chart/reading-clock-chart.component.scss @@ -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; + } +} diff --git a/booklore-ui/src/app/features/stats/component/user-stats/charts/reading-clock-chart/reading-clock-chart.component.ts b/booklore-ui/src/app/features/stats/component/user-stats/charts/reading-clock-chart/reading-clock-chart.component.ts new file mode 100644 index 0000000000..af507e859a --- /dev/null +++ b/booklore-ui/src/app/features/stats/component/user-stats/charts/reading-clock-chart/reading-clock-chart.component.ts @@ -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(); + + 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({ + labels: [], + datasets: [] + }); + + public readonly chartData$: Observable = 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 + }] + }); + } +} diff --git a/booklore-ui/src/app/features/stats/component/user-stats/charts/reading-dna-chart/reading-dna-chart.component.html b/booklore-ui/src/app/features/stats/component/user-stats/charts/reading-dna-chart/reading-dna-chart.component.html index d7328d1583..306aad651d 100644 --- a/booklore-ui/src/app/features/stats/component/user-stats/charts/reading-dna-chart/reading-dna-chart.component.html +++ b/booklore-ui/src/app/features/stats/component/user-stats/charts/reading-dna-chart/reading-dna-chart.component.html @@ -4,8 +4,13 @@

Reading DNA Profile +

-

Your unique reading personality across 8 key dimensions

+

Your unique reading personality across 8 dimensions

diff --git a/booklore-ui/src/app/features/stats/component/user-stats/charts/reading-dna-chart/reading-dna-chart.component.ts b/booklore-ui/src/app/features/stats/component/user-stats/charts/reading-dna-chart/reading-dna-chart.component.ts index 73f840eaf3..f6e21e5459 100644 --- a/booklore-ui/src/app/features/stats/component/user-stats/charts/reading-dna-chart/reading-dna-chart.component.ts +++ b/booklore-ui/src/app/features/stats/component/user-stats/charts/reading-dna-chart/reading-dna-chart.component.ts @@ -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; - } + 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 - }] - }); + const data = [ + profile.adventurous, + profile.perfectionist, + profile.intellectual, + profile.emotional, + profile.patient, + profile.social, + profile.nostalgic, + profile.ambitious + ]; - this.personalityInsights = this.convertToPersonalityInsights(profile); - } catch (error) { - console.error('Error updating reading DNA chart data:', error); - } + 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(); const languages = new Set(); - const formats = new Set(); 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 qualityRate = qualityBooks.length / books.length; - const completionScore = completionRate * 60; - const qualityScore = qualityRate * 40; + 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; - 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 classicBooks = books.filter(book => + this.bookMatchesGenres(book, classicGenres) + ); + const oldBookRate = oldBooks.length / books.length; - const classicRate = classicGenres.length / books.length; + const classicRate = classicBooks.length / books.length; - return Math.min(100, (oldBookRate * 60) + (classicRate * 40)); + 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, Math.round(volumeScore + challengingRate * 35 + completionRate * 25)); + } - return Math.min(100, volumeScore + challengingScore + completionBonus); + 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 getDefaultProfile(): ReadingDNAProfile { - return { - adventurous: 50, - perfectionist: 50, - intellectual: 50, - emotional: 50, - patient: 50, - social: 50, - nostalgic: 50, - ambitious: 50 + 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 = { + '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 + })); } } diff --git a/booklore-ui/src/app/features/stats/component/user-stats/charts/reading-habits-chart/reading-habits-chart.component.html b/booklore-ui/src/app/features/stats/component/user-stats/charts/reading-habits-chart/reading-habits-chart.component.html index 9b60e17e1d..57e9d9c98c 100644 --- a/booklore-ui/src/app/features/stats/component/user-stats/charts/reading-habits-chart/reading-habits-chart.component.html +++ b/booklore-ui/src/app/features/stats/component/user-stats/charts/reading-habits-chart/reading-habits-chart.component.html @@ -4,8 +4,13 @@

Reading Habits Analysis +

-

Your behavioral reading patterns across 8 key dimensions

+

Your behavioral reading patterns across 8 dimensions

diff --git a/booklore-ui/src/app/features/stats/component/user-stats/charts/reading-habits-chart/reading-habits-chart.component.ts b/booklore-ui/src/app/features/stats/component/user-stats/charts/reading-habits-chart/reading-habits-chart.component.ts index 57fc60ebcc..108d2d4866 100644 --- a/booklore-ui/src/app/features/stats/component/user-stats/charts/reading-habits-chart/reading-habits-chart.component.ts +++ b/booklore-ui/src/app/features/stats/component/user-stats/charts/reading-habits-chart/reading-habits-chart.component.ts @@ -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; - } + 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 - }] - }); + const data = [ + profile.consistency, + profile.multitasking, + profile.completionism, + profile.exploration, + profile.organization, + profile.intensity, + profile.methodology, + profile.momentum + ]; - this.habitInsights = this.convertToHabitInsights(profile); - } catch (error) { - console.error('Error updating reading habits chart data:', error); - } + 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) + .sort((a, b) => new Date(a.dateFinished!).getTime() - new Date(b.dateFinished!).getTime()); - const completedBooks = books.filter(book => book.readStatus === ReadStatus.READ && book.dateFinished); - if (completedBooks.length < 2) return 25; + if (completedBooks.length < 3) { + return Math.min(20, completedBooks.length * 10); + } - let consistencyScore = 50; + 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 inProgress = books.filter(book => - book.readStatus === ReadStatus.READING || book.readStatus === ReadStatus.RE_READING - ); - const progressRate = inProgress.length / books.length; - consistencyScore += progressRate * 30; + const meanGap = gaps.reduce((a, b) => a + b, 0) / gaps.length; + if (meanGap === 0) return 50; // all finished same day — likely bulk import - const sortedByCompletion = completedBooks - .sort((a, b) => new Date(a.dateFinished!).getTime() - new Date(b.dateFinished!).getTime()); + 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 - if (sortedByCompletion.length >= 3) { - consistencyScore += 20; - } + 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, consistencyScore); + 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 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; - }); + const activeBooks = books.filter(book => + book.readStatus === ReadStatus.READING || book.readStatus === ReadStatus.RE_READING + ).length; + + // 1 active = 10, 2 = 30, 3 = 50, 4 = 65, 5+ = 75+ + const activeScore = Math.min(75, activeBooks <= 1 ? activeBooks * 10 : 10 + (activeBooks - 1) * 20); - const multitaskingScore = Math.min(60, activeBooks * 15); - const progressScore = Math.min(40, (booksWithProgress.length / books.length) * 80); + // 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); - 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 + ); + + if (started.length === 0) return 0; - const completionRate = completed.length / (books.length - unfinished.length); - const abandonmentRate = abandoned.length / books.length; + const completed = books.filter(b => b.readStatus === ReadStatus.READ); + const abandoned = books.filter(b => b.readStatus === ReadStatus.ABANDONED); - const completionScore = completionRate * 70; - const abandonmentPenalty = abandonmentRate * 30; + const completionRate = completed.length / started.length; + const abandonmentRate = abandoned.length / started.length; - return Math.max(0, Math.min(100, completionScore - abandonmentPenalty + 30)); + 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(); - const authorCounts = new Map(); - 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(); + // 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(); 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; - - const wellOrganizedBooks = books.filter(book => { - const metadata = book.metadata; - if (!metadata) return false; - - const hasBasicInfo = metadata.title && metadata.authors && metadata.authors.length > 0; - const hasDetailedInfo = metadata.publishedDate || metadata.publisher || metadata.isbn10; - const hasCategories = metadata.categories && metadata.categories.length > 0; - - return hasBasicInfo && (hasDetailedInfo || hasCategories); - }); - - const metadataScore = (wellOrganizedBooks.length / books.length) * 35; - - const ratedBooks = books.filter(book => book.personalRating); - const ratingScore = (ratedBooks.length / books.length) * 25; - - return Math.min(100, seriesScore + metadataScore + ratingScore); + // 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; + + // 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; + + // 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 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 averagePages = booksWithPages.reduce((sum, book) => sum + (book.metadata?.pageCount || 0), 0) / booksWithPages.length; - const intensityFromLength = Math.min(50, averagePages / 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 > 75; - }); + const booksWithPages = books.filter(b => b.metadata?.pageCount && b.metadata.pageCount > 0); + if (booksWithPages.length === 0) return 0; - const progressScore = (highProgressBooks.length / books.length) * 30; + 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 completedSeriesBooks = books.filter(book => - book.metadata?.seriesName && book.readStatus === ReadStatus.READ - ); - const seriesIntensityScore = (completedSeriesBooks.length / books.length) * 20; + // 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; - 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(); - 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 authorBooks = new Map(); - books.forEach(book => { - book.metadata?.authors?.forEach(author => { - const authorName = author.toLowerCase(); - if (!authorBooks.has(authorName)) { - authorBooks.set(authorName, []); - } - authorBooks.get(authorName)!.push(book); + const 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 systematicAuthors = Array.from(authorBooks.values()).filter(books => books.length >= 2).length; - const authorMethodologyScore = Math.min(30, systematicAuthors * 5); + const orderScore = totalMultiBookSeries > 0 + ? (orderedSeries / totalMultiBookSeries) * 50 + : 25; - const categoryBooks = new Map(); + // Deep author dives (3+ books by same author) + const authorCounts = new Map(); books.forEach(book => { - book.metadata?.categories?.forEach(category => { - const cat = category.toLowerCase(); - categoryBooks.set(cat, (categoryBooks.get(cat) || 0) + 1); + 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 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); + // Focused genre reading (5+ books in a genre) + const genreCounts = new Map(); + books.forEach(book => { + 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); - 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, Math.round(recentScore + activeScore + progressScore)); + } - return Math.min(100, momentumScore); + 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 getDefaultProfile(): ReadingHabitsProfile { - return { - consistency: 40, - multitasking: 30, - completionism: 50, - exploration: 45, - organization: 35, - intensity: 40, - methodology: 35, - momentum: 30 + private getHabitDescription(habit: string, score: number): string { + const descriptions: Record = { + '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 + })); } } - diff --git a/booklore-ui/src/app/features/stats/component/user-stats/charts/reading-heatmap-chart/reading-heatmap-chart.component.html b/booklore-ui/src/app/features/stats/component/user-stats/charts/reading-heatmap-chart/reading-heatmap-chart.component.html index e3d022a15f..2e99c772e6 100644 --- a/booklore-ui/src/app/features/stats/component/user-stats/charts/reading-heatmap-chart/reading-heatmap-chart.component.html +++ b/booklore-ui/src/app/features/stats/component/user-stats/charts/reading-heatmap-chart/reading-heatmap-chart.component.html @@ -4,6 +4,11 @@

Reading Activity Heatmap +

Monthly reading activity over the past 10 years

diff --git a/booklore-ui/src/app/features/stats/component/user-stats/charts/reading-heatmap-chart/reading-heatmap-chart.component.ts b/booklore-ui/src/app/features/stats/component/user-stats/charts/reading-heatmap-chart/reading-heatmap-chart.component.ts index 5e1f220e16..0984faedea 100644 --- a/booklore-ui/src/app/features/stats/component/user-stats/charts/reading-heatmap-chart/reading-heatmap-chart.component.ts +++ b/booklore-ui/src/app/features/stats/component/user-stats/charts/reading-heatmap-chart/reading-heatmap-chart.component.ts @@ -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'] }) diff --git a/booklore-ui/src/app/features/stats/component/user-stats/charts/reading-progress-chart/reading-progress-chart.component.html b/booklore-ui/src/app/features/stats/component/user-stats/charts/reading-progress-chart/reading-progress-chart.component.html index a99264abb9..0722f3a6a8 100644 --- a/booklore-ui/src/app/features/stats/component/user-stats/charts/reading-progress-chart/reading-progress-chart.component.html +++ b/booklore-ui/src/app/features/stats/component/user-stats/charts/reading-progress-chart/reading-progress-chart.component.html @@ -4,6 +4,11 @@

Reading Progress Distribution +

Books by reading completion status

diff --git a/booklore-ui/src/app/features/stats/component/user-stats/charts/reading-progress-chart/reading-progress-chart.component.ts b/booklore-ui/src/app/features/stats/component/user-stats/charts/reading-progress-chart/reading-progress-chart.component.ts index 79c815f83d..c8ec44e695 100644 --- a/booklore-ui/src/app/features/stats/component/user-stats/charts/reading-progress-chart/reading-progress-chart.component.ts +++ b/booklore-ui/src/app/features/stats/component/user-stats/charts/reading-progress-chart/reading-progress-chart.component.ts @@ -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'] }) diff --git a/booklore-ui/src/app/features/stats/component/user-stats/charts/reading-session-heatmap/reading-session-heatmap.component.html b/booklore-ui/src/app/features/stats/component/user-stats/charts/reading-session-heatmap/reading-session-heatmap.component.html index 5190aa5677..0e19cf4a20 100644 --- a/booklore-ui/src/app/features/stats/component/user-stats/charts/reading-session-heatmap/reading-session-heatmap.component.html +++ b/booklore-ui/src/app/features/stats/component/user-stats/charts/reading-session-heatmap/reading-session-heatmap.component.html @@ -4,6 +4,11 @@

Reading Session Activity +

Daily overview of your reading sessions across the year

@@ -24,6 +29,7 @@

+
[type]="chartType">
+ + @if (hasStreakData) { + + } diff --git a/booklore-ui/src/app/features/stats/component/user-stats/charts/reading-session-heatmap/reading-session-heatmap.component.scss b/booklore-ui/src/app/features/stats/component/user-stats/charts/reading-session-heatmap/reading-session-heatmap.component.scss index 48d30332f4..adab507f7c 100644 --- a/booklore-ui/src/app/features/stats/component/user-stats/charts/reading-session-heatmap/reading-session-heatmap.component.scss +++ b/booklore-ui/src/app/features/stats/component/user-stats/charts/reading-session-heatmap/reading-session-heatmap.component.scss @@ -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) { diff --git a/booklore-ui/src/app/features/stats/component/user-stats/charts/reading-session-heatmap/reading-session-heatmap.component.ts b/booklore-ui/src/app/features/stats/component/user-stats/charts/reading-session-heatmap/reading-session-heatmap.component.ts index 46e8db1868..77797c1eb7 100644 --- a/booklore-ui/src/app/features/stats/component/user-stats/charts/reading-session-heatmap/reading-session-heatmap.component.ts +++ b/booklore-ui/src/app/features/stats/component/user-stats/charts/reading-session-heatmap/reading-session-heatmap.component.ts @@ -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; 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(); private readonly chartDataSubject: BehaviorSubject; @@ -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()); diff --git a/booklore-ui/src/app/features/stats/component/user-stats/charts/reading-session-timeline/reading-session-timeline.component.html b/booklore-ui/src/app/features/stats/component/user-stats/charts/reading-session-timeline/reading-session-timeline.component.html index 68ef188638..69ae211b1b 100644 --- a/booklore-ui/src/app/features/stats/component/user-stats/charts/reading-session-timeline/reading-session-timeline.component.html +++ b/booklore-ui/src/app/features/stats/component/user-stats/charts/reading-session-timeline/reading-session-timeline.component.html @@ -4,6 +4,11 @@

Reading Session Timeline +

Weekly overview of your reading sessions and patterns

diff --git a/booklore-ui/src/app/features/stats/component/user-stats/charts/reading-session-timeline/reading-session-timeline.component.ts b/booklore-ui/src/app/features/stats/component/user-stats/charts/reading-session-timeline/reading-session-timeline.component.ts index 2ddc9d4afd..204ab30a4f 100644 --- a/booklore-ui/src/app/features/stats/component/user-stats/charts/reading-session-timeline/reading-session-timeline.component.ts +++ b/booklore-ui/src/app/features/stats/component/user-stats/charts/reading-session-timeline/reading-session-timeline.component.ts @@ -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); + } } }); diff --git a/booklore-ui/src/app/features/stats/component/user-stats/charts/reading-survival-chart/reading-survival-chart.component.html b/booklore-ui/src/app/features/stats/component/user-stats/charts/reading-survival-chart/reading-survival-chart.component.html new file mode 100644 index 0000000000..ed26283250 --- /dev/null +++ b/booklore-ui/src/app/features/stats/component/user-stats/charts/reading-survival-chart/reading-survival-chart.component.html @@ -0,0 +1,53 @@ +
+
+
+

+ + Reading Survival Curve + +

+

Of books started, what percentage reached each progress threshold

+
+
+ + @if (totalStarted > 0) { +
+
+ {{ totalStarted }} + Started +
+
+ {{ completionRate }}% + Completion Rate +
+
+ {{ medianDropout }} + Median Dropout +
+
+ {{ dangerZone }} + Danger Zone +
+
+ } + +
+ + +
+ + @if (totalStarted === 0) { +
+ +

No books with reading progress found.

+ Start reading some books to see your survival curve. +
+ } +
diff --git a/booklore-ui/src/app/features/stats/component/user-stats/charts/reading-survival-chart/reading-survival-chart.component.scss b/booklore-ui/src/app/features/stats/component/user-stats/charts/reading-survival-chart/reading-survival-chart.component.scss new file mode 100644 index 0000000000..46b91230db --- /dev/null +++ b/booklore-ui/src/app/features/stats/component/user-stats/charts/reading-survival-chart/reading-survival-chart.component.scss @@ -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; + } + } + } +} diff --git a/booklore-ui/src/app/features/stats/component/user-stats/charts/reading-survival-chart/reading-survival-chart.component.ts b/booklore-ui/src/app/features/stats/component/user-stats/charts/reading-survival-chart/reading-survival-chart.component.ts new file mode 100644 index 0000000000..a640a83c22 --- /dev/null +++ b/booklore-ui/src/app/features/stats/component/user-stats/charts/reading-survival-chart/reading-survival-chart.component.ts @@ -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(); + + 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({ + labels: [], + datasets: [] + }); + + public readonly chartData$: Observable = 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; + } +} diff --git a/booklore-ui/src/app/features/stats/component/user-stats/charts/series-progress-chart/series-progress-chart.component.html b/booklore-ui/src/app/features/stats/component/user-stats/charts/series-progress-chart/series-progress-chart.component.html index 0cd4845a9b..af8385945b 100644 --- a/booklore-ui/src/app/features/stats/component/user-stats/charts/series-progress-chart/series-progress-chart.component.html +++ b/booklore-ui/src/app/features/stats/component/user-stats/charts/series-progress-chart/series-progress-chart.component.html @@ -4,6 +4,11 @@

Series Progress Tracker +

Track your reading progress across book series

diff --git a/booklore-ui/src/app/features/stats/component/user-stats/charts/series-progress-chart/series-progress-chart.component.scss b/booklore-ui/src/app/features/stats/component/user-stats/charts/series-progress-chart/series-progress-chart.component.scss index 4909f4fe24..0a1862d7f2 100644 --- a/booklore-ui/src/app/features/stats/component/user-stats/charts/series-progress-chart/series-progress-chart.component.scss +++ b/booklore-ui/src/app/features/stats/component/user-stats/charts/series-progress-chart/series-progress-chart.component.scss @@ -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 { diff --git a/booklore-ui/src/app/features/stats/component/user-stats/charts/series-progress-chart/series-progress-chart.component.ts b/booklore-ui/src/app/features/stats/component/user-stats/charts/series-progress-chart/series-progress-chart.component.ts index 5f5c902d25..566d493f24 100644 --- a/booklore-ui/src/app/features/stats/component/user-stats/charts/series-progress-chart/series-progress-chart.component.ts +++ b/booklore-ui/src/app/features/stats/component/user-stats/charts/series-progress-chart/series-progress-chart.component.ts @@ -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'] }) diff --git a/booklore-ui/src/app/features/stats/component/user-stats/service/user-chart-config.service.ts b/booklore-ui/src/app/features/stats/component/user-stats/service/user-chart-config.service.ts index 8fb8d1816c..713e2859eb 100644 --- a/booklore-ui/src/app/features/stats/component/user-stats/service/user-chart-config.service.ts +++ b/booklore-ui/src/app/features/stats/component/user-stats/service/user-chart-config.service.ts @@ -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(this.loadChartConfig()); diff --git a/booklore-ui/src/app/features/stats/component/user-stats/user-stats.component.html b/booklore-ui/src/app/features/stats/component/user-stats/user-stats.component.html index 8562920aee..165d125053 100644 --- a/booklore-ui/src/app/features/stats/component/user-stats/user-stats.component.html +++ b/booklore-ui/src/app/features/stats/component/user-stats/user-stats.component.html @@ -107,12 +107,24 @@

{{ userName }}'s Reading Statistics

@case ('rating-taste') { } - @case ('reading-backlog') { - - } @case ('series-progress') { } + @case ('page-turner') { + + } + @case ('completion-race') { + + } + @case ('reading-survival') { + + } + @case ('reading-clock') { + + } + @case ('book-length') { + + } } } diff --git a/booklore-ui/src/app/features/stats/component/user-stats/user-stats.component.ts b/booklore-ui/src/app/features/stats/component/user-stats/user-stats.component.ts index 4320d1fbb7..5ed0086dcf 100644 --- a/booklore-ui/src/app/features/stats/component/user-stats/user-stats.component.ts +++ b/booklore-ui/src/app/features/stats/component/user-stats/user-stats.component.ts @@ -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'] diff --git a/booklore-ui/src/styles.scss b/booklore-ui/src/styles.scss index 34a5bd7915..ca6357cee5 100644 --- a/booklore-ui/src/styles.scss +++ b/booklore-ui/src/styles.scss @@ -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; }