From b5ada2fff0cf316cc5c6e8ed9867af9d40a2b651 Mon Sep 17 00:00:00 2001 From: ACX <8075870+acx10@users.noreply.github.com> Date: Sun, 21 Dec 2025 21:14:42 -0700 Subject: [PATCH] Introduce reading session tracking with visual insights (#1957) * Introduce reading session tracking with visual insights (#1957) --------- Co-authored-by: acx10 --- .../controller/ReadingSessionController.java | 61 +++ .../model/dto/ReadingSessionCountDto.java | 9 + .../model/dto/ReadingSessionTimelineDto.java | 21 + .../dto/request/ReadingSessionRequest.java | 43 ++ .../ReadingSessionHeatmapResponse.java | 18 + .../ReadingSessionTimelineResponse.java | 23 + .../model/entity/BookLoreUserEntity.java | 4 + .../model/entity/ReadingSessionEntity.java | 67 +++ .../repository/ReadingSessionRepository.java | 47 ++ .../service/ReadingSessionService.java | 93 ++++ .../V78__Create_reading_sessions_table.sql | 22 + booklore-ui/src/app/app.routes.ts | 4 +- .../app/features/book/service/book.service.ts | 20 +- .../cbx-reader/cbx-reader.component.html | 7 + .../cbx-reader/cbx-reader.component.scss | 20 +- .../cbx-reader/cbx-reader.component.ts | 46 +- .../component/epub-reader.component.html | 8 + .../component/epub-reader.component.ts | 143 +++--- .../pdf-reader/pdf-reader.component.html | 45 +- .../pdf-reader/pdf-reader.component.ts | 33 +- .../user-management/user-stats.service.ts | 40 ++ .../reading-session-heatmap.component.html | 30 ++ .../reading-session-heatmap.component.scss | 120 +++++ .../reading-session-heatmap.component.ts | 228 +++++++++ .../reading-session-timeline.component.html | 104 ++++ .../reading-session-timeline.component.scss | 483 ++++++++++++++++++ .../reading-session-timeline.component.ts | 305 +++++++++++ .../stats/component/stats-component.html | 10 - .../stats/component/stats-component.scss | 34 ++ .../stats/component/stats-component.ts | 3 - .../user-stats/user-stats.component.html | 21 + .../user-stats/user-stats.component.scss | 147 ++++++ .../user-stats/user-stats.component.ts | 44 ++ .../stats/service/chart-config.service.ts | 11 +- .../service/reading-heatmap-chart.service.ts | 248 --------- .../layout-topbar/app.topbar.component.html | 12 +- .../layout-topbar/app.topbar.component.ts | 25 +- .../shared/service/reading-session.service.ts | 288 +++++++++++ .../app/shared/service/url-helper.service.ts | 45 +- 39 files changed, 2526 insertions(+), 406 deletions(-) create mode 100644 booklore-api/src/main/java/com/adityachandel/booklore/controller/ReadingSessionController.java create mode 100644 booklore-api/src/main/java/com/adityachandel/booklore/model/dto/ReadingSessionCountDto.java create mode 100644 booklore-api/src/main/java/com/adityachandel/booklore/model/dto/ReadingSessionTimelineDto.java create mode 100644 booklore-api/src/main/java/com/adityachandel/booklore/model/dto/request/ReadingSessionRequest.java create mode 100644 booklore-api/src/main/java/com/adityachandel/booklore/model/dto/response/ReadingSessionHeatmapResponse.java create mode 100644 booklore-api/src/main/java/com/adityachandel/booklore/model/dto/response/ReadingSessionTimelineResponse.java create mode 100644 booklore-api/src/main/java/com/adityachandel/booklore/model/entity/ReadingSessionEntity.java create mode 100644 booklore-api/src/main/java/com/adityachandel/booklore/repository/ReadingSessionRepository.java create mode 100644 booklore-api/src/main/java/com/adityachandel/booklore/service/ReadingSessionService.java create mode 100644 booklore-api/src/main/resources/db/migration/V78__Create_reading_sessions_table.sql create mode 100644 booklore-ui/src/app/features/settings/user-management/user-stats.service.ts create mode 100644 booklore-ui/src/app/features/stats/component/reading-session-heatmap/reading-session-heatmap.component.html create mode 100644 booklore-ui/src/app/features/stats/component/reading-session-heatmap/reading-session-heatmap.component.scss create mode 100644 booklore-ui/src/app/features/stats/component/reading-session-heatmap/reading-session-heatmap.component.ts create mode 100644 booklore-ui/src/app/features/stats/component/reading-session-timeline/reading-session-timeline.component.html create mode 100644 booklore-ui/src/app/features/stats/component/reading-session-timeline/reading-session-timeline.component.scss create mode 100644 booklore-ui/src/app/features/stats/component/reading-session-timeline/reading-session-timeline.component.ts create mode 100644 booklore-ui/src/app/features/stats/component/user-stats/user-stats.component.html create mode 100644 booklore-ui/src/app/features/stats/component/user-stats/user-stats.component.scss create mode 100644 booklore-ui/src/app/features/stats/component/user-stats/user-stats.component.ts delete mode 100644 booklore-ui/src/app/features/stats/service/reading-heatmap-chart.service.ts create mode 100644 booklore-ui/src/app/shared/service/reading-session.service.ts 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 new file mode 100644 index 00000000..02e8311c --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/controller/ReadingSessionController.java @@ -0,0 +1,61 @@ +package com.adityachandel.booklore.controller; + +import com.adityachandel.booklore.model.dto.request.ReadingSessionRequest; +import com.adityachandel.booklore.model.dto.response.ReadingSessionHeatmapResponse; +import com.adityachandel.booklore.model.dto.response.ReadingSessionTimelineResponse; +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.format.annotation.DateTimeFormat; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.time.LocalDateTime; +import java.util.List; + +@RestController +@AllArgsConstructor +@RequestMapping("/api/v1/reading-sessions") +public class ReadingSessionController { + + private final ReadingSessionService readingSessionService; + + @Operation(summary = "Record a reading session", description = "Receive telemetry from the reader client and persist or log the session.") + @ApiResponses({ + @ApiResponse(responseCode = "202", description = "Reading session accepted"), + @ApiResponse(responseCode = "400", description = "Invalid payload") + }) + @PostMapping + public ResponseEntity recordReadingSession(@RequestBody @Valid ReadingSessionRequest request) { + readingSessionService.recordSession(request); + 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") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "Heatmap data retrieved successfully"), + @ApiResponse(responseCode = "401", description = "Unauthorized") + }) + @GetMapping("/heatmap/year/{year}") + 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 or year"), + @ApiResponse(responseCode = "401", description = "Unauthorized") + }) + @GetMapping("/timeline/week/{year}/{week}") + public ResponseEntity> getTimelineForWeek( + @PathVariable int year, + @PathVariable int week) { + List timelineData = readingSessionService.getSessionTimelineForWeek(year, week); + return ResponseEntity.ok(timelineData); + } +} diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/ReadingSessionCountDto.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/ReadingSessionCountDto.java new file mode 100644 index 00000000..e73250c7 --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/ReadingSessionCountDto.java @@ -0,0 +1,9 @@ +package com.adityachandel.booklore.model.dto; + +import java.time.LocalDate; + +public interface ReadingSessionCountDto { + LocalDate getDate(); + Long getCount(); +} + diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/ReadingSessionTimelineDto.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/ReadingSessionTimelineDto.java new file mode 100644 index 00000000..1393f5f4 --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/ReadingSessionTimelineDto.java @@ -0,0 +1,21 @@ +package com.adityachandel.booklore.model.dto; + +import com.adityachandel.booklore.model.enums.BookFileType; + +import java.time.LocalDateTime; + +public interface ReadingSessionTimelineDto { + Long getBookId(); + + String getBookTitle(); + + BookFileType getBookFileType(); + + LocalDateTime getStartDate(); + + LocalDateTime getEndDate(); + + Long getTotalSessions(); + + Long getTotalDurationSeconds(); +} diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/request/ReadingSessionRequest.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/request/ReadingSessionRequest.java new file mode 100644 index 00000000..6053bc45 --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/request/ReadingSessionRequest.java @@ -0,0 +1,43 @@ +package com.adityachandel.booklore.model.dto.request; + +import com.adityachandel.booklore.model.enums.BookFileType; +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.Instant; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class ReadingSessionRequest { + @NotNull + private Long bookId; + + private BookFileType bookType; + + @NotNull + private Instant startTime; + + @NotNull + private Instant endTime; + + @NotNull + private Integer durationSeconds; + + @NotNull + private Float startProgress; + + @NotNull + private Float endProgress; + + @NotNull + private Float progressDelta; + + @NotNull + private String startLocation; + + @NotNull + private String endLocation; +} diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/response/ReadingSessionHeatmapResponse.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/response/ReadingSessionHeatmapResponse.java new file mode 100644 index 00000000..4904811a --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/response/ReadingSessionHeatmapResponse.java @@ -0,0 +1,18 @@ +package com.adityachandel.booklore.model.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ReadingSessionHeatmapResponse { + private LocalDate date; + private Long count; +} + diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/response/ReadingSessionTimelineResponse.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/response/ReadingSessionTimelineResponse.java new file mode 100644 index 00000000..514ed0ce --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/response/ReadingSessionTimelineResponse.java @@ -0,0 +1,23 @@ +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.LocalDateTime; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ReadingSessionTimelineResponse { + private Long bookId; + private String bookTitle; + private BookFileType bookType; + private LocalDateTime startDate; + private LocalDateTime endDate; + private Long totalSessions; + private Long totalDurationSeconds; +} diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/entity/BookLoreUserEntity.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/entity/BookLoreUserEntity.java index 19f5cc90..661b0e08 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/model/entity/BookLoreUserEntity.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/entity/BookLoreUserEntity.java @@ -66,6 +66,10 @@ public class BookLoreUserEntity { @OneToOne(mappedBy = "bookLoreUser", cascade = CascadeType.ALL, orphanRemoval = true) private KoreaderUserEntity koreaderUser; + @OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY) + @Builder.Default + private Set readingSessions = new HashSet<>(); + @PrePersist public void prePersist() { this.createdAt = LocalDateTime.now(); diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/entity/ReadingSessionEntity.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/entity/ReadingSessionEntity.java new file mode 100644 index 00000000..32a29733 --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/entity/ReadingSessionEntity.java @@ -0,0 +1,67 @@ +package com.adityachandel.booklore.model.entity; + +import com.adityachandel.booklore.model.enums.BookFileType; +import jakarta.persistence.*; +import lombok.*; + +import java.time.Instant; +import java.time.LocalDateTime; + +@Getter +@Setter +@Builder +@AllArgsConstructor +@NoArgsConstructor +@Entity +@Table(name = "reading_sessions") +public class ReadingSessionEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private BookLoreUserEntity user; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "book_id", nullable = false) + private BookEntity book; + + @Enumerated(EnumType.STRING) + @Column(name = "book_type", nullable = false) + private BookFileType bookType; + + @Column(name = "start_time", nullable = false) + private Instant startTime; + + @Column(name = "end_time", nullable = false) + private Instant endTime; + + @Column(name = "duration_seconds", nullable = false) + private Integer durationSeconds; + + @Column(name = "start_progress", nullable = false) + private Float startProgress; + + @Column(name = "end_progress", nullable = false) + private Float endProgress; + + @Column(name = "progress_delta", nullable = false) + private Float progressDelta; + + @Column(name = "start_location", nullable = false, length = 500) + private String startLocation; + + @Column(name = "end_location", nullable = false, length = 500) + private String endLocation; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @PrePersist + public void prePersist() { + this.createdAt = LocalDateTime.now(); + } +} + 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 new file mode 100644 index 00000000..e6002c32 --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/repository/ReadingSessionRepository.java @@ -0,0 +1,47 @@ +package com.adityachandel.booklore.repository; + +import com.adityachandel.booklore.model.dto.ReadingSessionCountDto; +import com.adityachandel.booklore.model.dto.ReadingSessionTimelineDto; +import com.adityachandel.booklore.model.entity.ReadingSessionEntity; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface ReadingSessionRepository extends JpaRepository { + + @Query(""" + SELECT CAST(rs.createdAt AS LocalDate) as date, COUNT(rs) as count + FROM ReadingSessionEntity rs + WHERE rs.user.id = :userId + AND YEAR(rs.createdAt) = :year + GROUP BY CAST(rs.createdAt AS LocalDate) + ORDER BY date + """) + List findSessionCountsByUserAndYear(@Param("userId") Long userId, @Param("year") int year); + + @Query(""" + SELECT + b.id as bookId, + b.metadata.title as bookTitle, + rs.bookType as bookFileType, + MIN(rs.startTime) as startDate, + MAX(rs.endTime) as endDate, + COUNT(rs) as totalSessions, + SUM(rs.durationSeconds) as totalDurationSeconds + FROM ReadingSessionEntity rs + JOIN rs.book b + WHERE rs.user.id = :userId + AND YEAR(rs.startTime) = :year + AND WEEK(rs.startTime) = :weekOfYear + GROUP BY b.id, b.metadata.title, rs.bookType + ORDER BY MIN(rs.startTime) + """) + List findSessionTimelineByUserAndWeek( + @Param("userId") Long userId, + @Param("year") int year, + @Param("weekOfYear") int weekOfYear); +} 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 new file mode 100644 index 00000000..e3883fbd --- /dev/null +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/ReadingSessionService.java @@ -0,0 +1,93 @@ +package com.adityachandel.booklore.service; + +import com.adityachandel.booklore.config.security.service.AuthenticationService; +import com.adityachandel.booklore.exception.ApiError; +import com.adityachandel.booklore.model.dto.BookLoreUser; +import com.adityachandel.booklore.model.dto.request.ReadingSessionRequest; +import com.adityachandel.booklore.model.dto.response.ReadingSessionHeatmapResponse; +import com.adityachandel.booklore.model.dto.response.ReadingSessionTimelineResponse; +import com.adityachandel.booklore.model.entity.BookEntity; +import com.adityachandel.booklore.model.entity.BookLoreUserEntity; +import com.adityachandel.booklore.model.entity.ReadingSessionEntity; +import com.adityachandel.booklore.repository.BookRepository; +import com.adityachandel.booklore.repository.ReadingSessionRepository; +import com.adityachandel.booklore.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.stream.Collectors; + +@Slf4j +@Service +@RequiredArgsConstructor +public class ReadingSessionService { + + private final AuthenticationService authenticationService; + private final ReadingSessionRepository readingSessionRepository; + private final BookRepository bookRepository; + private final UserRepository userRepository; + + @Transactional + public void recordSession(ReadingSessionRequest request) { + BookLoreUser authenticatedUser = authenticationService.getAuthenticatedUser(); + Long userId = authenticatedUser.getId(); + + BookLoreUserEntity userEntity = userRepository.findById(userId).orElseThrow(() -> new UsernameNotFoundException("User not found with ID: " + userId)); + BookEntity book = bookRepository.findById(request.getBookId()).orElseThrow(() -> ApiError.BOOK_NOT_FOUND.createException(request.getBookId())); + + ReadingSessionEntity session = ReadingSessionEntity.builder() + .user(userEntity) + .book(book) + .bookType(request.getBookType()) + .startTime(request.getStartTime()) + .endTime(request.getEndTime()) + .durationSeconds(request.getDurationSeconds()) + .startProgress(request.getStartProgress()) + .endProgress(request.getEndProgress()) + .progressDelta(request.getProgressDelta()) + .startLocation(request.getStartLocation()) + .endLocation(request.getEndLocation()) + .build(); + + readingSessionRepository.save(session); + + log.info("Reading session persisted successfully: sessionId={}, userId={}, bookId={}, duration={}s", session.getId(), userId, request.getBookId(), request.getDurationSeconds()); + } + + @Transactional(readOnly = true) + public List getSessionHeatmapForYear(int year) { + BookLoreUser authenticatedUser = authenticationService.getAuthenticatedUser(); + Long userId = authenticatedUser.getId(); + + return readingSessionRepository.findSessionCountsByUserAndYear(userId, year) + .stream() + .map(dto -> ReadingSessionHeatmapResponse.builder() + .date(dto.getDate()) + .count(dto.getCount()) + .build()) + .collect(Collectors.toList()); + } + + @Transactional(readOnly = true) + public List getSessionTimelineForWeek(int year, int week) { + BookLoreUser authenticatedUser = authenticationService.getAuthenticatedUser(); + Long userId = authenticatedUser.getId(); + + return readingSessionRepository.findSessionTimelineByUserAndWeek(userId, year, week) + .stream() + .map(dto -> ReadingSessionTimelineResponse.builder() + .bookId(dto.getBookId()) + .bookType(dto.getBookFileType()) + .bookTitle(dto.getBookTitle()) + .startDate(dto.getStartDate()) + .endDate(dto.getEndDate()) + .totalSessions(dto.getTotalSessions()) + .totalDurationSeconds(dto.getTotalDurationSeconds()) + .build()) + .collect(Collectors.toList()); + } +} diff --git a/booklore-api/src/main/resources/db/migration/V78__Create_reading_sessions_table.sql b/booklore-api/src/main/resources/db/migration/V78__Create_reading_sessions_table.sql new file mode 100644 index 00000000..1d5ad2af --- /dev/null +++ b/booklore-api/src/main/resources/db/migration/V78__Create_reading_sessions_table.sql @@ -0,0 +1,22 @@ +CREATE TABLE IF NOT EXISTS reading_sessions +( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + user_id BIGINT NOT NULL, + book_id BIGINT NOT NULL, + book_type VARCHAR(10) NOT NULL, + start_time DATETIME NOT NULL, + end_time DATETIME NOT NULL, + duration_seconds INTEGER NOT NULL, + start_progress FLOAT NOT NULL, + end_progress FLOAT NOT NULL, + progress_delta FLOAT NOT NULL, + start_location VARCHAR(500) NOT NULL, + end_location VARCHAR(500) NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_reading_session_user FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE, + CONSTRAINT fk_reading_session_book FOREIGN KEY (book_id) REFERENCES book (id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_reading_session_user_time ON reading_sessions (user_id, start_time DESC); +CREATE INDEX IF NOT EXISTS idx_reading_session_book ON reading_sessions (book_id, start_time DESC); +CREATE INDEX IF NOT EXISTS idx_reading_session_user_book ON reading_sessions (user_id, book_id, start_time DESC); diff --git a/booklore-ui/src/app/app.routes.ts b/booklore-ui/src/app/app.routes.ts index f484408d..624e4035 100644 --- a/booklore-ui/src/app/app.routes.ts +++ b/booklore-ui/src/app/app.routes.ts @@ -21,6 +21,7 @@ import {PdfReaderComponent} from './features/readers/pdf-reader/pdf-reader.compo import {BookdropFileReviewComponent} from './features/bookdrop/component/bookdrop-file-review/bookdrop-file-review.component'; import {ManageLibraryGuard} from './core/security/guards/manage-library.guard'; import {LoginGuard} from './shared/components/setup/login.guard'; +import {UserStatsComponent} from './features/stats/component/user-stats/user-stats.component'; export const routes: Routes = [ { @@ -50,7 +51,8 @@ export const routes: Routes = [ {path: 'book/:bookId', component: BookMetadataCenterComponent, canActivate: [AuthGuard]}, {path: 'bookdrop', component: BookdropFileReviewComponent, canActivate: [ManageLibraryGuard]}, {path: 'metadata-manager', component: MetadataManagerComponent, canActivate: [ManageLibraryGuard]}, - {path: 'stats', component: StatsComponent, canActivate: [AuthGuard]}, + {path: 'library-stats', component: StatsComponent, canActivate: [AuthGuard]}, + {path: 'reading-stats', component: UserStatsComponent, canActivate: [AuthGuard]}, ] }, { diff --git a/booklore-ui/src/app/features/book/service/book.service.ts b/booklore-ui/src/app/features/book/service/book.service.ts index d9306851..a0cbb916 100644 --- a/booklore-ui/src/app/features/book/service/book.service.ts +++ b/booklore-ui/src/app/features/book/service/book.service.ts @@ -11,6 +11,7 @@ import {MessageService} from 'primeng/api'; import {ResetProgressType, ResetProgressTypes} from '../../../shared/constants/reset-progress-type'; import {AuthService} from '../../../shared/service/auth.service'; import {FileDownloadService} from '../../../shared/service/file-download.service'; +import {Router} from '@angular/router'; @Injectable({ providedIn: 'root', @@ -23,6 +24,7 @@ export class BookService { private messageService = inject(MessageService); private authService = inject(AuthService); private fileDownloadService = inject(FileDownloadService); + private router = inject(Router); private bookStateSubject = new BehaviorSubject({ books: null, @@ -188,32 +190,36 @@ export class BookService { this.bookStateSubject.next({...currentState, books: updatedBooks}); } - readBook(bookId: number, reader?: "ngx" | "streaming"): void { + readBook(bookId: number, reader?: 'ngx' | 'streaming'): void { const book = this.bookStateSubject.value.books?.find(b => b.id === bookId); if (!book) { console.error('Book not found'); return; } - let url: string | null = null; + let url: string; + switch (book.bookType) { - case "PDF": - url = !reader || reader === "ngx" + case 'PDF': + url = !reader || reader === 'ngx' ? `/pdf-reader/book/${book.id}` : `/cbx-reader/book/${book.id}`; break; - case "EPUB": + + case 'EPUB': url = `/epub-reader/book/${book.id}`; break; - case "CBX": + + case 'CBX': url = `/cbx-reader/book/${book.id}`; break; + default: console.error('Unsupported book type:', book.bookType); return; } - window.open(url, '_blank'); + this.router.navigate([url]); this.updateLastReadTime(book.id); } diff --git a/booklore-ui/src/app/features/readers/cbx-reader/cbx-reader.component.html b/booklore-ui/src/app/features/readers/cbx-reader/cbx-reader.component.html index d56f613d..d155e0e5 100644 --- a/booklore-ui/src/app/features/readers/cbx-reader/cbx-reader.component.html +++ b/booklore-ui/src/app/features/readers/cbx-reader/cbx-reader.component.html @@ -97,6 +97,9 @@ +
@@ -160,6 +163,10 @@ {{ backgroundColorIcon }} Background +
} diff --git a/booklore-ui/src/app/features/readers/cbx-reader/cbx-reader.component.scss b/booklore-ui/src/app/features/readers/cbx-reader/cbx-reader.component.scss index 02924d62..a8ad31a0 100644 --- a/booklore-ui/src/app/features/readers/cbx-reader/cbx-reader.component.scss +++ b/booklore-ui/src/app/features/readers/cbx-reader/cbx-reader.component.scss @@ -311,6 +311,18 @@ background: #4a4a4a; border-color: #666; } + + &.close-button { + span { + font-size: 16px; + font-weight: bold; + } + + &:hover { + background: rgba(239, 68, 68, 0.2); + border-color: rgba(239, 68, 68, 0.4); + } + } } .fit-mode-dropdown { @@ -387,11 +399,11 @@ background: #0f0f0f; min-height: 0; position: relative; - + &:not(.two-page-view):not(.infinite-scroll) { flex-direction: column; align-items: stretch; - + .pages-wrapper { flex: 1; display: flex; @@ -399,7 +411,7 @@ align-items: center; min-height: 0; } - + .end-of-comic-action { flex: 0 0 auto; margin-top: 1rem; @@ -777,7 +789,7 @@ .action-icon { font-size: 2rem; } .action-text { font-size: 1rem; } - .book-title { + .book-title { font-size: 0.85rem; max-width: 250px; } diff --git a/booklore-ui/src/app/features/readers/cbx-reader/cbx-reader.component.ts b/booklore-ui/src/app/features/readers/cbx-reader/cbx-reader.component.ts index 7ed978dd..1a3345fb 100644 --- a/booklore-ui/src/app/features/readers/cbx-reader/cbx-reader.component.ts +++ b/booklore-ui/src/app/features/readers/cbx-reader/cbx-reader.component.ts @@ -1,5 +1,6 @@ -import {Component, HostListener, inject, OnInit} from '@angular/core'; +import {Component, HostListener, inject, OnInit, OnDestroy} from '@angular/core'; import {ActivatedRoute, Router} from '@angular/router'; +import {CommonModule, Location} from '@angular/common'; import {PageTitleService} from "../../../shared/service/page-title.service"; import {CbxReaderService} from '../../book/service/cbx-reader.service'; import {BookService} from '../../book/service/book.service'; @@ -21,6 +22,7 @@ import {BookState} from '../../book/model/state/book-state.model'; import {ProgressSpinner} from 'primeng/progressspinner'; import {FormsModule} from "@angular/forms"; import {NewPdfReaderService} from '../../book/service/new-pdf-reader.service'; +import {ReadingSessionService} from '../../../shared/service/reading-session.service'; @Component({ @@ -30,7 +32,7 @@ import {NewPdfReaderService} from '../../book/service/new-pdf-reader.service'; templateUrl: './cbx-reader.component.html', styleUrl: './cbx-reader.component.scss' }) -export class CbxReaderComponent implements OnInit { +export class CbxReaderComponent implements OnInit, OnDestroy { bookType!: BookType; goToPageInput: number | null = null; @@ -53,12 +55,14 @@ export class CbxReaderComponent implements OnInit { private route = inject(ActivatedRoute); private router = inject(Router); + private location = inject(Location); private cbxReaderService = inject(CbxReaderService); private pdfReaderService = inject(NewPdfReaderService); private bookService = inject(BookService); private userService = inject(UserService); private messageService = inject(MessageService); private pageTitle = inject(PageTitleService); + private readingSessionService = inject(ReadingSessionService); showFitModeDropdown: boolean = false; @@ -164,6 +168,9 @@ export class CbxReaderComponent implements OnInit { } this.alignCurrentPageToParity(); this.isLoading = false; + + const percentage = this.pages.length > 0 ? Math.round(((this.currentPage + 1) / this.pages.length) * 1000) / 10 : 0; + this.readingSessionService.startSession(this.bookId, "CBX", (this.currentPage + 1).toString(), percentage); }, error: (err) => { const errorMessage = err?.error?.message || 'Failed to load pages'; @@ -253,6 +260,7 @@ export class CbxReaderComponent implements OnInit { this.currentPage++; this.scrollToPage(this.currentPage); this.updateProgress(); + this.updateSessionProgress(); } return; } @@ -269,6 +277,7 @@ export class CbxReaderComponent implements OnInit { if (this.currentPage !== previousPage) { this.updateProgress(); + this.updateSessionProgress(); } } @@ -278,6 +287,7 @@ export class CbxReaderComponent implements OnInit { this.currentPage--; this.scrollToPage(this.currentPage); this.updateProgress(); + this.updateSessionProgress(); } return; } @@ -288,6 +298,7 @@ export class CbxReaderComponent implements OnInit { this.currentPage = Math.max(0, this.currentPage - 1); } this.updateProgress(); + this.updateSessionProgress(); } private alignCurrentPageToParity() { @@ -420,6 +431,7 @@ export class CbxReaderComponent implements OnInit { if (newPage !== this.currentPage) { this.currentPage = newPage; this.updateProgress(); + this.updateSessionProgress(); } break; } @@ -483,6 +495,16 @@ export class CbxReaderComponent implements OnInit { } } + private updateSessionProgress(): void { + const percentage = this.pages.length > 0 + ? Math.round(((this.currentPage + 1) / this.pages.length) * 1000) / 10 + : 0; + this.readingSessionService.updateProgress( + (this.currentPage + 1).toString(), + percentage + ); + } + goToPage(page: number): void { if (page < 1 || page > this.pages.length) return; @@ -495,9 +517,11 @@ export class CbxReaderComponent implements OnInit { this.ensurePageLoaded(targetIndex); this.scrollToPage(targetIndex); this.updateProgress(); + this.updateSessionProgress(); } else { this.alignCurrentPageToParity(); this.updateProgress(); + this.updateSessionProgress(); } } @@ -586,12 +610,14 @@ export class CbxReaderComponent implements OnInit { navigateToPreviousBook(): void { if (this.previousBookInSeries) { + this.endReadingSession(); this.router.navigate(['/cbx-reader/book', this.previousBookInSeries.id]); } } navigateToNextBook(): void { if (this.nextBookInSeries) { + this.endReadingSession(); this.router.navigate(['/cbx-reader/book', this.nextBookInSeries.id]); } } @@ -672,4 +698,20 @@ export class CbxReaderComponent implements OnInit { if (!this.nextBookInSeries) return 'No Next Book'; return `Next Book: ${this.getBookDisplayTitle(this.nextBookInSeries)}`; } + + ngOnDestroy(): void { + this.endReadingSession(); + } + + private endReadingSession(): void { + if (this.readingSessionService.isSessionActive()) { + const percentage = this.pages.length > 0 ? Math.round(((this.currentPage + 1) / this.pages.length) * 1000) / 10 : 0; + this.readingSessionService.endSession((this.currentPage + 1).toString(), percentage); + } + } + + closeReader(): void { + this.endReadingSession(); + this.location.back(); + } } diff --git a/booklore-ui/src/app/features/readers/epub-reader/component/epub-reader.component.html b/booklore-ui/src/app/features/readers/epub-reader/component/epub-reader.component.html index be1b2605..c6689108 100644 --- a/booklore-ui/src/app/features/readers/epub-reader/component/epub-reader.component.html +++ b/booklore-ui/src/app/features/readers/epub-reader/component/epub-reader.component.html @@ -284,6 +284,14 @@ + + diff --git a/booklore-ui/src/app/features/readers/epub-reader/component/epub-reader.component.ts b/booklore-ui/src/app/features/readers/epub-reader/component/epub-reader.component.ts index 43639939..83ae3c3f 100644 --- a/booklore-ui/src/app/features/readers/epub-reader/component/epub-reader.component.ts +++ b/booklore-ui/src/app/features/readers/epub-reader/component/epub-reader.component.ts @@ -4,7 +4,7 @@ import {Drawer} from 'primeng/drawer'; import {forkJoin, Subscription} from 'rxjs'; import {Button} from 'primeng/button'; import {InputText} from 'primeng/inputtext'; -import {CommonModule} from '@angular/common'; +import {CommonModule, Location} from '@angular/common'; import {FormsModule} from '@angular/forms'; import {ActivatedRoute} from '@angular/router'; import {Book, BookSetting} from '../../../book/model/book.model'; @@ -17,7 +17,8 @@ import {BookMark, BookMarkService, UpdateBookMarkRequest} from '../../../../shar import {Tooltip} from 'primeng/tooltip'; import {Slider} from 'primeng/slider'; import {FALLBACK_EPUB_SETTINGS, getChapter} from '../epub-reader-helper'; -import {EpubThemeUtil, EpubTheme} from '../epub-theme-util'; +import {ReadingSessionService} from '../../../../shared/service/reading-session.service'; +import {EpubTheme, EpubThemeUtil} from '../epub-theme-util'; import {PageTitleService} from "../../../../shared/service/page-title.service"; import {Tab, TabList, TabPanel, TabPanels, Tabs} from 'primeng/tabs'; import {IconField} from 'primeng/iconfield'; @@ -29,27 +30,7 @@ import {BookmarkViewDialogComponent} from './bookmark-view-dialog.component'; selector: 'app-epub-reader', templateUrl: './epub-reader.component.html', styleUrls: ['./epub-reader.component.scss'], - imports: [ - CommonModule, - FormsModule, - Drawer, - Button, - Select, - ProgressSpinner, - Tooltip, - Slider, - PrimeTemplate, - Tabs, - TabList, - Tab, - TabPanels, - TabPanel, - IconField, - InputIcon, - BookmarkEditDialogComponent, - BookmarkViewDialogComponent, - InputText - ], + imports: [CommonModule, FormsModule, Drawer, Button, Select, ProgressSpinner, Tooltip, Slider, PrimeTemplate, Tabs, TabList, Tab, TabPanels, TabPanel, IconField, InputIcon, BookmarkEditDialogComponent, BookmarkViewDialogComponent, InputText], standalone: true }) export class EpubReaderComponent implements OnInit, OnDestroy { @@ -67,10 +48,8 @@ export class EpubReaderComponent implements OnInit, OnDestroy { isAddingBookmark = false; isDeletingBookmark = false; isEditingBookmark = false; - isUpdatingPosition = false; private routeSubscription?: Subscription; - // Bookmark Filter & View filterText = ''; viewDialogVisible = false; selectedBookmark: BookMark | null = null; @@ -87,7 +66,6 @@ export class EpubReaderComponent implements OnInit, OnDestroy { private isMouseInTopRegion = false; private headerShownByMobileTouch = false; - // Properties for bookmark editing editingBookmark: BookMark | null = null; showEditBookmarkDialog = false; @@ -132,12 +110,14 @@ export class EpubReaderComponent implements OnInit, OnDestroy { ]; private route = inject(ActivatedRoute); + private location = inject(Location); private userService = inject(UserService); private bookService = inject(BookService); private messageService = inject(MessageService); private ngZone = inject(NgZone); private pageTitle = inject(PageTitleService); private bookMarkService = inject(BookMarkService); + private readingSessionService = inject(ReadingSessionService); epub!: Book; @@ -249,7 +229,6 @@ export class EpubReaderComponent implements OnInit, OnDestroy { get filteredBookmarks(): BookMark[] { let filtered = this.bookmarks; - // Filter if (this.filterText && this.filterText.trim()) { const lowerFilter = this.filterText.toLowerCase().trim(); filtered = filtered.filter(b => @@ -258,9 +237,8 @@ export class EpubReaderComponent implements OnInit, OnDestroy { ); } - // Sort: Priority ASC (1 is high), then CreatedAt DESC return [...filtered].sort((a, b) => { - const priorityA = a.priority ?? 3; // Default to 3 (Normal) if undefined + const priorityA = a.priority ?? 3; const priorityB = b.priority ?? 3; if (priorityA !== priorityB) { @@ -430,19 +408,37 @@ export class EpubReaderComponent implements OnInit, OnDestroy { prevPage(): void { if (this.rendition) { - this.rendition.prev(); + this.rendition.prev().then(() => { + const location = this.rendition.currentLocation(); + this.readingSessionService.updateProgress( + location?.start?.cfi, + this.progressPercentage + ); + }); } } nextPage(): void { if (this.rendition) { - this.rendition.next(); + this.rendition.next().then(() => { + const location = this.rendition.currentLocation(); + this.readingSessionService.updateProgress( + location?.start?.cfi, + this.progressPercentage + ); + }); } } navigateToChapter(chapter: { label: string; href: string; level: number }): void { if (this.book && chapter.href) { - this.book.rendition.display(chapter.href); + this.book.rendition.display(chapter.href).then(() => { + const location = this.rendition.currentLocation(); + this.readingSessionService.updateProgress( + location?.start?.cfi, + this.progressPercentage + ); + }); } } @@ -498,6 +494,11 @@ export class EpubReaderComponent implements OnInit, OnDestroy { } this.bookService.saveEpubProgress(this.epub.id, cfi, Math.round(percentage * 1000) / 10).subscribe(); + + this.readingSessionService.updateProgress( + location.start.cfi, + this.progressPercentage + ); }); this.book.ready.then(() => { @@ -509,13 +510,26 @@ export class EpubReaderComponent implements OnInit, OnDestroy { const cfi = location.end.cfi; const percentage = this.book.locations.percentageFromCfi(cfi); this.progressPercentage = Math.round(percentage * 1000) / 10; + + this.readingSessionService.startSession(this.epub.id, "EPUB", location.start.cfi, this.progressPercentage); } }).catch(() => { this.locationsReady = false; + const location = this.rendition.currentLocation(); + if (location) { + this.readingSessionService.startSession(this.epub.id, "EPUB", location.start.cfi, this.progressPercentage); + } }); } ngOnDestroy(): void { + if (this.readingSessionService.isSessionActive()) { + this.readingSessionService.endSession( + this.currentCfi || undefined, + this.progressPercentage + ); + } + this.routeSubscription?.unsubscribe(); if (this.rendition) { @@ -555,18 +569,15 @@ export class EpubReaderComponent implements OnInit, OnDestroy { const isBottomClick = clickY > screenHeight * 0.2; if (isTopClick && !this.showHeader) { - // Touch top 20% - show header and mark as shown by mobile touch this.showHeader = true; this.headerShownByMobileTouch = true; this.clearHeaderTimeout(); } else if (isBottomClick && this.showHeader && this.headerShownByMobileTouch) { - // Touch lower 80% - hide header this.showHeader = false; this.headerShownByMobileTouch = false; this.clearHeaderTimeout(); } } else { - // Desktop behavior const isTopClick = clickY < screenHeight * 0.1; if (isTopClick) { this.showHeader = true; @@ -615,7 +626,6 @@ export class EpubReaderComponent implements OnInit, OnDestroy { return; } - // Don't auto-hide on mobile if header was shown by touch if (this.isMobileDevice() && this.headerShownByMobileTouch) { return; } @@ -690,7 +700,6 @@ export class EpubReaderComponent implements OnInit, OnDestroy { this.bookMarkService.createBookmark(request).subscribe({ next: (bookmark) => { this.bookmarks.push(bookmark); - // Force array update for change detection if needed, but simple push works with getter usually if ref is stable this.bookmarks = [...this.bookmarks]; this.updateBookmarkStatus(); this.messageService.add({ @@ -715,9 +724,8 @@ export class EpubReaderComponent implements OnInit, OnDestroy { if (this.isDeletingBookmark) { return; } - // Simple confirmation using window.confirm for now, as consistent with UserManagementComponent behavior seen in linting if (!confirm('Are you sure you want to delete this bookmark?')) { - return; + return; } this.isDeletingBookmark = true; @@ -745,7 +753,13 @@ export class EpubReaderComponent implements OnInit, OnDestroy { navigateToBookmark(bookmark: BookMark): void { if (this.rendition && bookmark.cfi) { - this.rendition.display(bookmark.cfi); + this.rendition.display(bookmark.cfi).then(() => { + const location = this.rendition.currentLocation(); + this.readingSessionService.updateProgress( + location?.start?.cfi, + this.progressPercentage + ); + }); } } @@ -761,7 +775,7 @@ export class EpubReaderComponent implements OnInit, OnDestroy { } openEditBookmarkDialog(bookmark: BookMark): void { - this.editingBookmark = { ...bookmark }; + this.editingBookmark = {...bookmark}; this.showEditBookmarkDialog = true; } @@ -777,7 +791,7 @@ export class EpubReaderComponent implements OnInit, OnDestroy { const index = this.bookmarks.findIndex(b => b.id === this.editingBookmark!.id); if (index !== -1) { this.bookmarks[index] = updatedBookmark; - this.bookmarks = [...this.bookmarks]; // Trigger change detection for getter + this.bookmarks = [...this.bookmarks]; } this.messageService.add({ severity: 'success', @@ -785,7 +799,7 @@ export class EpubReaderComponent implements OnInit, OnDestroy { detail: 'Bookmark updated successfully', }); this.showEditBookmarkDialog = false; - this.editingBookmark = null; // Reset the editing bookmark after successful save + this.editingBookmark = null; this.isEditingBookmark = false; }, error: () => { @@ -795,7 +809,7 @@ export class EpubReaderComponent implements OnInit, OnDestroy { detail: 'Failed to update bookmark', }); this.showEditBookmarkDialog = false; - this.editingBookmark = null; // Reset the editing bookmark even on error + this.editingBookmark = null; this.isEditingBookmark = false; } }); @@ -803,41 +817,16 @@ export class EpubReaderComponent implements OnInit, OnDestroy { onBookmarkCancel(): void { this.showEditBookmarkDialog = false; - this.editingBookmark = null; // Reset the editing bookmark when dialog is cancelled + this.editingBookmark = null; } - updateBookmarkPosition(bookmarkId: number): void { - if (!this.currentCfi || this.isUpdatingPosition) { - return; + closeReader(): void { + if (this.readingSessionService.isSessionActive()) { + this.readingSessionService.endSession( + this.currentCfi || undefined, + this.progressPercentage + ); } - - this.isUpdatingPosition = true; - const updateRequest = { - cfi: this.currentCfi - }; - - this.bookMarkService.updateBookmark(bookmarkId, updateRequest).subscribe({ - next: (updatedBookmark) => { - const index = this.bookmarks.findIndex(b => b.id === bookmarkId); - if (index !== -1) { - this.bookmarks[index] = updatedBookmark; - this.bookmarks = [...this.bookmarks]; - } - this.messageService.add({ - severity: 'success', - summary: 'Success', - detail: 'Bookmark position updated successfully', - }); - this.isUpdatingPosition = false; - }, - error: () => { - this.messageService.add({ - severity: 'error', - summary: 'Error', - detail: 'Failed to update bookmark position', - }); - this.isUpdatingPosition = false; - } - }); + this.location.back(); } } diff --git a/booklore-ui/src/app/features/readers/pdf-reader/pdf-reader.component.html b/booklore-ui/src/app/features/readers/pdf-reader/pdf-reader.component.html index 6546beb4..8bc4f076 100644 --- a/booklore-ui/src/app/features/readers/pdf-reader/pdf-reader.component.html +++ b/booklore-ui/src/app/features/readers/pdf-reader/pdf-reader.component.html @@ -9,19 +9,54 @@ [src]="bookData" [textLayer]="true" [showHandToolButton]="true" - [handTool]="false" [height]="'auto'" [page]="page" [rotation]="rotation" - [sidebarVisible]="false" [zoom]="zoom" [spread]="spread" - [showBookModeButton]="false" - [showDownloadButton]="showDownloadButton" - [showPrintButton]="showPrintButton" + [customToolbar]="additionalButtons" (pagesLoaded)="onPdfPagesLoaded($event)" (pageChange)="onPageChange($event)" (zoomChange)="onZoomChange($event)" (spreadChange)="onSpreadChange($event)"> + + +
+
+ + + +
+ +
+ + + + + + + + + + + + + + + +
+ +
+
+
} + diff --git a/booklore-ui/src/app/features/readers/pdf-reader/pdf-reader.component.ts b/booklore-ui/src/app/features/readers/pdf-reader/pdf-reader.component.ts index 1ee335da..f15944ac 100644 --- a/booklore-ui/src/app/features/readers/pdf-reader/pdf-reader.component.ts +++ b/booklore-ui/src/app/features/readers/pdf-reader/pdf-reader.component.ts @@ -9,6 +9,8 @@ import {UserService} from '../../settings/user-management/user.service'; import {ProgressSpinner} from 'primeng/progressspinner'; import {MessageService} from 'primeng/api'; +import {ReadingSessionService} from '../../../shared/service/reading-session.service'; +import {Location} from '@angular/common'; @Component({ selector: 'app-pdf-reader', @@ -26,9 +28,6 @@ export class PdfReaderComponent implements OnInit, OnDestroy { spread!: 'off' | 'even' | 'odd'; zoom!: ZoomType; - showDownloadButton = false; - showPrintButton = false; - bookData!: string | Blob; bookId!: number; private appSettingsSubscription!: Subscription; @@ -38,6 +37,8 @@ export class PdfReaderComponent implements OnInit, OnDestroy { private messageService = inject(MessageService); private route = inject(ActivatedRoute); private pageTitle = inject(PageTitleService); + private readingSessionService = inject(ReadingSessionService); + private location = inject(Location); ngOnInit(): void { this.route.paramMap.subscribe((params) => { @@ -58,9 +59,6 @@ export class PdfReaderComponent implements OnInit, OnDestroy { this.pageTitle.setBookPageTitle(pdfMeta); - this.showDownloadButton = myself.permissions.canDownload || myself.permissions.admin; - this.showPrintButton = myself.permissions.canDownload || myself.permissions.admin; - const globalOrIndividual = myself.userSettings.perBookSetting.pdf; if (globalOrIndividual === 'Global') { this.zoom = myself.userSettings.pdfReaderSetting.pageZoom || 'page-fit'; @@ -85,6 +83,8 @@ export class PdfReaderComponent implements OnInit, OnDestroy { if (page !== this.page) { this.page = page; this.updateProgress(); + const percentage = this.totalPages > 0 ? Math.round((this.page / this.totalPages) * 1000) / 10 : 0; + this.readingSessionService.updateProgress(this.page.toString(), percentage); } } @@ -113,21 +113,34 @@ export class PdfReaderComponent implements OnInit, OnDestroy { } updateProgress(): void { - const percentage = this.totalPages > 0 - ? Math.round((this.page / this.totalPages) * 1000) / 10 - : 0; - + const percentage = this.totalPages > 0 ? Math.round((this.page / this.totalPages) * 1000) / 10 : 0; this.bookService.savePdfProgress(this.bookId, this.page, percentage).subscribe(); } onPdfPagesLoaded(event: any): void { this.totalPages = event.pagesCount; + const percentage = this.totalPages > 0 ? Math.round((this.page / this.totalPages) * 1000) / 10 : 0; + this.readingSessionService.startSession(this.bookId, "PDF", this.page.toString(), percentage); + this.readingSessionService.updateProgress(this.page.toString(), percentage); } ngOnDestroy(): void { + if (this.readingSessionService.isSessionActive()) { + const percentage = this.totalPages > 0 ? Math.round((this.page / this.totalPages) * 1000) / 10 : 0; + this.readingSessionService.endSession(this.page.toString(), percentage); + } + if (this.appSettingsSubscription) { this.appSettingsSubscription.unsubscribe(); } this.updateProgress(); } + + closeReader = (): void => { + if (this.readingSessionService.isSessionActive()) { + const percentage = this.totalPages > 0 ? Math.round((this.page / this.totalPages) * 1000) / 10 : 0; + this.readingSessionService.endSession(this.page.toString(), percentage); + } + this.location.back(); + } } 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 new file mode 100644 index 00000000..addbbc9e --- /dev/null +++ b/booklore-ui/src/app/features/settings/user-management/user-stats.service.ts @@ -0,0 +1,40 @@ +import {inject, Injectable} from '@angular/core'; +import {HttpClient} from '@angular/common/http'; +import {Observable} from 'rxjs'; +import {API_CONFIG} from '../../../core/config/api-config'; +import {BookType} from '../../book/model/book.model'; + +export interface ReadingSessionHeatmapResponse { + date: string; + count: number; +} + +export interface ReadingSessionTimelineResponse { + bookId: number; + bookTitle: string; + startDate: string; + bookType: BookType + endDate: string; + totalSessions: number; + totalDurationSeconds: number; +} + +@Injectable({ + providedIn: 'root' +}) +export class UserStatsService { + private readonly readingSessionsUrl = `${API_CONFIG.BASE_URL}/api/v1/reading-sessions`; + private http = inject(HttpClient); + + getHeatmapForYear(year: number): Observable { + return this.http.get( + `${this.readingSessionsUrl}/heatmap/year/${year}` + ); + } + + getTimelineForWeek(year: number, week: number): Observable { + return this.http.get( + `${this.readingSessionsUrl}/timeline/week/${year}/${week}` + ); + } +} diff --git a/booklore-ui/src/app/features/stats/component/reading-session-heatmap/reading-session-heatmap.component.html b/booklore-ui/src/app/features/stats/component/reading-session-heatmap/reading-session-heatmap.component.html new file mode 100644 index 00000000..aec24e1c --- /dev/null +++ b/booklore-ui/src/app/features/stats/component/reading-session-heatmap/reading-session-heatmap.component.html @@ -0,0 +1,30 @@ +
+
+
+

Reading Session Activity

+

Daily reading session activity throughout the year

+
+
+ + {{ currentYear }} + +
+
+
+ + +
+
diff --git a/booklore-ui/src/app/features/stats/component/reading-session-heatmap/reading-session-heatmap.component.scss b/booklore-ui/src/app/features/stats/component/reading-session-heatmap/reading-session-heatmap.component.scss new file mode 100644 index 00000000..3244bcc0 --- /dev/null +++ b/booklore-ui/src/app/features/stats/component/reading-session-heatmap/reading-session-heatmap.component.scss @@ -0,0 +1,120 @@ +.reading-session-heatmap-container { + width: 100%; +} + +.chart-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 0.5rem; + gap: 1rem; + + .chart-title { + flex: 1; + + h3 { + color: var(--text-color, #ffffff); + font-size: 1.25rem; + font-weight: 500; + margin: 0 0 0.5rem 0; + } + + .chart-description { + color: var(--text-secondary-color); + font-size: 0.9rem; + margin: 0; + line-height: 1.4; + } + } +} + +.year-selector { + display: flex; + align-items: center; + gap: 1rem; + flex-shrink: 0; + + .year-nav-btn { + background: rgba(255, 255, 255, 0.1); + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 6px; + padding: 0.5rem 0.75rem; + color: #ffffff; + cursor: pointer; + transition: all 0.3s ease; + display: flex; + align-items: center; + justify-content: center; + + &:hover { + background: rgba(255, 255, 255, 0.2); + border-color: rgba(255, 255, 255, 0.3); + transform: translateY(-1px); + } + + &:active { + transform: translateY(0); + } + + i { + font-size: 0.875rem; + } + } + + .current-year { + font-size: 1.125rem; + font-weight: 600; + color: #ffffff; + min-width: 4.5rem; + text-align: center; + } +} + +.chart-wrapper { + position: relative; + width: 100%; + min-height: 200px; + height: 200px; + + canvas { + width: 100% !important; + height: auto !important; + } +} + +@media (max-width: 768px) { + .chart-header { + flex-direction: column; + align-items: stretch; + + .year-selector { + justify-content: center; + margin-top: 0.5rem; + } + } +} + +@media (max-width: 480px) { + .chart-header { + .chart-title { + h3 { + font-size: 1.1rem; + } + + .chart-description { + font-size: 0.85rem; + } + } + } + + .year-selector { + .current-year { + font-size: 1rem; + min-width: 4rem; + } + + .year-nav-btn { + padding: 0.4rem 0.6rem; + } + } +} diff --git a/booklore-ui/src/app/features/stats/component/reading-session-heatmap/reading-session-heatmap.component.ts b/booklore-ui/src/app/features/stats/component/reading-session-heatmap/reading-session-heatmap.component.ts new file mode 100644 index 00000000..760019a8 --- /dev/null +++ b/booklore-ui/src/app/features/stats/component/reading-session-heatmap/reading-session-heatmap.component.ts @@ -0,0 +1,228 @@ +import {Component, inject, Input, OnDestroy, OnInit} from '@angular/core'; +import {CommonModule} from '@angular/common'; +import {BaseChartDirective} from 'ng2-charts'; +import {Chart, ChartConfiguration, ChartData, registerables} from 'chart.js'; +import {MatrixController, MatrixElement} from 'chartjs-chart-matrix'; +import {BehaviorSubject, EMPTY, Observable, Subject} from 'rxjs'; +import {catchError, takeUntil} from 'rxjs/operators'; +import {ReadingSessionHeatmapResponse, UserStatsService} from '../../../settings/user-management/user-stats.service'; + +const DAY_NAMES = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; +const MONTH_NAMES = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; + +interface MatrixDataPoint { + x: number; + y: number; + v: number; + date: string; +} + +type SessionHeatmapChartData = ChartData<'matrix', MatrixDataPoint[], string>; + +@Component({ + selector: 'app-reading-session-heatmap', + standalone: true, + imports: [CommonModule, BaseChartDirective], + templateUrl: './reading-session-heatmap.component.html', + styleUrls: ['./reading-session-heatmap.component.scss'] +}) +export class ReadingSessionHeatmapComponent implements OnInit, OnDestroy { + @Input() initialYear: number = new Date().getFullYear(); + + public currentYear: number = new Date().getFullYear(); + public readonly chartType = 'matrix' as const; + public readonly chartData$: Observable; + public readonly chartOptions: ChartConfiguration['options']; + + private readonly userStatsService = inject(UserStatsService); + private readonly destroy$ = new Subject(); + private readonly chartDataSubject: BehaviorSubject; + private maxSessionCount = 1; + + constructor() { + this.chartDataSubject = new BehaviorSubject({ + labels: [], + datasets: [{label: 'Reading Sessions', data: []}] + }); + this.chartData$ = this.chartDataSubject.asObservable(); + + this.chartOptions = { + responsive: true, + maintainAspectRatio: false, + layout: { + padding: {top: 20, bottom: 20, left: 10, right: 10} + }, + plugins: { + legend: {display: false}, + tooltip: { + enabled: true, + backgroundColor: 'rgba(0, 0, 0, 0.9)', + titleColor: '#ffffff', + bodyColor: '#ffffff', + borderColor: '#ffffff', + borderWidth: 1, + cornerRadius: 6, + displayColors: false, + padding: 12, + titleFont: {size: 14, weight: 'bold'}, + bodyFont: {size: 13}, + callbacks: { + title: (context) => { + const point = context[0].raw as MatrixDataPoint; + const date = new Date(point.date); + return date.toLocaleDateString('en-US', { + weekday: 'short', + year: 'numeric', + month: 'short', + day: 'numeric' + }); + }, + label: (context) => { + const point = context.raw as MatrixDataPoint; + return `${point.v} reading session${point.v === 1 ? '' : 's'}`; + } + } + }, + datalabels: {display: false} + }, + scales: { + x: { + type: 'linear', + position: 'top', + min: 0, + max: 52, + ticks: { + stepSize: 4, + callback: (value) => { + const weekNum = value as number; + if (weekNum % 4 === 0) { + const date = this.getDateFromWeek(this.currentYear, weekNum); + return MONTH_NAMES[date.getMonth()]; + } + return ''; + }, + color: '#ffffff', + font: {family: "'Inter', sans-serif", size: 11} + }, + grid: {display: false}, + border: {display: false} + }, + y: { + type: 'linear', + min: 0, + max: 6, + ticks: { + stepSize: 1, + callback: (value) => { + const dayIndex = value as number; + return dayIndex >= 0 && dayIndex <= 6 ? DAY_NAMES[dayIndex] : ''; + }, + color: '#ffffff', + font: {family: "'Inter', sans-serif", size: 11} + }, + border: {display: false} + } + } + }; + } + + ngOnInit(): void { + Chart.register(...registerables, MatrixController, MatrixElement); + this.currentYear = this.initialYear; + this.loadYearData(this.currentYear); + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + + public changeYear(delta: number): void { + this.currentYear += delta; + this.loadYearData(this.currentYear); + } + + private loadYearData(year: number): void { + this.userStatsService.getHeatmapForYear(year) + .pipe( + takeUntil(this.destroy$), + catchError((error) => { + console.error('Error loading reading session heatmap:', error); + return EMPTY; + }) + ) + .subscribe((data) => { + this.updateChartData(data); + }); + } + + private updateChartData(sessionData: ReadingSessionHeatmapResponse[]): void { + const sessionMap = new Map(); + sessionData.forEach(item => { + sessionMap.set(item.date, item.count); + }); + + this.maxSessionCount = Math.max(1, ...sessionData.map(d => d.count)); + + const heatmapData: MatrixDataPoint[] = []; + const startDate = new Date(this.currentYear, 0, 1); + const endDate = new Date(this.currentYear, 11, 31); + + const firstMonday = new Date(startDate); + const dayOfWeek = firstMonday.getDay(); + const daysToMonday = dayOfWeek === 0 ? 6 : dayOfWeek - 1; + firstMonday.setDate(firstMonday.getDate() - daysToMonday); + + let weekIndex = 0; + let currentDate = new Date(firstMonday); + + while (currentDate <= endDate || weekIndex === 0) { + for (let dayOfWeek = 0; dayOfWeek < 7; dayOfWeek++) { + const dateStr = currentDate.toISOString().split('T')[0]; + + if (currentDate >= startDate && currentDate <= endDate) { + const count = sessionMap.get(dateStr) || 0; + + heatmapData.push({ + x: weekIndex, + y: dayOfWeek, + v: count, + date: dateStr + }); + } + + currentDate.setDate(currentDate.getDate() + 1); + } + + weekIndex++; + + if (currentDate > endDate) { + break; + } + } + + this.chartDataSubject.next({ + labels: [], + datasets: [{ + label: 'Reading Sessions', + data: heatmapData, + backgroundColor: (context) => { + const point = context.raw as MatrixDataPoint; + if (!point?.v) return 'rgba(255, 255, 255, 0.05)'; + + const intensity = point.v / this.maxSessionCount; + const alpha = Math.max(0.2, Math.min(1.0, intensity * 0.8 + 0.2)); + return `rgba(106, 176, 76, ${alpha})`; + }, + borderColor: 'rgba(255, 255, 255, 0.1)', + borderWidth: 1 + }] + }); + } + + private getDateFromWeek(year: number, week: number): Date { + const date = new Date(year, 0, 1); + date.setDate(date.getDate() + (week * 7) - date.getDay()); + return date; + } +} diff --git a/booklore-ui/src/app/features/stats/component/reading-session-timeline/reading-session-timeline.component.html b/booklore-ui/src/app/features/stats/component/reading-session-timeline/reading-session-timeline.component.html new file mode 100644 index 00000000..f9ecea9c --- /dev/null +++ b/booklore-ui/src/app/features/stats/component/reading-session-timeline/reading-session-timeline.component.html @@ -0,0 +1,104 @@ +
+
+
+

Reading Session Timeline

+

Your reading schedule throughout the week

+
+
+ +
+ Week {{ currentWeek }} + {{ getWeekDateRange() }} +
+ +
+
+ +
+
+
+
+ @for (hour of hourLabels; track $index) { +
+ {{ hour }} +
+ } +
+
+ +
+ @for (dayData of timelineData; track dayData.dayOfWeek) { +
+
{{ dayData.day }}
+
+
+ @for (hour of hourLabels; track $index) { +
+ } +
+ +
+ @for (session of dayData.sessions; track $index) { +
+
+ + {{ formatTime(session.startHour, session.startMinute) }} + + + {{ formatDuration(session.duration) }} + +
+ +
+
+
+ Book Cover +
+
+
+ + {{ session.bookTitle || 'Reading Session' }} +
+
+
+
+ + Time: + + {{ formatTime(session.startHour, session.startMinute) }} - + {{ formatTime(session.endHour, session.endMinute) }} + +
+
+ + Duration: + {{ formatDuration(session.duration) }} +
+
+
+
+
+
+ } +
+
+
+ } +
+
+
diff --git a/booklore-ui/src/app/features/stats/component/reading-session-timeline/reading-session-timeline.component.scss b/booklore-ui/src/app/features/stats/component/reading-session-timeline/reading-session-timeline.component.scss new file mode 100644 index 00000000..be286b4c --- /dev/null +++ b/booklore-ui/src/app/features/stats/component/reading-session-timeline/reading-session-timeline.component.scss @@ -0,0 +1,483 @@ +.timeline-container { + width: 100%; +} + +.timeline-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 0.5rem; + gap: 1rem; + + .header-title { + flex: 1; + + h3 { + color: var(--text-color, #ffffff); + font-size: 1.25rem; + font-weight: 500; + margin: 0 0 0.5rem 0; + } + + .timeline-subtitle { + color: var(--text-secondary-color); + font-size: 0.9rem; + margin: 0; + line-height: 1.4; + } + } + + .week-selector { + display: flex; + align-items: center; + gap: 1rem; + flex-shrink: 0; + + .week-nav-btn { + background: rgba(255, 255, 255, 0.1); + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 6px; + padding: 0.5rem 0.75rem; + color: #ffffff; + cursor: pointer; + transition: all 0.3s ease; + display: flex; + align-items: center; + justify-content: center; + + &:hover { + background: rgba(255, 255, 255, 0.2); + border-color: rgba(255, 255, 255, 0.3); + transform: translateY(-1px); + } + + &:active { + transform: translateY(0); + } + + i { + font-size: 0.875rem; + } + } + + .week-info { + display: flex; + flex-direction: column; + align-items: center; + min-width: 100px; + + .week-label { + font-size: 1.125rem; + font-weight: 600; + color: #ffffff; + white-space: nowrap; + } + + .week-dates { + font-size: 0.8rem; + color: var(--text-secondary-color); + white-space: nowrap; + } + } + } +} + +.timeline-content { + position: relative; + width: 100%; + padding: 1rem; +} + +.hour-markers { + display: flex; + margin-bottom: 0.5rem; + + .day-label-spacer { + width: 100px; + flex-shrink: 0; + } + + .hour-grid { + flex: 1; + display: grid; + grid-template-columns: repeat(24, 1fr); + position: relative; + } + + .hour-marker { + position: relative; + height: 20px; + + &.major { + .hour-label { + display: block; + } + } + } + + .hour-label { + display: none; + font-size: 0.7rem; + color: rgba(255, 255, 255, 0.6); + position: absolute; + left: 0; + transform: translateX(-50%); + white-space: nowrap; + } +} + +.timeline-rows { + display: flex; + flex-direction: column; + gap: 8px; +} + +.timeline-row { + display: flex; + align-items: stretch; + + .day-label { + width: 50px; + flex-shrink: 0; + display: flex; + align-items: center; + font-size: 0.875rem; + font-weight: 500; + color: rgba(255, 255, 255, 0.9); + padding-right: 1rem; + } + + .timeline-track { + flex: 1; + position: relative; + background: rgba(255, 255, 255, 0.02); + border-radius: 4px; + border: 1px solid rgba(255, 255, 255, 0.1); + min-height: 40px; + } +} + +.grid-lines { + position: absolute; + inset: 0; + display: grid; + grid-template-columns: repeat(24, 1fr); + pointer-events: none; + + .grid-line { + border-right: 1px solid rgba(255, 255, 255, 0.05); + + &:last-child { + border-right: none; + } + } +} + +.sessions { + position: absolute; + inset: 4px; + pointer-events: none; + + .session-block { + position: absolute; + border-radius: 4px; + pointer-events: auto; + cursor: pointer; + transition: all 0.2s ease; + overflow: visible; + box-sizing: border-box; + + &.book-type-epub, &.book-type-default { + background: linear-gradient(135deg, #4a90e2 0%, #357abd 100%); + box-shadow: 0 2px 8px rgba(74, 144, 226, 0.3); + + &:hover { + box-shadow: 0 4px 16px rgba(74, 144, 226, 0.5); + } + } + + &.book-type-pdf { + background: linear-gradient(135deg, #e24a4a 0%, #bd3535 100%); + box-shadow: 0 2px 8px rgba(226, 74, 74, 0.3); + + &:hover { + box-shadow: 0 4px 16px rgba(226, 74, 74, 0.5); + } + } + + &.book-type-cbx { + background: linear-gradient(135deg, #e2b74a 0%, #bd9635 100%); + box-shadow: 0 2px 8px rgba(226, 183, 74, 0.3); + + &:hover { + box-shadow: 0 4px 16px rgba(226, 183, 74, 0.5); + } + } + + &:hover { + transform: translateY(-2px); + z-index: 10; + + .session-tooltip { + opacity: 1; + transform: translate(-50%, 0); + pointer-events: auto; + } + } + + .session-content { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + height: 100%; + padding: 4px 8px; + color: white; + font-size: 0.7rem; + font-weight: 500; + text-align: center; + gap: 2px; + overflow: hidden; + + .session-time { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 100%; + line-height: 1; + } + + .session-duration { + font-size: 0.65rem; + opacity: 0.9; + white-space: nowrap; + line-height: 1; + } + } + } +} + +.session-tooltip { + position: absolute; + bottom: calc(100% + 15px); + left: 50%; + transform: translate(-50%, 10px); + background: rgba(30, 30, 30, 0.98); + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 8px; + padding: 0; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5); + z-index: 1000; + pointer-events: none; + min-width: 320px; + max-width: 350px; + backdrop-filter: blur(10px); + opacity: 0; + transition: opacity 0.2s ease, transform 0.2s ease; + + .tooltip-content { + display: flex; + gap: 1rem; + padding: 1rem; + } + + .tooltip-cover { + flex-shrink: 0; + width: 80px; + height: 120px; + + img { + width: 100%; + height: 100%; + object-fit: cover; + border-radius: 4px; + background-color: rgba(255, 255, 255, 0.1); + } + } + + .tooltip-details { + flex: 1; + display: flex; + flex-direction: column; + min-width: 0; + } + + .tooltip-header { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0; + background: none; + + i { + font-size: 1.1rem; + color: #4a90e2; + } + + .tooltip-title { + color: #ffffff; + font-weight: 600; + font-size: 0.95rem; + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + } + + .tooltip-divider { + height: 1px; + background: linear-gradient(90deg, + transparent 0%, + rgba(255, 255, 255, 0.1) 50%, + transparent 100%); + margin: 0.75rem 0; + } + + .tooltip-body { + padding: 0; + display: flex; + flex-direction: column; + gap: 0.625rem; + } + + .tooltip-row { + display: grid; + grid-template-columns: auto auto 1fr; + align-items: center; + gap: 0.625rem; + font-size: 0.875rem; + + i { + font-size: 0.875rem; + color: rgba(255, 255, 255, 0.6); + width: 16px; + text-align: center; + } + + .tooltip-label { + color: rgba(255, 255, 255, 0.7); + font-weight: 500; + } + + .tooltip-value { + color: #ffffff; + text-align: right; + font-weight: 500; + } + } + + &::after { + content: ''; + position: absolute; + top: 100%; + left: 50%; + transform: translateX(-50%); + width: 0; + height: 0; + border-left: 8px solid transparent; + border-right: 8px solid transparent; + border-top: 8px solid rgba(30, 30, 30, 0.98); + filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.2)); + } +} + +@media (max-width: 768px) { + .timeline-header { + flex-direction: column; + align-items: stretch; + + .week-selector { + justify-content: center; + margin-top: 0.5rem; + + .week-info { + min-width: 120px; + } + } + } + + .timeline-content { + min-height: 300px; + padding: 0.75rem; + } + + .timeline-row { + .day-label { + width: 70px; + font-size: 0.75rem; + padding-right: 0.5rem; + } + } + + .hour-markers { + .day-label-spacer { + width: 70px; + } + } + + .hour-marker { + .hour-label { + font-size: 0.6rem; + } + } + + .session-block { + .session-content { + font-size: 0.65rem; + + .session-duration { + display: none; + } + } + } + + .session-tooltip { + min-width: 240px; + max-width: 280px; + font-size: 0.85rem; + + .tooltip-header { + padding: 0.75rem; + + .tooltip-title { + font-size: 0.875rem; + } + } + + .tooltip-body { + padding: 0.75rem; + } + + .tooltip-row { + font-size: 0.8rem; + gap: 0.5rem; + } + } +} + +@media (max-width: 480px) { + .timeline-header { + .header-title { + h3 { + font-size: 1.1rem; + } + + .timeline-subtitle { + font-size: 0.85rem; + } + } + } + + .week-selector { + .week-info { + .week-label { + font-size: 1rem; + } + } + + .week-nav-btn { + padding: 0.4rem 0.6rem; + } + } +} diff --git a/booklore-ui/src/app/features/stats/component/reading-session-timeline/reading-session-timeline.component.ts b/booklore-ui/src/app/features/stats/component/reading-session-timeline/reading-session-timeline.component.ts new file mode 100644 index 00000000..eb57de71 --- /dev/null +++ b/booklore-ui/src/app/features/stats/component/reading-session-timeline/reading-session-timeline.component.ts @@ -0,0 +1,305 @@ +import {Component, inject, Input, OnInit} from '@angular/core'; +import {CommonModule} from '@angular/common'; +import {UserStatsService, ReadingSessionTimelineResponse} from '../../../settings/user-management/user-stats.service'; +import {UrlHelperService} from '../../../../shared/service/url-helper.service'; +import {BookType} from '../../../book/model/book.model'; + +interface ReadingSession { + startTime: Date; + endTime: Date; + duration: number; + bookTitle?: string; + bookId: number; + bookType: BookType; +} + +interface TimelineSession { + startHour: number; + startMinute: number; + endHour: number; + endMinute: number; + duration: number; + left: number; + width: number; + bookTitle?: string; + bookId: number; + bookType: BookType; + level: number; + totalLevels: number; +} + +interface DayTimeline { + day: string; + dayOfWeek: number; + sessions: TimelineSession[]; +} + +@Component({ + selector: 'app-reading-session-timeline', + standalone: true, + imports: [CommonModule], + templateUrl: './reading-session-timeline.component.html', + styleUrls: ['./reading-session-timeline.component.scss'] +}) +export class ReadingSessionTimelineComponent implements OnInit { + @Input() initialYear: number = new Date().getFullYear(); + @Input() weekNumber: number = this.getCurrentWeekNumber(); + + private userStatsService = inject(UserStatsService); + private urlHelperService = inject(UrlHelperService); + + public daysOfWeek = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; + public hourLabels: string[] = []; + public timelineData: DayTimeline[] = []; + public currentYear: number = new Date().getFullYear(); + public currentWeek: number = this.getCurrentWeekNumber(); + + ngOnInit(): void { + this.currentYear = this.initialYear; + this.currentWeek = this.weekNumber; + this.initializeHourLabels(); + this.loadReadingSessions(); + } + + private initializeHourLabels(): void { + for (let i = 0; i < 24; i++) { + const hour = i === 0 ? 12 : i > 12 ? i - 12 : i; + const period = i < 12 ? 'AM' : 'PM'; + this.hourLabels.push(`${hour} ${period}`); + } + } + + private loadReadingSessions(): void { + this.userStatsService.getTimelineForWeek(this.currentYear, this.currentWeek) + .subscribe({ + next: (response) => { + const sessions = this.convertResponseToSessions(response); + this.processSessionData(sessions); + }, + error: (error) => { + console.error('Error loading reading sessions:', error); + this.processSessionData([]); + } + }); + } + + private convertResponseToSessions(response: ReadingSessionTimelineResponse[]): ReadingSession[] { + const sessions: ReadingSession[] = []; + + response.forEach((item) => { + const startTime = new Date(item.startDate); + const endTime = new Date(item.endDate); + const duration = Math.floor((endTime.getTime() - startTime.getTime()) / (1000 * 60)); + + sessions.push({ + startTime, + endTime, + duration, + bookId: item.bookId, + bookTitle: item.bookTitle, + bookType: item.bookType + }); + }); + + return sessions.sort((a, b) => a.startTime.getTime() - b.startTime.getTime()); + } + + private getCurrentWeekNumber(): number { + const now = new Date(); + const startOfYear = new Date(now.getFullYear(), 0, 1); + const days = Math.floor((now.getTime() - startOfYear.getTime()) / (24 * 60 * 60 * 1000)); + return Math.ceil((days + startOfYear.getDay() + 1) / 7); + } + + public changeWeek(delta: number): void { + this.currentWeek += delta; + + const weeksInYear = this.getWeeksInYear(this.currentYear); + if (this.currentWeek > weeksInYear) { + this.currentWeek = 1; + this.currentYear++; + } else if (this.currentWeek < 1) { + this.currentYear--; + this.currentWeek = this.getWeeksInYear(this.currentYear); + } + + this.loadReadingSessions(); + } + + private getWeeksInYear(year: number): number { + const lastDay = new Date(year, 11, 31); + const startOfYear = new Date(year, 0, 1); + const days = Math.floor((lastDay.getTime() - startOfYear.getTime()) / (24 * 60 * 60 * 1000)); + return Math.ceil((days + startOfYear.getDay() + 1) / 7); + } + + public getWeekDateRange(): string { + const startOfYear = new Date(this.currentYear, 0, 1); + const daysToAdd = (this.currentWeek - 1) * 7; + const weekStart = new Date(startOfYear.getTime() + daysToAdd * 24 * 60 * 60 * 1000); + + const dayOfWeek = weekStart.getDay(); + weekStart.setDate(weekStart.getDate() - dayOfWeek); + + const weekEnd = new Date(weekStart); + weekEnd.setDate(weekStart.getDate() + 6); + + const formatDate = (date: Date) => { + const month = date.toLocaleDateString('en-US', {month: 'short'}); + const day = date.getDate(); + return `${month} ${day}`; + }; + + return `${formatDate(weekStart)} - ${formatDate(weekEnd)}`; + } + + private processSessionData(sessions: ReadingSession[]): void { + const dayMap = new Map(); + + sessions.forEach(session => { + const sessionStart = new Date(session.startTime); + const sessionEnd = new Date(session.endTime); + + if (sessionStart.getDate() === sessionEnd.getDate()) { + const dayOfWeek = sessionStart.getDay(); + if (!dayMap.has(dayOfWeek)) { + dayMap.set(dayOfWeek, []); + } + dayMap.get(dayOfWeek)!.push(session); + } else { + let currentStart = new Date(sessionStart); + + while (currentStart < sessionEnd) { + const dayOfWeek = currentStart.getDay(); + const endOfDay = new Date(currentStart); + endOfDay.setHours(23, 59, 59, 999); + + const segmentEnd = sessionEnd < endOfDay ? sessionEnd : endOfDay; + const segmentDuration = Math.floor((segmentEnd.getTime() - currentStart.getTime()) / (1000 * 60)); + + if (!dayMap.has(dayOfWeek)) { + dayMap.set(dayOfWeek, []); + } + + dayMap.get(dayOfWeek)!.push({ + startTime: new Date(currentStart), + endTime: new Date(segmentEnd), + duration: segmentDuration, + bookTitle: session.bookTitle, + bookId: session.bookId, + bookType: session.bookType + }); + + currentStart = new Date(segmentEnd); + currentStart.setDate(currentStart.getDate() + 1); + currentStart.setHours(0, 0, 0, 0); + } + } + }); + + this.timelineData = []; + for (let i = 0; i < 7; i++) { + const sessionsForDay = dayMap.get(i) || []; + const timelineSessions = this.layoutSessionsForDay(sessionsForDay); + + this.timelineData.push({ + day: this.daysOfWeek[i], + dayOfWeek: i, + sessions: timelineSessions + }); + } + } + + private layoutSessionsForDay(sessions: ReadingSession[]): TimelineSession[] { + if (sessions.length === 0) { + return []; + } + + sessions.sort((a, b) => { + if (a.startTime.getTime() !== b.startTime.getTime()) { + return a.startTime.getTime() - b.startTime.getTime(); + } + return b.endTime.getTime() - a.endTime.getTime(); + }); + + const tracks: ReadingSession[][] = []; + + sessions.forEach(session => { + let placed = false; + for (let i = 0; i < tracks.length; i++) { + const lastSessionInTrack = tracks[i][tracks[i].length - 1]; + if (session.startTime >= lastSessionInTrack.endTime) { + tracks[i].push(session); + placed = true; + break; + } + } + if (!placed) { + tracks.push([session]); + } + }); + + const totalLevels = tracks.length; + const timelineSessions: TimelineSession[] = []; + + tracks.forEach((track, level) => { + track.forEach(session => { + timelineSessions.push(this.convertToTimelineSession(session, level, totalLevels)); + }); + }); + + return timelineSessions; + } + + private convertToTimelineSession(session: ReadingSession, level: number, totalLevels: number): TimelineSession { + const startHour = session.startTime.getHours(); + const startMinute = session.startTime.getMinutes(); + const endHour = session.endTime.getHours(); + const endMinute = session.endTime.getMinutes(); + + const startDecimal = startHour + startMinute / 60; + const endDecimal = endHour + endMinute / 60; + + const left = (startDecimal / 24) * 100; + let width = ((endDecimal - startDecimal) / 24) * 100; + + if (width < 0.5) { + width = 0.5; + } + + return { + startHour, + startMinute, + endHour, + endMinute, + duration: session.duration, + left, + width, + bookTitle: session.bookTitle, + bookId: session.bookId, + bookType: session.bookType, + level, + totalLevels + }; + } + + public formatTime(hour: number, minute: number): string { + const displayHour = hour === 0 ? 12 : hour > 12 ? hour - 12 : hour; + const period = hour < 12 ? 'AM' : 'PM'; + const displayMinute = minute.toString().padStart(2, '0'); + return `${displayHour}:${displayMinute} ${period}`; + } + + public formatDuration(minutes: number): string { + const hours = Math.floor(minutes / 60); + const mins = minutes % 60; + if (hours > 0) { + return mins > 0 ? `${hours}h ${mins}m` : `${hours}h`; + } + return `${mins}m`; + } + + public getCoverUrl(bookId: number): string { + return this.urlHelperService.getThumbnailUrl1(bookId); + } +} diff --git a/booklore-ui/src/app/features/stats/component/stats-component.html b/booklore-ui/src/app/features/stats/component/stats-component.html index 81626f07..0f419739 100644 --- a/booklore-ui/src/app/features/stats/component/stats-component.html +++ b/booklore-ui/src/app/features/stats/component/stats-component.html @@ -392,16 +392,6 @@ } } - @case ('readingHeatmap') { -

Books Finished per Month

-
- - -
- } } } diff --git a/booklore-ui/src/app/features/stats/component/stats-component.scss b/booklore-ui/src/app/features/stats/component/stats-component.scss index e1a6651e..0e8deb82 100644 --- a/booklore-ui/src/app/features/stats/component/stats-component.scss +++ b/booklore-ui/src/app/features/stats/component/stats-component.scss @@ -711,6 +711,40 @@ } } +.year-selector { + display: flex; + align-items: center; + justify-content: center; + gap: 1rem; + + .year-nav-btn { + background: rgba(255, 255, 255, 0.1); + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 4px; + padding: 0.5rem 0.75rem; + color: #ffffff; + cursor: pointer; + transition: all 0.3s ease; + + &:hover { + background: rgba(255, 255, 255, 0.2); + border-color: rgba(255, 255, 255, 0.3); + } + + i { + font-size: 0.875rem; + } + } + + .current-year { + font-size: 1.125rem; + font-weight: 600; + color: #ffffff; + min-width: 4rem; + text-align: center; + } +} + @media (max-width: 1600px) { .charts-grid { grid-template-columns: repeat(3, 1fr); diff --git a/booklore-ui/src/app/features/stats/component/stats-component.ts b/booklore-ui/src/app/features/stats/component/stats-component.ts index 7ab03a49..88a1b0cf 100644 --- a/booklore-ui/src/app/features/stats/component/stats-component.ts +++ b/booklore-ui/src/app/features/stats/component/stats-component.ts @@ -31,7 +31,6 @@ import {TopSeriesChartService} from '../service/top-series-chart.service'; import {ReadingDNAChartService} from '../service/reading-dna-chart.service'; import {ReadingHabitsChartService} from '../service/reading-habits-chart.service'; import {ChartConfig, ChartConfigService} from '../service/chart-config.service'; -import {ReadingHeatmapChartService} from '../service/reading-heatmap-chart.service'; @Component({ selector: 'app-stats-component', @@ -71,7 +70,6 @@ export class StatsComponent implements OnInit, OnDestroy { protected readonly readingDNAChartService = inject(ReadingDNAChartService); protected readonly readingHabitsChartService = inject(ReadingHabitsChartService); protected readonly chartConfigService = inject(ChartConfigService); - protected readonly readingHeatmapChartService = inject(ReadingHeatmapChartService); private readonly pageTitle = inject(PageTitleService); private readonly destroy$ = new Subject(); @@ -100,7 +98,6 @@ export class StatsComponent implements OnInit, OnDestroy { ngOnInit(): void { Chart.register(...registerables, Tooltip, ChartDataLabels, MatrixController, MatrixElement); - // Register matrix chart - it's automatically registered when imported Chart.defaults.plugins.legend.labels.font = { family: "'Inter', sans-serif", size: 11.5, diff --git a/booklore-ui/src/app/features/stats/component/user-stats/user-stats.component.html b/booklore-ui/src/app/features/stats/component/user-stats/user-stats.component.html new file mode 100644 index 00000000..8f1cc8de --- /dev/null +++ b/booklore-ui/src/app/features/stats/component/user-stats/user-stats.component.html @@ -0,0 +1,21 @@ +
+
+
+
+ +

{{ userName ? userName + "'s Reading Statistics" : "Your Reading Statistics" }}

+
+

Track your reading habits and progress

+
+
+ +
+
+ +
+ +
+ +
+
+
diff --git a/booklore-ui/src/app/features/stats/component/user-stats/user-stats.component.scss b/booklore-ui/src/app/features/stats/component/user-stats/user-stats.component.scss new file mode 100644 index 00000000..ed40df58 --- /dev/null +++ b/booklore-ui/src/app/features/stats/component/user-stats/user-stats.component.scss @@ -0,0 +1,147 @@ +.user-stats-container { + max-width: 1800px; + margin: 0 auto; + padding: 0.5rem; + height: calc(100dvh - 6.25rem); + overflow-y: auto; +} + +.header-section { + background: var(--card-background); + border-radius: 12px; + padding: 1.5rem; + backdrop-filter: blur(10px); + border: 1px solid var(--p-content-border-color); + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); + margin-bottom: 2rem; + margin-left: auto; + margin-right: auto; + transition: all 0.3s ease; + width: fit-content; + max-width: 100%; + + &:hover { + border-color: rgba(255, 255, 255, 0.2); + box-shadow: 0 6px 12px rgba(0, 0, 0, 0.15); + } + + .header-content { + display: flex; + flex-direction: column; + gap: 0.5rem; + align-items: center; + text-align: center; + } + + .greeting { + display: flex; + align-items: center; + gap: 0.75rem; + + i { + font-size: 1.5rem; + color: var(--primary-color); + } + + h2 { + color: var(--text-color, #ffffff); + font-size: 1.5rem; + font-weight: 600; + margin: 0; + white-space: nowrap; + } + } + + .subtitle { + color: var(--text-secondary-color); + font-size: 0.95rem; + margin: 0; + padding-left: 0; + white-space: nowrap; + } +} + +.charts-container { + display: flex; + flex-direction: column; + gap: 2rem; +} + +.chart-card { + background: var(--card-background); + border-radius: 12px; + padding: 1.5rem; + backdrop-filter: blur(10px); + border: 1px solid var(--p-content-border-color); + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); + transition: all 0.3s ease; + + &:hover { + border-color: rgba(255, 255, 255, 0.2); + box-shadow: 0 6px 12px rgba(0, 0, 0, 0.15); + } +} + +@media (max-width: 768px) { + .user-stats-container { + padding: 1rem; + } + + .header-section { + padding: 1rem; + + .greeting { + i { + font-size: 1.25rem; + } + + h2 { + font-size: 1.25rem; + white-space: normal; + } + } + + .subtitle { + font-size: 0.875rem; + padding-left: 0; + white-space: normal; + } + } + + .chart-card { + padding: 1rem; + } +} + +@media (max-width: 480px) { + .user-stats-container { + padding: 0.75rem; + } + + .header-section { + padding: 0.75rem; + + .greeting { + gap: 0.5rem; + + i { + font-size: 1rem; + } + + h2 { + font-size: 1rem; + white-space: normal; + } + } + + .subtitle { + font-size: 0.8rem; + padding-left: 0; + white-space: normal; + } + } + + .chart-card { + padding: 0.75rem; + } +} diff --git a/booklore-ui/src/app/features/stats/component/user-stats/user-stats.component.ts b/booklore-ui/src/app/features/stats/component/user-stats/user-stats.component.ts new file mode 100644 index 00000000..55069ea5 --- /dev/null +++ b/booklore-ui/src/app/features/stats/component/user-stats/user-stats.component.ts @@ -0,0 +1,44 @@ +import {Component, inject, OnDestroy, OnInit} from '@angular/core'; +import {CommonModule} from '@angular/common'; +import {Subject} from 'rxjs'; +import {ReadingSessionHeatmapComponent} from '../reading-session-heatmap/reading-session-heatmap.component'; +import {ReadingSessionTimelineComponent} from '../reading-session-timeline/reading-session-timeline.component'; +import {UserService} from '../../../settings/user-management/user.service'; +import {takeUntil} from 'rxjs/operators'; + +interface UserChartConfig { + id: string; + component: any; + enabled: boolean; + order: number; +} + +@Component({ + selector: 'app-user-stats', + standalone: true, + imports: [CommonModule, ReadingSessionHeatmapComponent, ReadingSessionTimelineComponent], + templateUrl: './user-stats.component.html', + styleUrls: ['./user-stats.component.scss'] +}) +export class UserStatsComponent implements OnInit, OnDestroy { + private readonly destroy$ = new Subject(); + + private userService = inject(UserService); + public currentYear = new Date().getFullYear(); + public userName: string = ''; + + ngOnInit(): void { + this.userService.userState$ + .pipe(takeUntil(this.destroy$)) + .subscribe(state => { + if (state.user) { + this.userName = state.user.name || state.user.username; + } + }); + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } +} diff --git a/booklore-ui/src/app/features/stats/service/chart-config.service.ts b/booklore-ui/src/app/features/stats/service/chart-config.service.ts index 64113087..177da01e 100644 --- a/booklore-ui/src/app/features/stats/service/chart-config.service.ts +++ b/booklore-ui/src/app/features/stats/service/chart-config.service.ts @@ -19,7 +19,7 @@ export class ChartConfigService { {id: 'readingStatus', name: 'Reading Status', enabled: true, category: 'small', order: 0}, {id: 'bookFormats', name: 'Book Formats', enabled: true, category: 'small', order: 1}, {id: 'bookMetadataScore', name: 'Book Metadata Score', enabled: true, category: 'small', order: 2}, - {id: 'readingHeatmap', name: 'Books Finished per Month', category: 'small', enabled: true, order: 3}, + {id: 'languageDistribution', name: 'Language Distribution', enabled: true, category: 'small', order: 3}, {id: 'topAuthors', name: 'Top 25 Authors', enabled: true, category: 'large', order: 4}, {id: 'topCategories', name: 'Top 25 Categories', enabled: true, category: 'large', order: 5}, {id: 'monthlyReadingPatterns', name: 'Monthly Reading Patterns', enabled: true, category: 'large', order: 6}, @@ -32,8 +32,7 @@ export class ChartConfigService { {id: 'topSeries', name: 'Top 20 Series', enabled: true, category: 'large', order: 13}, {id: 'readingDNA', name: 'Reading DNA Profile', enabled: true, category: 'large', order: 14}, {id: 'readingHabits', name: 'Reading Habits Analysis', enabled: true, category: 'large', order: 15}, - {id: 'publicationYear', name: 'Publication Year Timeline', enabled: true, category: 'full-width', order: 16}, - {id: 'languageDistribution', name: 'Language Distribution', enabled: true, category: 'small', order: 17} + {id: 'publicationYear', name: 'Publication Year Timeline', enabled: true, category: 'full-width', order: 16} ]; private chartsConfigSubject = new BehaviorSubject(this.loadConfig()); @@ -114,10 +113,6 @@ export class ChartConfigService { this.saveConfig(updatedConfig); } - public getChartsByCategory(category: string): ChartConfig[] { - return this.chartsConfigSubject.value.filter(chart => chart.category === category); - } - public getEnabledChartsSorted(): ChartConfig[] { return this.chartsConfigSubject.value .filter(chart => chart.enabled) @@ -132,11 +127,9 @@ export class ChartConfigService { return; } - // Move the chart from fromIndex to toIndex const [movedChart] = enabledCharts.splice(fromIndex, 1); enabledCharts.splice(toIndex, 0, movedChart); - // Update order values for all enabled charts enabledCharts.forEach((chart, index) => { const configIndex = currentConfig.findIndex(c => c.id === chart.id); if (configIndex !== -1) { diff --git a/booklore-ui/src/app/features/stats/service/reading-heatmap-chart.service.ts b/booklore-ui/src/app/features/stats/service/reading-heatmap-chart.service.ts deleted file mode 100644 index 0bb6541f..00000000 --- a/booklore-ui/src/app/features/stats/service/reading-heatmap-chart.service.ts +++ /dev/null @@ -1,248 +0,0 @@ -import {inject, Injectable, OnDestroy} from '@angular/core'; -import {BehaviorSubject, EMPTY, Observable, Subject} from 'rxjs'; -import {map, takeUntil, catchError, filter, first, switchMap} from 'rxjs/operators'; -import {ChartConfiguration, ChartData} from 'chart.js'; - -import {LibraryFilterService} from './library-filter.service'; -import {BookService} from '../../book/service/book.service'; -import {Book} from '../../book/model/book.model'; - -interface MatrixDataPoint { - x: number; // month (0-11) - y: number; // year index - v: number; // book count -} - -interface YearMonthData { - year: number; - month: number; - count: number; -} - -const MONTH_NAMES = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; - -type HeatmapChartData = ChartData<'matrix', MatrixDataPoint[], string>; - -@Injectable({ - providedIn: 'root' -}) -export class ReadingHeatmapChartService implements OnDestroy { - private readonly bookService = inject(BookService); - private readonly libraryFilterService = inject(LibraryFilterService); - private readonly destroy$ = new Subject(); - - public readonly heatmapChartType = 'matrix' as const; - - private yearLabels: string[] = []; - private maxBookCount = 1; - - public readonly heatmapChartOptions: ChartConfiguration['options'] = { - responsive: true, - maintainAspectRatio: false, - layout: { - padding: { - top: 20 - } - }, - plugins: { - legend: {display: false}, - tooltip: { - enabled: true, - backgroundColor: 'rgba(0, 0, 0, 0.9)', - titleColor: '#ffffff', - bodyColor: '#ffffff', - borderColor: '#ffffff', - borderWidth: 1, - cornerRadius: 6, - displayColors: false, - padding: 12, - titleFont: {size: 14, weight: 'bold'}, - bodyFont: {size: 13}, - callbacks: { - title: (context) => { - const point = context[0].raw as MatrixDataPoint; - const year = this.yearLabels[point.y]; - const month = MONTH_NAMES[point.x]; - return `${month} ${year}`; - }, - label: (context) => { - const point = context.raw as MatrixDataPoint; - return `${point.v} book${point.v === 1 ? '' : 's'} read`; - } - } - }, - datalabels: { - display: true, - color: '#ffffff', - font: { - family: "'Inter', sans-serif", - size: 10, - weight: 'bold' - }, - formatter: (value: MatrixDataPoint) => value.v > 0 ? value.v.toString() : '' - } - }, - scales: { - x: { - type: 'linear', - position: 'bottom', - ticks: { - stepSize: 1, - callback: (value) => MONTH_NAMES[value as number] || '', - color: '#ffffff', - font: { - family: "'Inter', sans-serif", - size: 11 - } - }, - grid: {display: false}, - }, - y: { - type: 'linear', - ticks: { - stepSize: 1, - callback: (value) => this.yearLabels[value as number] || '', - color: '#ffffff', - font: { - family: "'Inter', sans-serif", - size: 11 - } - }, - grid: {display: false}, - } - } - }; - - private readonly heatmapChartDataSubject = new BehaviorSubject({ - labels: [], - datasets: [{ - label: 'Books Read', - data: [] - }] - }); - - public readonly heatmapChartData$: Observable = - this.heatmapChartDataSubject.asObservable(); - - constructor() { - this.bookService.bookState$ - .pipe( - filter(state => state.loaded), - first(), - switchMap(() => - this.libraryFilterService.selectedLibrary$.pipe( - takeUntil(this.destroy$) - ) - ), - catchError((error) => { - console.error('Error processing reading heatmap stats:', error); - return EMPTY; - }) - ) - .subscribe(() => { - const stats = this.calculateHeatmapData(); - this.updateChartData(stats); - }); - } - - ngOnDestroy(): void { - this.destroy$.next(); - this.destroy$.complete(); - } - - private updateChartData(yearMonthData: YearMonthData[]): void { - const currentYear = new Date().getFullYear(); - const currentMonth = new Date().getMonth(); - const years = Array.from({length: 10}, (_, i) => currentYear - 9 + i); - - this.yearLabels = years.map(String); - this.maxBookCount = Math.max(1, ...yearMonthData.map(d => d.count)); - - const heatmapData: MatrixDataPoint[] = []; - - years.forEach((year, yearIndex) => { - const maxMonth = year === currentYear ? currentMonth : 11; - - for (let month = 0; month <= maxMonth; month++) { - const dataPoint = yearMonthData.find(d => d.year === year && d.month === month + 1); - heatmapData.push({ - x: month, - y: yearIndex, - v: dataPoint?.count || 0 - }); - } - }); - - if (this.heatmapChartOptions?.scales?.['y']) { - (this.heatmapChartOptions.scales['y'] as any).max = years.length - 0.5; - } - - this.heatmapChartDataSubject.next({ - labels: [], - datasets: [{ - label: 'Books Read', - data: heatmapData, - backgroundColor: (context) => { - const point = context.raw as MatrixDataPoint; - if (!point?.v) return 'rgba(255, 255, 255, 0.05)'; - - const intensity = point.v / this.maxBookCount; - const alpha = Math.max(0.2, Math.min(1.0, intensity * 0.8 + 0.2)); - return `rgba(239, 71, 111, ${alpha})`; - }, - borderColor: 'rgba(255, 255, 255, 0.2)', - borderWidth: 1, - width: ({chart}) => (chart.chartArea?.width || 0) / 12 - 1, - height: ({chart}) => (chart.chartArea?.height || 0) / years.length - 1 - }] - }); - } - - private calculateHeatmapData(): YearMonthData[] { - const currentState = this.bookService.getCurrentBookState(); - const selectedLibraryId = this.libraryFilterService.getCurrentSelectedLibrary(); - - if (!this.isValidBookState(currentState)) { - return []; - } - - const filteredBooks = this.filterBooksByLibrary(currentState.books!, selectedLibraryId); - return this.processHeatmapData(filteredBooks); - } - - private isValidBookState(state: any): boolean { - return state?.loaded && state?.books && Array.isArray(state.books) && state.books.length > 0; - } - - private filterBooksByLibrary(books: Book[], selectedLibraryId: string | number | null): Book[] { - return selectedLibraryId - ? books.filter(book => book.libraryId === selectedLibraryId) - : books; - } - - private processHeatmapData(books: Book[]): YearMonthData[] { - const yearMonthMap = new Map(); - const currentYear = new Date().getFullYear(); - const startYear = currentYear - 9; - - books - .filter(book => book.dateFinished) - .forEach(book => { - const finishedDate = new Date(book.dateFinished!); - const year = finishedDate.getFullYear(); - - if (year >= startYear && year <= currentYear) { - const month = finishedDate.getMonth() + 1; - const key = `${year}-${month}`; - yearMonthMap.set(key, (yearMonthMap.get(key) || 0) + 1); - } - }); - - return Array.from(yearMonthMap.entries()) - .map(([key, count]) => { - const [year, month] = key.split('-').map(Number); - return {year, month, count}; - }) - .sort((a, b) => a.year - b.year || a.month - b.month); - } -} diff --git a/booklore-ui/src/app/shared/layout/component/layout-topbar/app.topbar.component.html b/booklore-ui/src/app/shared/layout/component/layout-topbar/app.topbar.component.html index 26e6899e..fa12038f 100644 --- a/booklore-ui/src/app/shared/layout/component/layout-topbar/app.topbar.component.html +++ b/booklore-ui/src/app/shared/layout/component/layout-topbar/app.topbar.component.html @@ -53,9 +53,14 @@ }
  • - + +
  • @if (userService.userState$ | async; as userState) {
  • @@ -192,11 +197,12 @@
  • +