Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -135,4 +135,40 @@ public ResponseEntity<List<BookCompletionHeatmapResponse>> getBookCompletionHeat
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);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package org.booklore.model.dto;

import java.time.Instant;

public interface CompletionRaceSessionDto {
Long getBookId();
String getBookTitle();
Instant getSessionDate();
Float getEndProgress();
}
Original file line number Diff line number Diff line change
@@ -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();
}
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -136,4 +139,52 @@ Page<ReadingSessionEntity> 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<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);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
Expand Down Expand Up @@ -287,4 +292,152 @@ public List<BookCompletionHeatmapResponse> getBookCompletionHeatmap() {
.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;
}
}
Loading