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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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