diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/controller/ReadingSessionController.java b/booklore-api/src/main/java/com/adityachandel/booklore/controller/ReadingSessionController.java index 1c7ba343e..6c17b2bee 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/controller/ReadingSessionController.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/controller/ReadingSessionController.java @@ -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> getHeatmapForYear(@PathVariable int year) { - List 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> getTimelineForWeek( - @PathVariable int year, - @PathVariable int month, - @PathVariable int week) { - List 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> getReadingSpeedForYear(@PathVariable int year) { - List 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> getPeakReadingHours( - @RequestParam(required = false) Integer year, - @RequestParam(required = false) Integer month) { - List 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> getFavoriteReadingDays( - @RequestParam(required = false) Integer year, - @RequestParam(required = false) Integer month) { - List 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> getGenreStatistics() { - List 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> getCompletionTimeline(@PathVariable int year) { - List timeline = readingSessionService.getCompletionTimeline(year); - return ResponseEntity.ok(timeline); + @GetMapping("/book/{bookId}") + public ResponseEntity> getReadingSessionsForBook(@PathVariable Long bookId, @RequestParam(defaultValue = "0") int page) { + Page sessions = readingSessionService.getReadingSessionsForBook(bookId, page); + return ResponseEntity.ok(sessions); } } diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/controller/UserStatsController.java b/booklore-api/src/main/java/com/adityachandel/booklore/controller/UserStatsController.java new file mode 100644 index 000000000..ad51e202f --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/controller/UserStatsController.java @@ -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> getHeatmapForYear(@RequestParam int year) { + List 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> getTimelineForWeek( + @RequestParam int year, + @RequestParam int month, + @RequestParam int week) { + List 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> getReadingSpeedForYear(@RequestParam int year) { + List 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> getPeakReadingHours( + @RequestParam(required = false) Integer year, + @RequestParam(required = false) Integer month) { + List 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> getFavoriteReadingDays( + @RequestParam(required = false) Integer year, + @RequestParam(required = false) Integer month) { + List 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> getGenreStatistics() { + List 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> getCompletionTimeline(@RequestParam int year) { + List timeline = readingSessionService.getCompletionTimeline(year); + return ResponseEntity.ok(timeline); + } +} + diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/response/ReadingSessionResponse.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/response/ReadingSessionResponse.java new file mode 100644 index 000000000..29dcb40ef --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/response/ReadingSessionResponse.java @@ -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; +} + diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/repository/ReadingSessionRepository.java b/booklore-api/src/main/java/com/adityachandel/booklore/repository/ReadingSessionRepository.java index 3bfbc41c1..1094df900 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/repository/ReadingSessionRepository.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/repository/ReadingSessionRepository.java @@ -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 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 findByUserIdAndBookId( + @Param("userId") Long userId, + @Param("bookId") Long bookId, + Pageable pageable); } diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/ReadingSessionService.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/ReadingSessionService.java index b423117b5..fef5299b9 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/ReadingSessionService.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/ReadingSessionService.java @@ -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 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 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()); + } } diff --git a/booklore-ui/src/app/features/metadata/component/book-metadata-center/book-reading-sessions/book-reading-sessions.component.html b/booklore-ui/src/app/features/metadata/component/book-metadata-center/book-reading-sessions/book-reading-sessions.component.html new file mode 100644 index 000000000..cc1fac852 --- /dev/null +++ b/booklore-ui/src/app/features/metadata/component/book-metadata-center/book-reading-sessions/book-reading-sessions.component.html @@ -0,0 +1,65 @@ +
+ @if (loading && sessions.length === 0) { +
+ +

Loading reading sessions...

+
+ } @else if (sessions.length === 0) { +
+ +

No reading sessions recorded yet

+
+ } @else { + + + + Start Time + End Time + Duration + Progress + Progress Delta + Location + + + + + {{ formatDate(session.startTime) }} + {{ formatDate(session.endTime) }} + {{ formatDuration(session.durationSeconds) }} + + @if (session.startProgress !== null && session.startProgress !== undefined && session.endProgress !== null && session.endProgress !== undefined) { + {{ session.startProgress }}% → {{ session.endProgress }}% + } @else { + - + } + + + @if (session.progressDelta !== null && session.progressDelta !== undefined) { + + } @else { + N/A + } + + + @if (session.startLocation && session.endLocation && isPageNumber(session.startLocation)) { + Page {{ session.startLocation }} → Page {{ session.endLocation }} + } @else { + - + } + + + + + } +
diff --git a/booklore-ui/src/app/features/metadata/component/book-metadata-center/book-reading-sessions/book-reading-sessions.component.scss b/booklore-ui/src/app/features/metadata/component/book-metadata-center/book-reading-sessions/book-reading-sessions.component.scss new file mode 100644 index 000000000..41289dce5 --- /dev/null +++ b/booklore-ui/src/app/features/metadata/component/book-metadata-center/book-reading-sessions/book-reading-sessions.component.scss @@ -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; + } + } +} diff --git a/booklore-ui/src/app/features/metadata/component/book-metadata-center/book-reading-sessions/book-reading-sessions.component.ts b/booklore-ui/src/app/features/metadata/component/book-metadata-center/book-reading-sessions/book-reading-sessions.component.ts new file mode 100644 index 000000000..c3c798d51 --- /dev/null +++ b/booklore-ui/src/app/features/metadata/component/book-metadata-center/book-reading-sessions/book-reading-sessions.component.ts @@ -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() !== ''; + } +} diff --git a/booklore-ui/src/app/features/metadata/component/book-metadata-center/metadata-viewer/metadata-viewer.component.html b/booklore-ui/src/app/features/metadata/component/book-metadata-center/metadata-viewer/metadata-viewer.component.html index efa1c6351..fc921e4fe 100644 --- a/booklore-ui/src/app/features/metadata/component/book-metadata-center/metadata-viewer/metadata-viewer.component.html +++ b/booklore-ui/src/app/features/metadata/component/book-metadata-center/metadata-viewer/metadata-viewer.component.html @@ -19,7 +19,7 @@ } -
@let progress = getProgressPercent(book); @@ -605,6 +605,9 @@ Notes + Reading Sessions + + Reviews @@ -641,6 +644,9 @@ + + + diff --git a/booklore-ui/src/app/features/metadata/component/book-metadata-center/metadata-viewer/metadata-viewer.component.ts b/booklore-ui/src/app/features/metadata/component/book-metadata-center/metadata-viewer/metadata-viewer.component.ts index 8a379004f..845b6bb47 100644 --- a/booklore-ui/src/app/features/metadata/component/book-metadata-center/metadata-viewer/metadata-viewer.component.ts +++ b/booklore-ui/src/app/features/metadata/component/book-metadata-center/metadata-viewer/metadata-viewer.component.ts @@ -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; diff --git a/booklore-ui/src/app/features/settings/user-management/user-stats.service.ts b/booklore-ui/src/app/features/settings/user-management/user-stats.service.ts index daf2113dc..e7abf734f 100644 --- a/booklore-ui/src/app/features/settings/user-management/user-stats.service.ts +++ b/booklore-ui/src/app/features/settings/user-management/user-stats.service.ts @@ -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 { return this.http.get( - `${this.readingSessionsUrl}/heatmap/year/${year}` + `${this.readingSessionsUrl}/heatmap`, + { params: { year: year.toString() } } ); } getTimelineForWeek(year: number, month: number, week: number): Observable { return this.http.get( - `${this.readingSessionsUrl}/timeline/week/${year}/${month}/${week}` + `${this.readingSessionsUrl}/timeline`, + { params: { year: year.toString(), month: month.toString(), week: week.toString() } } ); } getGenreStats(): Observable { return this.http.get( - `${this.readingSessionsUrl}/stats/genres` + `${this.readingSessionsUrl}/genres` ); } getCompletionTimelineForYear(year: number): Observable { return this.http.get( - `${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( - `${this.readingSessionsUrl}/stats/favorite-days`, + `${this.readingSessionsUrl}/favorite-days`, {params} ); } @@ -105,7 +108,7 @@ export class UserStatsService { } return this.http.get( - `${this.readingSessionsUrl}/stats/peak-hours`, + `${this.readingSessionsUrl}/peak-hours`, {params} ); } diff --git a/booklore-ui/src/app/shared/service/reading-session-api.service.ts b/booklore-ui/src/app/shared/service/reading-session-api.service.ts new file mode 100644 index 000000000..48e6add6f --- /dev/null +++ b/booklore-ui/src/app/shared/service/reading-session-api.service.ts @@ -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 { + 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 { + return this.http.post(this.url, sessionData); + } + + getSessionsByBookId(bookId: number, page: number = 0, size: number = 5): Observable> { + const params = new HttpParams() + .set('page', page.toString()) + .set('size', size.toString()); + + return this.http.get>( + `${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; + } + } +} + diff --git a/booklore-ui/src/app/shared/service/reading-session.service.ts b/booklore-ui/src/app/shared/service/reading-session.service.ts index 4a21f1b25..1119a1445 100644 --- a/booklore-ui/src/app/shared/service/reading-session.service.ts +++ b/booklore-ui/src/app/shared/service/reading-session.service.ts @@ -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 | 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(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,