mirror of
https://github.com/adityachandelgit/BookLore.git
synced 2026-01-04 15:19:48 -06:00
Display paginated reading sessions in the book metadata view (#2003)
Co-authored-by: acx10 <acx10@users.noreply.github.com>
This commit is contained in:
@@ -1,19 +1,17 @@
|
||||
package com.adityachandel.booklore.controller;
|
||||
|
||||
import com.adityachandel.booklore.model.dto.request.ReadingSessionRequest;
|
||||
import com.adityachandel.booklore.model.dto.response.*;
|
||||
import com.adityachandel.booklore.model.dto.response.ReadingSessionResponse;
|
||||
import com.adityachandel.booklore.service.ReadingSessionService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponses;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.AllArgsConstructor;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@RestController
|
||||
@AllArgsConstructor
|
||||
@RequestMapping("/api/v1/reading-sessions")
|
||||
@@ -32,95 +30,15 @@ public class ReadingSessionController {
|
||||
return ResponseEntity.accepted().build();
|
||||
}
|
||||
|
||||
@Operation(summary = "Get reading session heatmap for a year", description = "Returns daily reading session counts for the authenticated user for a specific year")
|
||||
@Operation(summary = "Get reading sessions for a book", description = "Returns paginated reading sessions for a specific book for the authenticated user")
|
||||
@ApiResponses({
|
||||
@ApiResponse(responseCode = "200", description = "Heatmap data retrieved successfully"),
|
||||
@ApiResponse(responseCode = "401", description = "Unauthorized")
|
||||
@ApiResponse(responseCode = "200", description = "Reading sessions retrieved successfully"),
|
||||
@ApiResponse(responseCode = "401", description = "Unauthorized"),
|
||||
@ApiResponse(responseCode = "404", description = "Book not found")
|
||||
})
|
||||
@GetMapping("/heatmap/year/{year}")
|
||||
@PreAuthorize("@securityUtil.canAccessUserStats() or @securityUtil.isAdmin()")
|
||||
public ResponseEntity<List<ReadingSessionHeatmapResponse>> getHeatmapForYear(@PathVariable int year) {
|
||||
List<ReadingSessionHeatmapResponse> heatmapData = readingSessionService.getSessionHeatmapForYear(year);
|
||||
return ResponseEntity.ok(heatmapData);
|
||||
}
|
||||
|
||||
@Operation(summary = "Get reading session timeline for a week", description = "Returns reading sessions grouped by book for calendar timeline view")
|
||||
@ApiResponses({
|
||||
@ApiResponse(responseCode = "200", description = "Timeline data retrieved successfully"),
|
||||
@ApiResponse(responseCode = "400", description = "Invalid week, month, or year"),
|
||||
@ApiResponse(responseCode = "401", description = "Unauthorized")
|
||||
})
|
||||
@GetMapping("/timeline/week/{year}/{month}/{week}")
|
||||
@PreAuthorize("@securityUtil.canAccessUserStats() or @securityUtil.isAdmin()")
|
||||
public ResponseEntity<List<ReadingSessionTimelineResponse>> getTimelineForWeek(
|
||||
@PathVariable int year,
|
||||
@PathVariable int month,
|
||||
@PathVariable int week) {
|
||||
List<ReadingSessionTimelineResponse> timelineData = readingSessionService.getSessionTimelineForWeek(year, month, week);
|
||||
return ResponseEntity.ok(timelineData);
|
||||
}
|
||||
|
||||
@Operation(summary = "Get reading speed analysis", description = "Returns average reading speed (progress per minute) over time for a specific year")
|
||||
@ApiResponses({
|
||||
@ApiResponse(responseCode = "200", description = "Reading speed data retrieved successfully"),
|
||||
@ApiResponse(responseCode = "401", description = "Unauthorized")
|
||||
})
|
||||
@GetMapping("/stats/speed/year/{year}")
|
||||
@PreAuthorize("@securityUtil.canAccessUserStats() or @securityUtil.isAdmin()")
|
||||
public ResponseEntity<List<ReadingSpeedResponse>> getReadingSpeedForYear(@PathVariable int year) {
|
||||
List<ReadingSpeedResponse> speedData = readingSessionService.getReadingSpeedForYear(year);
|
||||
return ResponseEntity.ok(speedData);
|
||||
}
|
||||
|
||||
@Operation(summary = "Get peak reading hours", description = "Returns reading activity distribution by hour of day. Can be filtered by year and/or month.")
|
||||
@ApiResponses({
|
||||
@ApiResponse(responseCode = "200", description = "Peak reading hours retrieved successfully"),
|
||||
@ApiResponse(responseCode = "401", description = "Unauthorized")
|
||||
})
|
||||
@GetMapping("/stats/peak-hours")
|
||||
@PreAuthorize("@securityUtil.canAccessUserStats() or @securityUtil.isAdmin()")
|
||||
public ResponseEntity<List<PeakReadingHoursResponse>> getPeakReadingHours(
|
||||
@RequestParam(required = false) Integer year,
|
||||
@RequestParam(required = false) Integer month) {
|
||||
List<PeakReadingHoursResponse> peakHours = readingSessionService.getPeakReadingHours(year, month);
|
||||
return ResponseEntity.ok(peakHours);
|
||||
}
|
||||
|
||||
@Operation(summary = "Get favorite reading days", description = "Returns reading activity distribution by day of week. Can be filtered by year and/or month.")
|
||||
@ApiResponses({
|
||||
@ApiResponse(responseCode = "200", description = "Favorite reading days retrieved successfully"),
|
||||
@ApiResponse(responseCode = "401", description = "Unauthorized")
|
||||
})
|
||||
@GetMapping("/stats/favorite-days")
|
||||
@PreAuthorize("@securityUtil.canAccessUserStats() or @securityUtil.isAdmin()")
|
||||
public ResponseEntity<List<FavoriteReadingDaysResponse>> getFavoriteReadingDays(
|
||||
@RequestParam(required = false) Integer year,
|
||||
@RequestParam(required = false) Integer month) {
|
||||
List<FavoriteReadingDaysResponse> favoriteDays = readingSessionService.getFavoriteReadingDays(year, month);
|
||||
return ResponseEntity.ok(favoriteDays);
|
||||
}
|
||||
|
||||
@Operation(summary = "Get genre statistics", description = "Returns reading statistics grouped by book genres/categories")
|
||||
@ApiResponses({
|
||||
@ApiResponse(responseCode = "200", description = "Genre statistics retrieved successfully"),
|
||||
@ApiResponse(responseCode = "401", description = "Unauthorized")
|
||||
})
|
||||
@GetMapping("/stats/genres")
|
||||
@PreAuthorize("@securityUtil.canAccessUserStats() or @securityUtil.isAdmin()")
|
||||
public ResponseEntity<List<GenreStatisticsResponse>> getGenreStatistics() {
|
||||
List<GenreStatisticsResponse> genreStats = readingSessionService.getGenreStatistics();
|
||||
return ResponseEntity.ok(genreStats);
|
||||
}
|
||||
|
||||
@Operation(summary = "Get completion timeline", description = "Returns reading completion statistics over time with status breakdown for a specific year")
|
||||
@ApiResponses({
|
||||
@ApiResponse(responseCode = "200", description = "Completion timeline retrieved successfully"),
|
||||
@ApiResponse(responseCode = "401", description = "Unauthorized")
|
||||
})
|
||||
@GetMapping("/stats/completion-timeline/year/{year}")
|
||||
@PreAuthorize("@securityUtil.canAccessUserStats() or @securityUtil.isAdmin()")
|
||||
public ResponseEntity<List<CompletionTimelineResponse>> getCompletionTimeline(@PathVariable int year) {
|
||||
List<CompletionTimelineResponse> timeline = readingSessionService.getCompletionTimeline(year);
|
||||
return ResponseEntity.ok(timeline);
|
||||
@GetMapping("/book/{bookId}")
|
||||
public ResponseEntity<Page<ReadingSessionResponse>> getReadingSessionsForBook(@PathVariable Long bookId, @RequestParam(defaultValue = "0") int page) {
|
||||
Page<ReadingSessionResponse> sessions = readingSessionService.getReadingSessionsForBook(bookId, page);
|
||||
return ResponseEntity.ok(sessions);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
package com.adityachandel.booklore.controller;
|
||||
|
||||
import com.adityachandel.booklore.model.dto.response.*;
|
||||
import com.adityachandel.booklore.service.ReadingSessionService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponses;
|
||||
import lombok.AllArgsConstructor;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@RestController
|
||||
@AllArgsConstructor
|
||||
@RequestMapping("/api/v1/user-stats")
|
||||
public class UserStatsController {
|
||||
|
||||
private final ReadingSessionService readingSessionService;
|
||||
|
||||
@Operation(summary = "Get reading session heatmap for a year", description = "Returns daily reading session counts for the authenticated user for a specific year")
|
||||
@ApiResponses({
|
||||
@ApiResponse(responseCode = "200", description = "Heatmap data retrieved successfully"),
|
||||
@ApiResponse(responseCode = "401", description = "Unauthorized")
|
||||
})
|
||||
@GetMapping("/heatmap")
|
||||
@PreAuthorize("@securityUtil.canAccessUserStats() or @securityUtil.isAdmin()")
|
||||
public ResponseEntity<List<ReadingSessionHeatmapResponse>> getHeatmapForYear(@RequestParam int year) {
|
||||
List<ReadingSessionHeatmapResponse> heatmapData = readingSessionService.getSessionHeatmapForYear(year);
|
||||
return ResponseEntity.ok(heatmapData);
|
||||
}
|
||||
|
||||
@Operation(summary = "Get reading session timeline for a week", description = "Returns reading sessions grouped by book for calendar timeline view")
|
||||
@ApiResponses({
|
||||
@ApiResponse(responseCode = "200", description = "Timeline data retrieved successfully"),
|
||||
@ApiResponse(responseCode = "400", description = "Invalid week, month, or year"),
|
||||
@ApiResponse(responseCode = "401", description = "Unauthorized")
|
||||
})
|
||||
@GetMapping("/timeline")
|
||||
@PreAuthorize("@securityUtil.canAccessUserStats() or @securityUtil.isAdmin()")
|
||||
public ResponseEntity<List<ReadingSessionTimelineResponse>> getTimelineForWeek(
|
||||
@RequestParam int year,
|
||||
@RequestParam int month,
|
||||
@RequestParam int week) {
|
||||
List<ReadingSessionTimelineResponse> timelineData = readingSessionService.getSessionTimelineForWeek(year, month, week);
|
||||
return ResponseEntity.ok(timelineData);
|
||||
}
|
||||
|
||||
@Operation(summary = "Get reading speed analysis", description = "Returns average reading speed (progress per minute) over time for a specific year")
|
||||
@ApiResponses({
|
||||
@ApiResponse(responseCode = "200", description = "Reading speed data retrieved successfully"),
|
||||
@ApiResponse(responseCode = "401", description = "Unauthorized")
|
||||
})
|
||||
@GetMapping("/speed")
|
||||
@PreAuthorize("@securityUtil.canAccessUserStats() or @securityUtil.isAdmin()")
|
||||
public ResponseEntity<List<ReadingSpeedResponse>> getReadingSpeedForYear(@RequestParam int year) {
|
||||
List<ReadingSpeedResponse> speedData = readingSessionService.getReadingSpeedForYear(year);
|
||||
return ResponseEntity.ok(speedData);
|
||||
}
|
||||
|
||||
@Operation(summary = "Get peak reading hours", description = "Returns reading activity distribution by hour of day. Can be filtered by year and/or month.")
|
||||
@ApiResponses({
|
||||
@ApiResponse(responseCode = "200", description = "Peak reading hours retrieved successfully"),
|
||||
@ApiResponse(responseCode = "401", description = "Unauthorized")
|
||||
})
|
||||
@GetMapping("/peak-hours")
|
||||
@PreAuthorize("@securityUtil.canAccessUserStats() or @securityUtil.isAdmin()")
|
||||
public ResponseEntity<List<PeakReadingHoursResponse>> getPeakReadingHours(
|
||||
@RequestParam(required = false) Integer year,
|
||||
@RequestParam(required = false) Integer month) {
|
||||
List<PeakReadingHoursResponse> peakHours = readingSessionService.getPeakReadingHours(year, month);
|
||||
return ResponseEntity.ok(peakHours);
|
||||
}
|
||||
|
||||
@Operation(summary = "Get favorite reading days", description = "Returns reading activity distribution by day of week. Can be filtered by year and/or month.")
|
||||
@ApiResponses({
|
||||
@ApiResponse(responseCode = "200", description = "Favorite reading days retrieved successfully"),
|
||||
@ApiResponse(responseCode = "401", description = "Unauthorized")
|
||||
})
|
||||
@GetMapping("/favorite-days")
|
||||
@PreAuthorize("@securityUtil.canAccessUserStats() or @securityUtil.isAdmin()")
|
||||
public ResponseEntity<List<FavoriteReadingDaysResponse>> getFavoriteReadingDays(
|
||||
@RequestParam(required = false) Integer year,
|
||||
@RequestParam(required = false) Integer month) {
|
||||
List<FavoriteReadingDaysResponse> favoriteDays = readingSessionService.getFavoriteReadingDays(year, month);
|
||||
return ResponseEntity.ok(favoriteDays);
|
||||
}
|
||||
|
||||
@Operation(summary = "Get genre statistics", description = "Returns reading statistics grouped by book genres/categories")
|
||||
@ApiResponses({
|
||||
@ApiResponse(responseCode = "200", description = "Genre statistics retrieved successfully"),
|
||||
@ApiResponse(responseCode = "401", description = "Unauthorized")
|
||||
})
|
||||
@GetMapping("/genres")
|
||||
@PreAuthorize("@securityUtil.canAccessUserStats() or @securityUtil.isAdmin()")
|
||||
public ResponseEntity<List<GenreStatisticsResponse>> getGenreStatistics() {
|
||||
List<GenreStatisticsResponse> genreStats = readingSessionService.getGenreStatistics();
|
||||
return ResponseEntity.ok(genreStats);
|
||||
}
|
||||
|
||||
@Operation(summary = "Get completion timeline", description = "Returns reading completion statistics over time with status breakdown for a specific year")
|
||||
@ApiResponses({
|
||||
@ApiResponse(responseCode = "200", description = "Completion timeline retrieved successfully"),
|
||||
@ApiResponse(responseCode = "401", description = "Unauthorized")
|
||||
})
|
||||
@GetMapping("/completion-timeline")
|
||||
@PreAuthorize("@securityUtil.canAccessUserStats() or @securityUtil.isAdmin()")
|
||||
public ResponseEntity<List<CompletionTimelineResponse>> getCompletionTimeline(@RequestParam int year) {
|
||||
List<CompletionTimelineResponse> timeline = readingSessionService.getCompletionTimeline(year);
|
||||
return ResponseEntity.ok(timeline);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.adityachandel.booklore.model.dto.response;
|
||||
|
||||
import com.adityachandel.booklore.model.enums.BookFileType;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class ReadingSessionResponse {
|
||||
private Long id;
|
||||
private Long bookId;
|
||||
private String bookTitle;
|
||||
private BookFileType bookType;
|
||||
private Instant startTime;
|
||||
private Instant endTime;
|
||||
private Integer durationSeconds;
|
||||
private Float startProgress;
|
||||
private Float endProgress;
|
||||
private Float progressDelta;
|
||||
private String startLocation;
|
||||
private String endLocation;
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ package com.adityachandel.booklore.repository;
|
||||
|
||||
import com.adityachandel.booklore.model.dto.*;
|
||||
import com.adityachandel.booklore.model.entity.ReadingSessionEntity;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
@@ -109,4 +111,16 @@ public interface ReadingSessionRepository extends JpaRepository<ReadingSessionEn
|
||||
ORDER BY totalSessions DESC
|
||||
""")
|
||||
List<GenreStatisticsDto> findGenreStatisticsByUser(@Param("userId") Long userId);
|
||||
|
||||
@Query("""
|
||||
SELECT rs
|
||||
FROM ReadingSessionEntity rs
|
||||
WHERE rs.user.id = :userId
|
||||
AND rs.book.id = :bookId
|
||||
ORDER BY rs.startTime DESC
|
||||
""")
|
||||
Page<ReadingSessionEntity> findByUserIdAndBookId(
|
||||
@Param("userId") Long userId,
|
||||
@Param("bookId") Long bookId,
|
||||
Pageable pageable);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import com.adityachandel.booklore.model.dto.response.FavoriteReadingDaysResponse
|
||||
import com.adityachandel.booklore.model.dto.response.GenreStatisticsResponse;
|
||||
import com.adityachandel.booklore.model.dto.response.PeakReadingHoursResponse;
|
||||
import com.adityachandel.booklore.model.dto.response.ReadingSessionHeatmapResponse;
|
||||
import com.adityachandel.booklore.model.dto.response.ReadingSessionResponse;
|
||||
import com.adityachandel.booklore.model.dto.response.ReadingSessionTimelineResponse;
|
||||
import com.adityachandel.booklore.model.dto.response.ReadingSpeedResponse;
|
||||
import com.adityachandel.booklore.model.entity.BookEntity;
|
||||
@@ -21,6 +22,9 @@ import com.adityachandel.booklore.repository.UserBookProgressRepository;
|
||||
import com.adityachandel.booklore.repository.UserRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.security.core.userdetails.UsernameNotFoundException;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
@@ -210,4 +214,31 @@ public class ReadingSessionService {
|
||||
})
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public Page<ReadingSessionResponse> getReadingSessionsForBook(Long bookId, int page) {
|
||||
BookLoreUser authenticatedUser = authenticationService.getAuthenticatedUser();
|
||||
Long userId = authenticatedUser.getId();
|
||||
|
||||
bookRepository.findById(bookId).orElseThrow(() -> ApiError.BOOK_NOT_FOUND.createException(bookId));
|
||||
|
||||
Pageable pageable = PageRequest.of(page, 5);
|
||||
Page<ReadingSessionEntity> sessions = readingSessionRepository.findByUserIdAndBookId(userId, bookId, pageable);
|
||||
|
||||
return sessions.map(session -> ReadingSessionResponse.builder()
|
||||
.id(session.getId())
|
||||
.bookId(session.getBook().getId())
|
||||
.bookTitle(session.getBook().getMetadata().getTitle())
|
||||
.bookType(session.getBookType())
|
||||
.startTime(session.getStartTime())
|
||||
.endTime(session.getEndTime())
|
||||
.durationSeconds(session.getDurationSeconds())
|
||||
.startProgress(session.getStartProgress())
|
||||
.endProgress(session.getEndProgress())
|
||||
.progressDelta(session.getProgressDelta())
|
||||
.startLocation(session.getStartLocation())
|
||||
.endLocation(session.getEndLocation())
|
||||
.createdAt(session.getCreatedAt())
|
||||
.build());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
<div class="reading-sessions-container">
|
||||
@if (loading && sessions.length === 0) {
|
||||
<div class="loading-state">
|
||||
<p-progressSpinner strokeWidth="4" fill="transparent" animationDuration=".8s"></p-progressSpinner>
|
||||
<p class="loading-text">Loading reading sessions...</p>
|
||||
</div>
|
||||
} @else if (sessions.length === 0) {
|
||||
<div class="empty-state">
|
||||
<i class="pi pi-clock" style="font-size: 3rem; color: var(--text-color-secondary);"></i>
|
||||
<p>No reading sessions recorded yet</p>
|
||||
</div>
|
||||
} @else {
|
||||
<p-table
|
||||
[value]="sessions"
|
||||
[lazy]="true"
|
||||
[paginator]="true"
|
||||
[rows]="rows"
|
||||
[totalRecords]="totalRecords"
|
||||
[loading]="loading"
|
||||
[first]="first"
|
||||
(onLazyLoad)="onPageChange($event)"
|
||||
[showCurrentPageReport]="true"
|
||||
currentPageReportTemplate="Showing {first} to {last} of {totalRecords} sessions"
|
||||
class="p-datatable-sm">
|
||||
<ng-template #header>
|
||||
<tr>
|
||||
<th>Start Time</th>
|
||||
<th>End Time</th>
|
||||
<th>Duration</th>
|
||||
<th>Progress</th>
|
||||
<th>Progress Delta</th>
|
||||
<th>Location</th>
|
||||
</tr>
|
||||
</ng-template>
|
||||
<ng-template #body let-session>
|
||||
<tr>
|
||||
<td>{{ formatDate(session.startTime) }}</td>
|
||||
<td>{{ formatDate(session.endTime) }}</td>
|
||||
<td>{{ formatDuration(session.durationSeconds) }}</td>
|
||||
<td>
|
||||
@if (session.startProgress !== null && session.startProgress !== undefined && session.endProgress !== null && session.endProgress !== undefined) {
|
||||
<span>{{ session.startProgress }}% → {{ session.endProgress }}%</span>
|
||||
} @else {
|
||||
<span class="text-color-secondary">-</span>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
@if (session.progressDelta !== null && session.progressDelta !== undefined) {
|
||||
<p-tag [value]="(session.progressDelta > 0 ? '+' : '') + session.progressDelta + '%'" [severity]="getProgressColor(session.progressDelta)"></p-tag>
|
||||
} @else {
|
||||
<span class="text-color-secondary">N/A</span>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
@if (session.startLocation && session.endLocation && isPageNumber(session.startLocation)) {
|
||||
<span class="location-text">Page {{ session.startLocation }} → Page {{ session.endLocation }}</span>
|
||||
} @else {
|
||||
<span class="text-color-secondary">-</span>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
</ng-template>
|
||||
</p-table>
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,51 @@
|
||||
.reading-sessions-container {
|
||||
padding: 1rem 0;
|
||||
}
|
||||
|
||||
.loading-state,
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 3rem;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
color: var(--text-color-secondary);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
color: var(--text-color-secondary);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.location-text {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
:host ::ng-deep {
|
||||
.p-datatable {
|
||||
.p-datatable-thead > tr > th {
|
||||
background-color: var(--card-background);
|
||||
color: var(--text-color);
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
white-space: nowrap;
|
||||
border-color: var(--border-color);
|
||||
}
|
||||
|
||||
.p-datatable-tbody > tr > td {
|
||||
padding: 0.35rem 0.65rem;
|
||||
font-size: 0.875rem;
|
||||
border-color: var(--border-color);
|
||||
}
|
||||
|
||||
.p-datatable-wrapper {
|
||||
overflow-x: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import {Component, inject, Input, OnInit, OnChanges, SimpleChanges} from '@angular/core';
|
||||
import {CommonModule} from '@angular/common';
|
||||
import {ReadingSessionApiService, ReadingSessionResponse} from '../../../../../shared/service/reading-session-api.service';
|
||||
import {TableModule} from 'primeng/table';
|
||||
import {ProgressSpinnerModule} from 'primeng/progressspinner';
|
||||
import {TagModule} from 'primeng/tag';
|
||||
|
||||
@Component({
|
||||
selector: 'app-book-reading-sessions',
|
||||
standalone: true,
|
||||
imports: [CommonModule, TableModule, ProgressSpinnerModule, TagModule],
|
||||
templateUrl: './book-reading-sessions.component.html',
|
||||
styleUrls: ['./book-reading-sessions.component.scss']
|
||||
})
|
||||
export class BookReadingSessionsComponent implements OnInit, OnChanges {
|
||||
@Input() bookId!: number;
|
||||
|
||||
private readonly readingSessionService = inject(ReadingSessionApiService);
|
||||
|
||||
sessions: ReadingSessionResponse[] = [];
|
||||
totalRecords = 0;
|
||||
first = 0;
|
||||
rows = 5;
|
||||
loading = false;
|
||||
|
||||
ngOnInit() {
|
||||
this.loadSessions();
|
||||
}
|
||||
|
||||
ngOnChanges(changes: SimpleChanges) {
|
||||
if (changes['bookId'] && !changes['bookId'].firstChange) {
|
||||
this.first = 0;
|
||||
this.loadSessions();
|
||||
}
|
||||
}
|
||||
|
||||
loadSessions(page: number = 0) {
|
||||
this.loading = true;
|
||||
this.readingSessionService.getSessionsByBookId(this.bookId, page, this.rows)
|
||||
.subscribe({
|
||||
next: (response) => {
|
||||
this.sessions = response.content;
|
||||
this.totalRecords = response.totalElements;
|
||||
this.loading = false;
|
||||
},
|
||||
error: () => {
|
||||
this.loading = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onPageChange(event: any) {
|
||||
this.first = event.first;
|
||||
const page = Math.floor(event.first / event.rows);
|
||||
this.loadSessions(page);
|
||||
}
|
||||
|
||||
formatDuration(seconds: number): string {
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
const secs = seconds % 60;
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${minutes}m`;
|
||||
} else if (minutes > 0) {
|
||||
return `${minutes}m ${secs}s`;
|
||||
}
|
||||
return `${secs}s`;
|
||||
}
|
||||
|
||||
formatDate(dateString: string): string {
|
||||
return new Date(dateString).toLocaleString();
|
||||
}
|
||||
|
||||
getProgressColor(delta: number): 'success' | 'secondary' | 'danger' {
|
||||
if (delta > 0) return 'success';
|
||||
if (delta < 0) return 'danger';
|
||||
return 'secondary';
|
||||
}
|
||||
|
||||
isPageNumber(location: string | undefined): boolean {
|
||||
if (!location) return false;
|
||||
return !isNaN(Number(location)) && location.trim() !== '';
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,7 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
<div
|
||||
<div
|
||||
class="cover-progress-bar-container"
|
||||
[style.--progress-count]="getProgressCount(book)">
|
||||
@let progress = getProgressPercent(book);
|
||||
@@ -605,6 +605,9 @@
|
||||
<i class="pi pi-pen-to-square"></i> Notes
|
||||
</p-tab>
|
||||
<p-tab [value]="4">
|
||||
<i class="pi pi-clock"></i> Reading Sessions
|
||||
</p-tab>
|
||||
<p-tab [value]="5">
|
||||
<i class="pi pi-comments"></i> Reviews
|
||||
</p-tab>
|
||||
</p-tablist>
|
||||
@@ -641,6 +644,9 @@
|
||||
<app-book-notes-component [bookId]="book.id"></app-book-notes-component>
|
||||
</p-tabpanel>
|
||||
<p-tabpanel [value]="4">
|
||||
<app-book-reading-sessions [bookId]="book.id"></app-book-reading-sessions>
|
||||
</p-tabpanel>
|
||||
<p-tabpanel [value]="5">
|
||||
<app-book-reviews [bookId]="book.id"></app-book-reviews>
|
||||
</p-tabpanel>
|
||||
</p-tabpanels>
|
||||
|
||||
@@ -41,13 +41,14 @@ import {
|
||||
import {BookNavigationService} from '../../../../book/service/book-navigation.service';
|
||||
import {Divider} from 'primeng/divider';
|
||||
import {BookMetadataHostService} from '../../../../../shared/service/book-metadata-host-service';
|
||||
import { BookReadingSessionsComponent } from '../book-reading-sessions/book-reading-sessions.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-metadata-viewer',
|
||||
standalone: true,
|
||||
templateUrl: './metadata-viewer.component.html',
|
||||
styleUrl: './metadata-viewer.component.scss',
|
||||
imports: [Button, AsyncPipe, Rating, FormsModule, SplitButton, NgClass, Tooltip, DecimalPipe, Editor, ProgressBar, Menu, InfiniteScrollDirective, BookCardLiteComponent, DatePicker, Tab, TabList, TabPanel, TabPanels, Tabs, BookReviewsComponent, BookNotesComponent, ProgressSpinner, TieredMenu, Image, TagComponent, UpperCasePipe, Divider]
|
||||
imports: [Button, AsyncPipe, Rating, FormsModule, SplitButton, NgClass, Tooltip, DecimalPipe, Editor, ProgressBar, Menu, InfiniteScrollDirective, BookCardLiteComponent, DatePicker, Tab, TabList, TabPanel, TabPanels, Tabs, BookReviewsComponent, BookNotesComponent, ProgressSpinner, TieredMenu, Image, TagComponent, UpperCasePipe, Divider, BookReadingSessionsComponent]
|
||||
})
|
||||
export class MetadataViewerComponent implements OnInit, OnChanges {
|
||||
@Input() book$!: Observable<Book | null>;
|
||||
|
||||
@@ -53,30 +53,33 @@ export interface PeakHoursResponse {
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class UserStatsService {
|
||||
private readonly readingSessionsUrl = `${API_CONFIG.BASE_URL}/api/v1/reading-sessions`;
|
||||
private readonly readingSessionsUrl = `${API_CONFIG.BASE_URL}/api/v1/user-stats`;
|
||||
private http = inject(HttpClient);
|
||||
|
||||
getHeatmapForYear(year: number): Observable<ReadingSessionHeatmapResponse[]> {
|
||||
return this.http.get<ReadingSessionHeatmapResponse[]>(
|
||||
`${this.readingSessionsUrl}/heatmap/year/${year}`
|
||||
`${this.readingSessionsUrl}/heatmap`,
|
||||
{ params: { year: year.toString() } }
|
||||
);
|
||||
}
|
||||
|
||||
getTimelineForWeek(year: number, month: number, week: number): Observable<ReadingSessionTimelineResponse[]> {
|
||||
return this.http.get<ReadingSessionTimelineResponse[]>(
|
||||
`${this.readingSessionsUrl}/timeline/week/${year}/${month}/${week}`
|
||||
`${this.readingSessionsUrl}/timeline`,
|
||||
{ params: { year: year.toString(), month: month.toString(), week: week.toString() } }
|
||||
);
|
||||
}
|
||||
|
||||
getGenreStats(): Observable<GenreStatsResponse[]> {
|
||||
return this.http.get<GenreStatsResponse[]>(
|
||||
`${this.readingSessionsUrl}/stats/genres`
|
||||
`${this.readingSessionsUrl}/genres`
|
||||
);
|
||||
}
|
||||
|
||||
getCompletionTimelineForYear(year: number): Observable<CompletionTimelineResponse[]> {
|
||||
return this.http.get<CompletionTimelineResponse[]>(
|
||||
`${this.readingSessionsUrl}/stats/completion-timeline/year/${year}`
|
||||
`${this.readingSessionsUrl}/completion-timeline`,
|
||||
{ params: { year: year.toString() } }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -90,7 +93,7 @@ export class UserStatsService {
|
||||
}
|
||||
|
||||
return this.http.get<FavoriteDaysResponse[]>(
|
||||
`${this.readingSessionsUrl}/stats/favorite-days`,
|
||||
`${this.readingSessionsUrl}/favorite-days`,
|
||||
{params}
|
||||
);
|
||||
}
|
||||
@@ -105,7 +108,7 @@ export class UserStatsService {
|
||||
}
|
||||
|
||||
return this.http.get<PeakHoursResponse[]>(
|
||||
`${this.readingSessionsUrl}/stats/peak-hours`,
|
||||
`${this.readingSessionsUrl}/peak-hours`,
|
||||
{params}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import {inject, Injectable} from '@angular/core';
|
||||
import {HttpClient, HttpParams} from '@angular/common/http';
|
||||
import {Observable} from 'rxjs';
|
||||
import {API_CONFIG} from '../../core/config/api-config';
|
||||
import {BookType} from '../../features/book/model/book.model';
|
||||
|
||||
export interface ReadingSessionResponse {
|
||||
id: number;
|
||||
bookId: number;
|
||||
bookTitle: string;
|
||||
bookType: BookType;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
durationSeconds: number;
|
||||
startProgress: number;
|
||||
endProgress: number;
|
||||
progressDelta: number;
|
||||
startLocation?: string;
|
||||
endLocation?: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface PageableResponse<T> {
|
||||
content: T[];
|
||||
pageable: {
|
||||
pageNumber: number;
|
||||
pageSize: number;
|
||||
sort: {
|
||||
empty: boolean;
|
||||
sorted: boolean;
|
||||
unsorted: boolean;
|
||||
};
|
||||
offset: number;
|
||||
paged: boolean;
|
||||
unpaged: boolean;
|
||||
};
|
||||
totalElements: number;
|
||||
last: boolean;
|
||||
totalPages: number;
|
||||
numberOfElements: number;
|
||||
first: boolean;
|
||||
size: number;
|
||||
number: number;
|
||||
sort: {
|
||||
empty: boolean;
|
||||
sorted: boolean;
|
||||
unsorted: boolean;
|
||||
};
|
||||
empty: boolean;
|
||||
}
|
||||
|
||||
export interface CreateReadingSessionDto {
|
||||
bookId: number;
|
||||
bookType: BookType;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
durationSeconds: number;
|
||||
durationFormatted: string;
|
||||
startProgress?: number;
|
||||
endProgress?: number;
|
||||
progressDelta?: number;
|
||||
startLocation?: string;
|
||||
endLocation?: string;
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class ReadingSessionApiService {
|
||||
private readonly http = inject(HttpClient);
|
||||
private readonly url = `${API_CONFIG.BASE_URL}/api/v1/reading-sessions`;
|
||||
|
||||
createSession(sessionData: CreateReadingSessionDto): Observable<void> {
|
||||
return this.http.post<void>(this.url, sessionData);
|
||||
}
|
||||
|
||||
getSessionsByBookId(bookId: number, page: number = 0, size: number = 5): Observable<PageableResponse<ReadingSessionResponse>> {
|
||||
const params = new HttpParams()
|
||||
.set('page', page.toString())
|
||||
.set('size', size.toString());
|
||||
|
||||
return this.http.get<PageableResponse<ReadingSessionResponse>>(
|
||||
`${this.url}/book/${bookId}`,
|
||||
{params}
|
||||
);
|
||||
}
|
||||
|
||||
sendSessionBeacon(sessionData: CreateReadingSessionDto): boolean {
|
||||
try {
|
||||
const blob = new Blob([JSON.stringify(sessionData)], {type: 'application/json'});
|
||||
return navigator.sendBeacon(this.url, blob);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { inject, Injectable } from '@angular/core';
|
||||
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
|
||||
import { HttpErrorResponse } from '@angular/common/http';
|
||||
import { fromEvent, merge, Subscription } from 'rxjs';
|
||||
import { debounceTime } from 'rxjs/operators';
|
||||
import { API_CONFIG } from '../../core/config/api-config';
|
||||
import {BookType} from '../../features/book/model/book.model';
|
||||
import { BookType } from '../../features/book/model/book.model';
|
||||
import {
|
||||
ReadingSessionApiService,
|
||||
CreateReadingSessionDto
|
||||
} from './reading-session-api.service';
|
||||
|
||||
export interface ReadingSession {
|
||||
bookId: number;
|
||||
@@ -22,8 +25,7 @@ export interface ReadingSession {
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class ReadingSessionService {
|
||||
private readonly http = inject(HttpClient);
|
||||
private readonly url = `${API_CONFIG.BASE_URL}/api/v1/reading-sessions`;
|
||||
private readonly apiService = inject(ReadingSessionApiService);
|
||||
|
||||
private currentSession: ReadingSession | null = null;
|
||||
private idleTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
@@ -139,15 +141,9 @@ export class ReadingSessionService {
|
||||
|
||||
this.log('Reading session ended (sync)', sessionData);
|
||||
|
||||
try {
|
||||
const blob = new Blob([JSON.stringify(sessionData)], { type: 'application/json' });
|
||||
const success = navigator.sendBeacon(this.url, blob);
|
||||
|
||||
if (!success) {
|
||||
this.logError('sendBeacon failed, request may not have been queued');
|
||||
}
|
||||
} catch (error) {
|
||||
this.logError('Failed to send session data', error);
|
||||
const success = this.apiService.sendSessionBeacon(sessionData);
|
||||
if (!success) {
|
||||
this.logError('sendBeacon failed, request may not have been queued');
|
||||
}
|
||||
|
||||
this.cleanup();
|
||||
@@ -167,13 +163,17 @@ export class ReadingSessionService {
|
||||
|
||||
this.log('Reading session completed', sessionData);
|
||||
|
||||
this.http.post<void>(this.url, sessionData).subscribe({
|
||||
this.apiService.createSession(sessionData).subscribe({
|
||||
next: () => this.log('Session saved to backend'),
|
||||
error: (err: HttpErrorResponse) => this.logError('Failed to save session', err)
|
||||
});
|
||||
}
|
||||
|
||||
private buildSessionData(session: ReadingSession, endTime: Date, durationSeconds: number) {
|
||||
private buildSessionData(
|
||||
session: ReadingSession,
|
||||
endTime: Date,
|
||||
durationSeconds: number
|
||||
): CreateReadingSessionDto {
|
||||
return {
|
||||
bookId: session.bookId,
|
||||
bookType: session.bookType,
|
||||
|
||||
Reference in New Issue
Block a user