mirror of
https://github.com/booklore-app/booklore.git
synced 2025-12-23 22:28:11 -05:00
Introduce reading session tracking with visual insights (#1957)
* Introduce reading session tracking with visual insights (#1957) --------- Co-authored-by: acx10 <acx10@users.noreply.github.com>
This commit is contained in:
@@ -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<Void> 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<List<ReadingSessionHeatmapResponse>> getHeatmapForYear(@PathVariable int year) {
|
||||
List<ReadingSessionHeatmapResponse> heatmapData = readingSessionService.getSessionHeatmapForYear(year);
|
||||
return ResponseEntity.ok(heatmapData);
|
||||
}
|
||||
|
||||
@Operation(summary = "Get reading session timeline for a week", description = "Returns reading sessions grouped by book for calendar timeline view")
|
||||
@ApiResponses({
|
||||
@ApiResponse(responseCode = "200", description = "Timeline data retrieved successfully"),
|
||||
@ApiResponse(responseCode = "400", description = "Invalid week or year"),
|
||||
@ApiResponse(responseCode = "401", description = "Unauthorized")
|
||||
})
|
||||
@GetMapping("/timeline/week/{year}/{week}")
|
||||
public ResponseEntity<List<ReadingSessionTimelineResponse>> getTimelineForWeek(
|
||||
@PathVariable int year,
|
||||
@PathVariable int week) {
|
||||
List<ReadingSessionTimelineResponse> timelineData = readingSessionService.getSessionTimelineForWeek(year, week);
|
||||
return ResponseEntity.ok(timelineData);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.adityachandel.booklore.model.dto;
|
||||
|
||||
import java.time.LocalDate;
|
||||
|
||||
public interface ReadingSessionCountDto {
|
||||
LocalDate getDate();
|
||||
Long getCount();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<ReadingSessionEntity> readingSessions = new HashSet<>();
|
||||
|
||||
@PrePersist
|
||||
public void prePersist() {
|
||||
this.createdAt = LocalDateTime.now();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<ReadingSessionEntity, Long> {
|
||||
|
||||
@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<ReadingSessionCountDto> 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<ReadingSessionTimelineDto> findSessionTimelineByUserAndWeek(
|
||||
@Param("userId") Long userId,
|
||||
@Param("year") int year,
|
||||
@Param("weekOfYear") int weekOfYear);
|
||||
}
|
||||
@@ -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<ReadingSessionHeatmapResponse> 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<ReadingSessionTimelineResponse> 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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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]},
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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<BookState>({
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -97,6 +97,9 @@
|
||||
<button class="view-button background-toggle" (click)="toggleBackground()" title="Toggle Background Color">
|
||||
<span>{{ backgroundColorIcon }}</span>
|
||||
</button>
|
||||
<button class="view-button close-button" (click)="closeReader()" title="Close Reader">
|
||||
<span>✕</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mobile-controls">
|
||||
@@ -160,6 +163,10 @@
|
||||
<span class="option-icon">{{ backgroundColorIcon }}</span>
|
||||
<span class="option-label">Background</span>
|
||||
</button>
|
||||
<button class="mobile-option" (click)="closeReader()">
|
||||
<span class="option-icon">✕</span>
|
||||
<span class="option-label">Close Reader</span>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -284,6 +284,14 @@
|
||||
</div>
|
||||
</p-drawer>
|
||||
</div>
|
||||
<p-button
|
||||
size="small"
|
||||
icon="pi pi-times"
|
||||
(click)="closeReader()"
|
||||
severity="secondary"
|
||||
pTooltip="Close Reader"
|
||||
tooltipPosition="bottom">
|
||||
</p-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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,7 +724,6 @@ 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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)">
|
||||
</ngx-extended-pdf-viewer>
|
||||
|
||||
<ng-template #additionalButtons>
|
||||
<div id="toolbarViewer">
|
||||
<div id="toolbarViewerLeft">
|
||||
<pdf-toggle-sidebar></pdf-toggle-sidebar>
|
||||
<pdf-find-button></pdf-find-button>
|
||||
<pdf-paging-area></pdf-paging-area>
|
||||
</div>
|
||||
<pdf-zoom-toolbar></pdf-zoom-toolbar>
|
||||
<div id="toolbarViewerRight">
|
||||
<pdf-shy-button
|
||||
[cssClass]="'lg' | responsiveCSSClass"
|
||||
class="newTab"
|
||||
title="Close PDF Reader"
|
||||
primaryToolbarId="closePdfReaderButton"
|
||||
[action]="closeReader"
|
||||
[order]="5"
|
||||
image="<svg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='orange' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' class='lucide lucide-circle-x-icon lucide-circle-x'><circle cx='12' cy='12' r='10'/><path d='m15 9-6 6'/><path d='m9 9 6 6'/></svg>"
|
||||
>
|
||||
</pdf-shy-button>
|
||||
<pdf-hand-tool></pdf-hand-tool>
|
||||
<pdf-select-tool></pdf-select-tool>
|
||||
<pdf-rotate-page></pdf-rotate-page>
|
||||
<pdf-print></pdf-print>
|
||||
<pdf-no-spread></pdf-no-spread>
|
||||
<pdf-even-spread></pdf-even-spread>
|
||||
<pdf-odd-spread></pdf-odd-spread>
|
||||
<pdf-infinite-scroll></pdf-infinite-scroll>
|
||||
<pdf-horizontal-scroll></pdf-horizontal-scroll>
|
||||
<pdf-vertical-scroll-mode></pdf-vertical-scroll-mode>
|
||||
<pdf-wrapped-scroll-mode></pdf-wrapped-scroll-mode>
|
||||
<pdf-single-page-mode></pdf-single-page-mode>
|
||||
<pdf-book-mode></pdf-book-mode>
|
||||
<div class="verticalToolbarSeparator hiddenSmallView"></div>
|
||||
<pdf-toggle-secondary-toolbar></pdf-toggle-secondary-toolbar>
|
||||
</div>
|
||||
</div>
|
||||
</ng-template>
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<ReadingSessionHeatmapResponse[]> {
|
||||
return this.http.get<ReadingSessionHeatmapResponse[]>(
|
||||
`${this.readingSessionsUrl}/heatmap/year/${year}`
|
||||
);
|
||||
}
|
||||
|
||||
getTimelineForWeek(year: number, week: number): Observable<ReadingSessionTimelineResponse[]> {
|
||||
return this.http.get<ReadingSessionTimelineResponse[]>(
|
||||
`${this.readingSessionsUrl}/timeline/week/${year}/${week}`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<div class="reading-session-heatmap-container">
|
||||
<div class="chart-header">
|
||||
<div class="chart-title">
|
||||
<h3>Reading Session Activity</h3>
|
||||
<p class="chart-description">Daily reading session activity throughout the year</p>
|
||||
</div>
|
||||
<div class="year-selector">
|
||||
<button type="button"
|
||||
class="year-nav-btn"
|
||||
(click)="changeYear(-1)"
|
||||
title="Previous year">
|
||||
<i class="pi pi-chevron-left"></i>
|
||||
</button>
|
||||
<span class="current-year">{{ currentYear }}</span>
|
||||
<button type="button"
|
||||
class="year-nav-btn"
|
||||
(click)="changeYear(1)"
|
||||
title="Next year">
|
||||
<i class="pi pi-chevron-right"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chart-wrapper">
|
||||
<canvas baseChart
|
||||
[data]="(chartData$ | async) ?? {labels: [], datasets: []}"
|
||||
[options]="chartOptions"
|
||||
[type]="chartType">
|
||||
</canvas>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<SessionHeatmapChartData>;
|
||||
public readonly chartOptions: ChartConfiguration['options'];
|
||||
|
||||
private readonly userStatsService = inject(UserStatsService);
|
||||
private readonly destroy$ = new Subject<void>();
|
||||
private readonly chartDataSubject: BehaviorSubject<SessionHeatmapChartData>;
|
||||
private maxSessionCount = 1;
|
||||
|
||||
constructor() {
|
||||
this.chartDataSubject = new BehaviorSubject<SessionHeatmapChartData>({
|
||||
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<string, number>();
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
<div class="timeline-container">
|
||||
<div class="timeline-header">
|
||||
<div class="header-title">
|
||||
<h3>Reading Session Timeline</h3>
|
||||
<p class="timeline-subtitle">Your reading schedule throughout the week</p>
|
||||
</div>
|
||||
<div class="week-selector">
|
||||
<button type="button"
|
||||
class="week-nav-btn"
|
||||
(click)="changeWeek(-1)"
|
||||
title="Previous week">
|
||||
<i class="pi pi-chevron-left"></i>
|
||||
</button>
|
||||
<div class="week-info">
|
||||
<span class="week-label">Week {{ currentWeek }}</span>
|
||||
<span class="week-dates">{{ getWeekDateRange() }}</span>
|
||||
</div>
|
||||
<button type="button"
|
||||
class="week-nav-btn"
|
||||
(click)="changeWeek(1)"
|
||||
title="Next week">
|
||||
<i class="pi pi-chevron-right"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="timeline-content">
|
||||
<div class="hour-markers">
|
||||
<div class="day-label-spacer"></div>
|
||||
<div class="hour-grid">
|
||||
@for (hour of hourLabels; track $index) {
|
||||
<div class="hour-marker" [class.major]="$index % 3 === 0">
|
||||
<span class="hour-label">{{ hour }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="timeline-rows">
|
||||
@for (dayData of timelineData; track dayData.dayOfWeek) {
|
||||
<div class="timeline-row">
|
||||
<div class="day-label">{{ dayData.day }}</div>
|
||||
<div class="timeline-track">
|
||||
<div class="grid-lines">
|
||||
@for (hour of hourLabels; track $index) {
|
||||
<div class="grid-line" [class.major]="$index % 6 === 0"></div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="sessions">
|
||||
@for (session of dayData.sessions; track $index) {
|
||||
<div class="session-block"
|
||||
[ngClass]="'book-type-' + (session.bookType || 'default').toLowerCase()"
|
||||
[style.left.%]="session.left"
|
||||
[style.width.%]="session.width"
|
||||
[style.top]="session.totalLevels > 1 ? 'calc(' + session.level + ' / ' + session.totalLevels + ' * 100% + ' + session.level * 2 + 'px)' : '0'"
|
||||
[style.height]="session.totalLevels > 1 ? 'calc((1 / ' + session.totalLevels + ' * 100%) - ' + (session.totalLevels - 1) * 2 / session.totalLevels + 'px)' : '100%'">
|
||||
<div class="session-content">
|
||||
<span class="session-time">
|
||||
{{ formatTime(session.startHour, session.startMinute) }}
|
||||
</span>
|
||||
<span class="session-duration">
|
||||
{{ formatDuration(session.duration) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="session-tooltip">
|
||||
<div class="tooltip-content">
|
||||
<div class="tooltip-cover">
|
||||
<img [src]="getCoverUrl(session.bookId)" alt="Book Cover">
|
||||
</div>
|
||||
<div class="tooltip-details">
|
||||
<div class="tooltip-header">
|
||||
<i class="pi pi-book"></i>
|
||||
<span class="tooltip-title">{{ session.bookTitle || 'Reading Session' }}</span>
|
||||
</div>
|
||||
<div class="tooltip-divider"></div>
|
||||
<div class="tooltip-body">
|
||||
<div class="tooltip-row">
|
||||
<i class="pi pi-clock"></i>
|
||||
<span class="tooltip-label">Time:</span>
|
||||
<span class="tooltip-value">
|
||||
{{ formatTime(session.startHour, session.startMinute) }} -
|
||||
{{ formatTime(session.endHour, session.endMinute) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="tooltip-row">
|
||||
<i class="pi pi-hourglass"></i>
|
||||
<span class="tooltip-label">Duration:</span>
|
||||
<span class="tooltip-value">{{ formatDuration(session.duration) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<number, ReadingSession[]>();
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -392,16 +392,6 @@
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@case ('readingHeatmap') {
|
||||
<h3>Books Finished per Month</h3>
|
||||
<div class="chart-wrapper reading-heatmap-chart">
|
||||
<canvas baseChart
|
||||
[data]="(readingHeatmapChartService.heatmapChartData$ | async) ?? {labels: [], datasets: []}"
|
||||
[options]="readingHeatmapChartService.heatmapChartOptions"
|
||||
[type]="readingHeatmapChartService.heatmapChartType">
|
||||
</canvas>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<void>();
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
<div class="user-stats-container">
|
||||
<div class="header-section">
|
||||
<div class="header-content">
|
||||
<div class="greeting">
|
||||
<i class="pi pi-chart-line"></i>
|
||||
<h2>{{ userName ? userName + "'s Reading Statistics" : "Your Reading Statistics" }}</h2>
|
||||
</div>
|
||||
<p class="subtitle">Track your reading habits and progress</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="charts-container">
|
||||
<div class="chart-card">
|
||||
<app-reading-session-heatmap [initialYear]="currentYear"></app-reading-session-heatmap>
|
||||
</div>
|
||||
|
||||
<div class="chart-card">
|
||||
<app-reading-session-timeline [initialYear]="currentYear"></app-reading-session-timeline>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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<void>();
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -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<ChartConfig[]>(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) {
|
||||
|
||||
@@ -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<void>();
|
||||
|
||||
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<HeatmapChartData>({
|
||||
labels: [],
|
||||
datasets: [{
|
||||
label: 'Books Read',
|
||||
data: []
|
||||
}]
|
||||
});
|
||||
|
||||
public readonly heatmapChartData$: Observable<HeatmapChartData> =
|
||||
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<string, number>();
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -53,9 +53,14 @@
|
||||
</li>
|
||||
}
|
||||
<li>
|
||||
<a class="topbar-item" (click)="navigateToStats()" pTooltip="Stats" tooltipPosition="bottom">
|
||||
<button
|
||||
class="topbar-item"
|
||||
(click)="statsMenu.toggle($event)"
|
||||
pTooltip="Stats"
|
||||
tooltipPosition="bottom">
|
||||
<i class="pi pi-chart-bar text-surface-100"></i>
|
||||
</a>
|
||||
</button>
|
||||
<p-menu #statsMenu [model]="statsMenuItems" [popup]="true" appendTo="body" />
|
||||
</li>
|
||||
@if (userService.userState$ | async; as userState) {
|
||||
<li>
|
||||
@@ -192,11 +197,12 @@
|
||||
<li>
|
||||
<button
|
||||
class="flex items-center gap-2 w-full text-left p-2 hover:bg-surface-200 dark:hover:bg-surface-700 rounded"
|
||||
(click)="navigateToStats(); mobileMenu.hide()"
|
||||
(click)="statsMenu.toggle($event)"
|
||||
>
|
||||
<i class="pi pi-chart-bar text-surface-100"></i>
|
||||
Charts
|
||||
</button>
|
||||
<p-menu #statsMenuMobile [model]="statsMenuItems" [popup]="true" />
|
||||
</li>
|
||||
<li>
|
||||
<button
|
||||
|
||||
@@ -24,6 +24,7 @@ import {BookdropFileService} from '../../../../features/bookdrop/service/bookdro
|
||||
import {DialogLauncherService} from '../../../services/dialog-launcher.service';
|
||||
import {UnifiedNotificationBoxComponent} from '../../../components/unified-notification-popover/unified-notification-popover-component';
|
||||
import {Severity, LogNotification} from '../../../websocket/model/log-notification.model';
|
||||
import {Menu} from 'primeng/menu';
|
||||
|
||||
@Component({
|
||||
selector: 'app-topbar',
|
||||
@@ -45,11 +46,13 @@ import {Severity, LogNotification} from '../../../websocket/model/log-notificati
|
||||
Popover,
|
||||
UnifiedNotificationBoxComponent,
|
||||
NgStyle,
|
||||
Menu,
|
||||
],
|
||||
})
|
||||
export class AppTopBarComponent implements OnDestroy {
|
||||
items!: MenuItem[];
|
||||
ref?: DynamicDialogRef;
|
||||
statsMenuItems: MenuItem[] = [];
|
||||
|
||||
@ViewChild('menubutton') menuButton!: ElementRef;
|
||||
@ViewChild('topbarmenubutton') topbarMenuButton!: ElementRef;
|
||||
@@ -80,6 +83,7 @@ export class AppTopBarComponent implements OnDestroy {
|
||||
private bookdropFileService: BookdropFileService,
|
||||
private dialogLauncher: DialogLauncherService
|
||||
) {
|
||||
this.initializeStatsMenu();
|
||||
this.subscribeToMetadataProgress();
|
||||
this.subscribeToNotifications();
|
||||
|
||||
@@ -143,7 +147,11 @@ export class AppTopBarComponent implements OnDestroy {
|
||||
}
|
||||
|
||||
navigateToStats() {
|
||||
this.router.navigate(['/stats']);
|
||||
this.router.navigate(['/library-stats']);
|
||||
}
|
||||
|
||||
navigateToUserStats() {
|
||||
this.router.navigate(['/reading-stats']);
|
||||
}
|
||||
|
||||
logout() {
|
||||
@@ -191,6 +199,21 @@ export class AppTopBarComponent implements OnDestroy {
|
||||
this.hasActiveOrCompletedTasks = this.hasActiveOrCompletedTasks || this.hasPendingBookdropFiles;
|
||||
}
|
||||
|
||||
private initializeStatsMenu() {
|
||||
this.statsMenuItems = [
|
||||
{
|
||||
label: 'Library Stats',
|
||||
icon: 'pi pi-chart-line',
|
||||
command: () => this.navigateToStats()
|
||||
},
|
||||
{
|
||||
label: 'Reading Stats',
|
||||
icon: 'pi pi-users',
|
||||
command: () => this.navigateToUserStats()
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
get iconClass(): string {
|
||||
if (this.progressHighlight) return 'pi-spinner spin';
|
||||
if (this.iconPulsating) return 'pi-wave-pulse';
|
||||
|
||||
288
booklore-ui/src/app/shared/service/reading-session.service.ts
Normal file
288
booklore-ui/src/app/shared/service/reading-session.service.ts
Normal file
@@ -0,0 +1,288 @@
|
||||
import { inject, Injectable } from '@angular/core';
|
||||
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
|
||||
import { fromEvent, merge, Subscription } from 'rxjs';
|
||||
import { debounceTime } from 'rxjs/operators';
|
||||
import { API_CONFIG } from '../../core/config/api-config';
|
||||
import {BookType} from '../../features/book/model/book.model';
|
||||
|
||||
export interface ReadingSession {
|
||||
bookId: number;
|
||||
bookType: BookType;
|
||||
startTime: Date;
|
||||
endTime?: Date;
|
||||
durationSeconds?: number;
|
||||
startLocation?: string;
|
||||
endLocation?: string;
|
||||
startProgress?: number;
|
||||
endProgress?: number;
|
||||
progressDelta?: number;
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class ReadingSessionService {
|
||||
private readonly http = inject(HttpClient);
|
||||
private readonly url = `${API_CONFIG.BASE_URL}/api/v1/reading-sessions`;
|
||||
|
||||
private currentSession: ReadingSession | null = null;
|
||||
private idleTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private activitySubscription: Subscription | null = null;
|
||||
|
||||
private readonly IDLE_TIMEOUT_MS = 5 * 60 * 1000;
|
||||
private readonly MIN_SESSION_DURATION_SECONDS = 30;
|
||||
private readonly ACTIVITY_DEBOUNCE_MS = 1000;
|
||||
|
||||
constructor() {
|
||||
this.setupBrowserLifecycleListeners();
|
||||
}
|
||||
|
||||
private setupBrowserLifecycleListeners(): void {
|
||||
window.addEventListener('beforeunload', () => {
|
||||
if (this.currentSession) {
|
||||
this.endSessionSync();
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.hidden && this.currentSession) {
|
||||
this.log('Tab hidden, pausing session');
|
||||
this.pauseIdleDetection();
|
||||
} else if (!document.hidden && this.currentSession) {
|
||||
this.log('Tab visible, resuming session');
|
||||
this.resumeIdleDetection();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
startSession(bookId: number, bookType: BookType, startLocation?: string, startProgress?: number): void {
|
||||
if (this.currentSession) {
|
||||
this.endSession();
|
||||
}
|
||||
|
||||
this.currentSession = {
|
||||
bookId,
|
||||
bookType,
|
||||
startTime: new Date(),
|
||||
startLocation,
|
||||
startProgress
|
||||
};
|
||||
|
||||
this.log('Reading session started', {
|
||||
bookId,
|
||||
startTime: this.currentSession.startTime.toISOString(),
|
||||
startLocation,
|
||||
startProgress: startProgress != null ? `${startProgress.toFixed(1)}%` : 'N/A'
|
||||
});
|
||||
|
||||
this.startIdleDetection();
|
||||
}
|
||||
|
||||
updateProgress(currentLocation?: string, currentProgress?: number): void {
|
||||
if (!this.currentSession) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.currentSession.endLocation = currentLocation;
|
||||
this.currentSession.endProgress = currentProgress;
|
||||
this.resetIdleTimer();
|
||||
}
|
||||
|
||||
endSession(endLocation?: string, endProgress?: number): void {
|
||||
if (!this.currentSession) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.stopIdleDetection();
|
||||
|
||||
this.currentSession.endTime = new Date();
|
||||
this.currentSession.endLocation = endLocation ?? this.currentSession.endLocation;
|
||||
this.currentSession.endProgress = endProgress ?? this.currentSession.endProgress;
|
||||
|
||||
const durationMs = this.currentSession.endTime.getTime() - this.currentSession.startTime.getTime();
|
||||
this.currentSession.durationSeconds = Math.floor(durationMs / 1000);
|
||||
|
||||
if (this.currentSession.startProgress != null && this.currentSession.endProgress != null) {
|
||||
this.currentSession.progressDelta = this.currentSession.endProgress - this.currentSession.startProgress;
|
||||
}
|
||||
|
||||
if (this.currentSession.durationSeconds >= this.MIN_SESSION_DURATION_SECONDS) {
|
||||
this.sendSessionToBackend(this.currentSession);
|
||||
} else {
|
||||
this.log('Session too short, discarding', {
|
||||
durationSeconds: this.currentSession.durationSeconds
|
||||
});
|
||||
}
|
||||
|
||||
this.currentSession = null;
|
||||
}
|
||||
|
||||
private endSessionSync(): void {
|
||||
if (!this.currentSession) {
|
||||
return;
|
||||
}
|
||||
|
||||
const endTime = new Date();
|
||||
const durationMs = endTime.getTime() - this.currentSession.startTime.getTime();
|
||||
const durationSeconds = Math.floor(durationMs / 1000);
|
||||
|
||||
if (durationSeconds < this.MIN_SESSION_DURATION_SECONDS) {
|
||||
this.cleanup();
|
||||
return;
|
||||
}
|
||||
|
||||
const sessionData = this.buildSessionData(
|
||||
this.currentSession,
|
||||
endTime,
|
||||
durationSeconds
|
||||
);
|
||||
|
||||
this.log('Reading session ended (sync)', sessionData);
|
||||
|
||||
try {
|
||||
const blob = new Blob([JSON.stringify(sessionData)], { type: 'application/json' });
|
||||
const success = navigator.sendBeacon(this.url, blob);
|
||||
|
||||
if (!success) {
|
||||
this.logError('sendBeacon failed, request may not have been queued');
|
||||
}
|
||||
} catch (error) {
|
||||
this.logError('Failed to send session data', error);
|
||||
}
|
||||
|
||||
this.cleanup();
|
||||
}
|
||||
|
||||
private sendSessionToBackend(session: ReadingSession): void {
|
||||
if (!session.endTime || session.durationSeconds == null) {
|
||||
this.logError('Invalid session data, missing endTime or duration');
|
||||
return;
|
||||
}
|
||||
|
||||
const sessionData = this.buildSessionData(
|
||||
session,
|
||||
session.endTime,
|
||||
session.durationSeconds
|
||||
);
|
||||
|
||||
this.log('Reading session completed', sessionData);
|
||||
|
||||
this.http.post<void>(this.url, sessionData).subscribe({
|
||||
next: () => this.log('Session saved to backend'),
|
||||
error: (err: HttpErrorResponse) => this.logError('Failed to save session', err)
|
||||
});
|
||||
}
|
||||
|
||||
private buildSessionData(session: ReadingSession, endTime: Date, durationSeconds: number) {
|
||||
return {
|
||||
bookId: session.bookId,
|
||||
bookType: session.bookType,
|
||||
startTime: session.startTime.toISOString(),
|
||||
endTime: endTime.toISOString(),
|
||||
durationSeconds,
|
||||
durationFormatted: this.formatDuration(durationSeconds),
|
||||
startProgress: session.startProgress,
|
||||
endProgress: session.endProgress,
|
||||
progressDelta: session.progressDelta,
|
||||
startLocation: session.startLocation,
|
||||
endLocation: session.endLocation
|
||||
};
|
||||
}
|
||||
|
||||
private formatDuration(seconds: number): string {
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
const secs = seconds % 60;
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${minutes}m ${secs}s`;
|
||||
} else if (minutes > 0) {
|
||||
return `${minutes}m ${secs}s`;
|
||||
}
|
||||
return `${secs}s`;
|
||||
}
|
||||
|
||||
private startIdleDetection(): void {
|
||||
this.stopIdleDetection();
|
||||
|
||||
const activity$ = merge(
|
||||
fromEvent(document, 'mousemove'),
|
||||
fromEvent(document, 'mousedown'),
|
||||
fromEvent(document, 'keypress'),
|
||||
fromEvent(document, 'scroll'),
|
||||
fromEvent(document, 'touchstart')
|
||||
).pipe(
|
||||
debounceTime(this.ACTIVITY_DEBOUNCE_MS)
|
||||
);
|
||||
|
||||
this.activitySubscription = activity$.subscribe(() => {
|
||||
this.resetIdleTimer();
|
||||
});
|
||||
|
||||
this.resetIdleTimer();
|
||||
}
|
||||
|
||||
private pauseIdleDetection(): void {
|
||||
if (this.idleTimer) {
|
||||
clearTimeout(this.idleTimer);
|
||||
this.idleTimer = null;
|
||||
}
|
||||
if (this.activitySubscription) {
|
||||
this.activitySubscription.unsubscribe();
|
||||
this.activitySubscription = null;
|
||||
}
|
||||
}
|
||||
|
||||
private resumeIdleDetection(): void {
|
||||
if (this.currentSession) {
|
||||
this.startIdleDetection();
|
||||
}
|
||||
}
|
||||
|
||||
private resetIdleTimer(): void {
|
||||
if (this.idleTimer) {
|
||||
clearTimeout(this.idleTimer);
|
||||
}
|
||||
|
||||
this.idleTimer = setTimeout(() => {
|
||||
this.log('User idle detected, ending session');
|
||||
this.endSession();
|
||||
}, this.IDLE_TIMEOUT_MS);
|
||||
}
|
||||
|
||||
private stopIdleDetection(): void {
|
||||
if (this.idleTimer) {
|
||||
clearTimeout(this.idleTimer);
|
||||
this.idleTimer = null;
|
||||
}
|
||||
if (this.activitySubscription) {
|
||||
this.activitySubscription.unsubscribe();
|
||||
this.activitySubscription = null;
|
||||
}
|
||||
}
|
||||
|
||||
private cleanup(): void {
|
||||
this.stopIdleDetection();
|
||||
this.currentSession = null;
|
||||
}
|
||||
|
||||
isSessionActive(): boolean {
|
||||
return this.currentSession !== null;
|
||||
}
|
||||
|
||||
private log(message: string, data?: any): void {
|
||||
if (data) {
|
||||
console.log(`[ReadingSession] ${message}`, data);
|
||||
} else {
|
||||
console.log(`[ReadingSession] ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
private logError(message: string, error?: any): void {
|
||||
if (error) {
|
||||
console.error(`[ReadingSession] ${message}`, error);
|
||||
} else {
|
||||
console.error(`[ReadingSession] ${message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,8 @@ import {API_CONFIG} from '../../core/config/api-config';
|
||||
import {AuthService} from './auth.service';
|
||||
import {BookService} from '../../features/book/service/book.service';
|
||||
import {CoverGeneratorComponent} from '../components/cover-generator/cover-generator.component';
|
||||
import { Router } from '@angular/router';
|
||||
import { Book } from '../../features/book/model/book.model';
|
||||
import {Router} from '@angular/router';
|
||||
import {Book} from '../../features/book/model/book.model';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
@@ -33,11 +33,20 @@ export class UrlHelperService {
|
||||
coverGenerator.title = book.metadata.title || '';
|
||||
coverGenerator.author = (book.metadata.authors || []).join(', ');
|
||||
return coverGenerator.generateCover();
|
||||
} else {
|
||||
return 'assets/images/missing-cover.jpg';
|
||||
}
|
||||
}
|
||||
const url = `${this.mediaBaseUrl}/book/${bookId}/thumbnail?${coverUpdatedOn}`;
|
||||
let url = `${this.mediaBaseUrl}/book/${bookId}/thumbnail`;
|
||||
if (coverUpdatedOn) {
|
||||
url += `?${coverUpdatedOn}`;
|
||||
}
|
||||
return this.appendToken(url);
|
||||
}
|
||||
|
||||
getThumbnailUrl1(bookId: number, coverUpdatedOn?: string): string {
|
||||
let url = `${this.mediaBaseUrl}/book/${bookId}/thumbnail`;
|
||||
if (coverUpdatedOn) {
|
||||
url += `?${coverUpdatedOn}`;
|
||||
}
|
||||
return this.appendToken(url);
|
||||
}
|
||||
|
||||
@@ -49,11 +58,12 @@ export class UrlHelperService {
|
||||
coverGenerator.title = book.metadata.title || '';
|
||||
coverGenerator.author = (book.metadata.authors || []).join(', ');
|
||||
return coverGenerator.generateCover();
|
||||
} else {
|
||||
return 'assets/images/missing-cover.jpg';
|
||||
}
|
||||
}
|
||||
const url = `${this.mediaBaseUrl}/book/${bookId}/cover?${coverUpdatedOn}`;
|
||||
let url = `${this.mediaBaseUrl}/book/${bookId}/cover`;
|
||||
if (coverUpdatedOn) {
|
||||
url += `?${coverUpdatedOn}`;
|
||||
}
|
||||
return this.appendToken(url);
|
||||
}
|
||||
|
||||
@@ -67,25 +77,13 @@ export class UrlHelperService {
|
||||
return this.appendToken(url);
|
||||
}
|
||||
|
||||
getBackgroundImageUrl(lastUpdated?: number): string {
|
||||
let url = `${this.mediaBaseUrl}/background`;
|
||||
if (lastUpdated) {
|
||||
url += `?t=${lastUpdated}`;
|
||||
}
|
||||
const token = this.getToken();
|
||||
if (token) {
|
||||
url += `${url.includes('?') ? '&' : '?'}token=${token}`;
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
getBookUrl(book: Book) {
|
||||
return this.router.createUrlTree(['/book', book.id], {
|
||||
queryParams: {tab: 'view'}
|
||||
});
|
||||
}
|
||||
|
||||
filterBooksBy(filterKey: string, filterValue: string){
|
||||
filterBooksBy(filterKey: string, filterValue: string) {
|
||||
if (filterKey === 'series') {
|
||||
return this.router.createUrlTree(['/series', encodeURIComponent(filterValue)])
|
||||
}
|
||||
@@ -100,9 +98,4 @@ export class UrlHelperService {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
getIconUrl(iconName: string): string {
|
||||
const url = `${this.mediaBaseUrl}/icon/${iconName}`;
|
||||
return this.appendToken(url);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user