Display paginated reading sessions in the book metadata view (#2003)

Co-authored-by: acx10 <acx10@users.noreply.github.com>
This commit is contained in:
ACX
2025-12-26 16:44:44 -07:00
committed by GitHub
parent 2c1e8a99e3
commit 4712f53b8e
13 changed files with 533 additions and 117 deletions

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}

View File

@@ -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);
}

View File

@@ -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());
}
}

View File

@@ -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>

View File

@@ -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;
}
}
}

View File

@@ -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() !== '';
}
}

View File

@@ -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>

View File

@@ -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>;

View File

@@ -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}
);
}

View File

@@ -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;
}
}
}

View File

@@ -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,