mirror of
https://github.com/booklore-app/booklore.git
synced 2026-02-18 00:17:53 +01:00
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:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
package org.booklore.model.dto;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
public interface CompletionRaceSessionDto {
|
||||
Long getBookId();
|
||||
String getBookTitle();
|
||||
Instant getSessionDate();
|
||||
Float getEndProgress();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user