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:
ACX
2025-12-21 21:14:42 -07:00
committed by GitHub
parent b12fc82414
commit b5ada2fff0
39 changed files with 2526 additions and 406 deletions

View File

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

View File

@@ -0,0 +1,9 @@
package com.adityachandel.booklore.model.dto;
import java.time.LocalDate;
public interface ReadingSessionCountDto {
LocalDate getDate();
Long getCount();
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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]},
]
},
{

View File

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

View File

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

View File

@@ -311,6 +311,18 @@
background: #4a4a4a;
border-color: #666;
}
&.close-button {
span {
font-size: 16px;
font-weight: bold;
}
&:hover {
background: rgba(239, 68, 68, 0.2);
border-color: rgba(239, 68, 68, 0.4);
}
}
}
.fit-mode-dropdown {
@@ -387,11 +399,11 @@
background: #0f0f0f;
min-height: 0;
position: relative;
&:not(.two-page-view):not(.infinite-scroll) {
flex-direction: column;
align-items: stretch;
.pages-wrapper {
flex: 1;
display: flex;
@@ -399,7 +411,7 @@
align-items: center;
min-height: 0;
}
.end-of-comic-action {
flex: 0 0 auto;
margin-top: 1rem;
@@ -777,7 +789,7 @@
.action-icon { font-size: 2rem; }
.action-text { font-size: 1rem; }
.book-title {
.book-title {
font-size: 0.85rem;
max-width: 250px;
}

View File

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

View File

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

View File

@@ -4,7 +4,7 @@ import {Drawer} from 'primeng/drawer';
import {forkJoin, Subscription} from 'rxjs';
import {Button} from 'primeng/button';
import {InputText} from 'primeng/inputtext';
import {CommonModule} from '@angular/common';
import {CommonModule, Location} from '@angular/common';
import {FormsModule} from '@angular/forms';
import {ActivatedRoute} from '@angular/router';
import {Book, BookSetting} from '../../../book/model/book.model';
@@ -17,7 +17,8 @@ import {BookMark, BookMarkService, UpdateBookMarkRequest} from '../../../../shar
import {Tooltip} from 'primeng/tooltip';
import {Slider} from 'primeng/slider';
import {FALLBACK_EPUB_SETTINGS, getChapter} from '../epub-reader-helper';
import {EpubThemeUtil, EpubTheme} from '../epub-theme-util';
import {ReadingSessionService} from '../../../../shared/service/reading-session.service';
import {EpubTheme, EpubThemeUtil} from '../epub-theme-util';
import {PageTitleService} from "../../../../shared/service/page-title.service";
import {Tab, TabList, TabPanel, TabPanels, Tabs} from 'primeng/tabs';
import {IconField} from 'primeng/iconfield';
@@ -29,27 +30,7 @@ import {BookmarkViewDialogComponent} from './bookmark-view-dialog.component';
selector: 'app-epub-reader',
templateUrl: './epub-reader.component.html',
styleUrls: ['./epub-reader.component.scss'],
imports: [
CommonModule,
FormsModule,
Drawer,
Button,
Select,
ProgressSpinner,
Tooltip,
Slider,
PrimeTemplate,
Tabs,
TabList,
Tab,
TabPanels,
TabPanel,
IconField,
InputIcon,
BookmarkEditDialogComponent,
BookmarkViewDialogComponent,
InputText
],
imports: [CommonModule, FormsModule, Drawer, Button, Select, ProgressSpinner, Tooltip, Slider, PrimeTemplate, Tabs, TabList, Tab, TabPanels, TabPanel, IconField, InputIcon, BookmarkEditDialogComponent, BookmarkViewDialogComponent, InputText],
standalone: true
})
export class EpubReaderComponent implements OnInit, OnDestroy {
@@ -67,10 +48,8 @@ export class EpubReaderComponent implements OnInit, OnDestroy {
isAddingBookmark = false;
isDeletingBookmark = false;
isEditingBookmark = false;
isUpdatingPosition = false;
private routeSubscription?: Subscription;
// Bookmark Filter & View
filterText = '';
viewDialogVisible = false;
selectedBookmark: BookMark | null = null;
@@ -87,7 +66,6 @@ export class EpubReaderComponent implements OnInit, OnDestroy {
private isMouseInTopRegion = false;
private headerShownByMobileTouch = false;
// Properties for bookmark editing
editingBookmark: BookMark | null = null;
showEditBookmarkDialog = false;
@@ -132,12 +110,14 @@ export class EpubReaderComponent implements OnInit, OnDestroy {
];
private route = inject(ActivatedRoute);
private location = inject(Location);
private userService = inject(UserService);
private bookService = inject(BookService);
private messageService = inject(MessageService);
private ngZone = inject(NgZone);
private pageTitle = inject(PageTitleService);
private bookMarkService = inject(BookMarkService);
private readingSessionService = inject(ReadingSessionService);
epub!: Book;
@@ -249,7 +229,6 @@ export class EpubReaderComponent implements OnInit, OnDestroy {
get filteredBookmarks(): BookMark[] {
let filtered = this.bookmarks;
// Filter
if (this.filterText && this.filterText.trim()) {
const lowerFilter = this.filterText.toLowerCase().trim();
filtered = filtered.filter(b =>
@@ -258,9 +237,8 @@ export class EpubReaderComponent implements OnInit, OnDestroy {
);
}
// Sort: Priority ASC (1 is high), then CreatedAt DESC
return [...filtered].sort((a, b) => {
const priorityA = a.priority ?? 3; // Default to 3 (Normal) if undefined
const priorityA = a.priority ?? 3;
const priorityB = b.priority ?? 3;
if (priorityA !== priorityB) {
@@ -430,19 +408,37 @@ export class EpubReaderComponent implements OnInit, OnDestroy {
prevPage(): void {
if (this.rendition) {
this.rendition.prev();
this.rendition.prev().then(() => {
const location = this.rendition.currentLocation();
this.readingSessionService.updateProgress(
location?.start?.cfi,
this.progressPercentage
);
});
}
}
nextPage(): void {
if (this.rendition) {
this.rendition.next();
this.rendition.next().then(() => {
const location = this.rendition.currentLocation();
this.readingSessionService.updateProgress(
location?.start?.cfi,
this.progressPercentage
);
});
}
}
navigateToChapter(chapter: { label: string; href: string; level: number }): void {
if (this.book && chapter.href) {
this.book.rendition.display(chapter.href);
this.book.rendition.display(chapter.href).then(() => {
const location = this.rendition.currentLocation();
this.readingSessionService.updateProgress(
location?.start?.cfi,
this.progressPercentage
);
});
}
}
@@ -498,6 +494,11 @@ export class EpubReaderComponent implements OnInit, OnDestroy {
}
this.bookService.saveEpubProgress(this.epub.id, cfi, Math.round(percentage * 1000) / 10).subscribe();
this.readingSessionService.updateProgress(
location.start.cfi,
this.progressPercentage
);
});
this.book.ready.then(() => {
@@ -509,13 +510,26 @@ export class EpubReaderComponent implements OnInit, OnDestroy {
const cfi = location.end.cfi;
const percentage = this.book.locations.percentageFromCfi(cfi);
this.progressPercentage = Math.round(percentage * 1000) / 10;
this.readingSessionService.startSession(this.epub.id, "EPUB", location.start.cfi, this.progressPercentage);
}
}).catch(() => {
this.locationsReady = false;
const location = this.rendition.currentLocation();
if (location) {
this.readingSessionService.startSession(this.epub.id, "EPUB", location.start.cfi, this.progressPercentage);
}
});
}
ngOnDestroy(): void {
if (this.readingSessionService.isSessionActive()) {
this.readingSessionService.endSession(
this.currentCfi || undefined,
this.progressPercentage
);
}
this.routeSubscription?.unsubscribe();
if (this.rendition) {
@@ -555,18 +569,15 @@ export class EpubReaderComponent implements OnInit, OnDestroy {
const isBottomClick = clickY > screenHeight * 0.2;
if (isTopClick && !this.showHeader) {
// Touch top 20% - show header and mark as shown by mobile touch
this.showHeader = true;
this.headerShownByMobileTouch = true;
this.clearHeaderTimeout();
} else if (isBottomClick && this.showHeader && this.headerShownByMobileTouch) {
// Touch lower 80% - hide header
this.showHeader = false;
this.headerShownByMobileTouch = false;
this.clearHeaderTimeout();
}
} else {
// Desktop behavior
const isTopClick = clickY < screenHeight * 0.1;
if (isTopClick) {
this.showHeader = true;
@@ -615,7 +626,6 @@ export class EpubReaderComponent implements OnInit, OnDestroy {
return;
}
// Don't auto-hide on mobile if header was shown by touch
if (this.isMobileDevice() && this.headerShownByMobileTouch) {
return;
}
@@ -690,7 +700,6 @@ export class EpubReaderComponent implements OnInit, OnDestroy {
this.bookMarkService.createBookmark(request).subscribe({
next: (bookmark) => {
this.bookmarks.push(bookmark);
// Force array update for change detection if needed, but simple push works with getter usually if ref is stable
this.bookmarks = [...this.bookmarks];
this.updateBookmarkStatus();
this.messageService.add({
@@ -715,9 +724,8 @@ export class EpubReaderComponent implements OnInit, OnDestroy {
if (this.isDeletingBookmark) {
return;
}
// Simple confirmation using window.confirm for now, as consistent with UserManagementComponent behavior seen in linting
if (!confirm('Are you sure you want to delete this bookmark?')) {
return;
return;
}
this.isDeletingBookmark = true;
@@ -745,7 +753,13 @@ export class EpubReaderComponent implements OnInit, OnDestroy {
navigateToBookmark(bookmark: BookMark): void {
if (this.rendition && bookmark.cfi) {
this.rendition.display(bookmark.cfi);
this.rendition.display(bookmark.cfi).then(() => {
const location = this.rendition.currentLocation();
this.readingSessionService.updateProgress(
location?.start?.cfi,
this.progressPercentage
);
});
}
}
@@ -761,7 +775,7 @@ export class EpubReaderComponent implements OnInit, OnDestroy {
}
openEditBookmarkDialog(bookmark: BookMark): void {
this.editingBookmark = { ...bookmark };
this.editingBookmark = {...bookmark};
this.showEditBookmarkDialog = true;
}
@@ -777,7 +791,7 @@ export class EpubReaderComponent implements OnInit, OnDestroy {
const index = this.bookmarks.findIndex(b => b.id === this.editingBookmark!.id);
if (index !== -1) {
this.bookmarks[index] = updatedBookmark;
this.bookmarks = [...this.bookmarks]; // Trigger change detection for getter
this.bookmarks = [...this.bookmarks];
}
this.messageService.add({
severity: 'success',
@@ -785,7 +799,7 @@ export class EpubReaderComponent implements OnInit, OnDestroy {
detail: 'Bookmark updated successfully',
});
this.showEditBookmarkDialog = false;
this.editingBookmark = null; // Reset the editing bookmark after successful save
this.editingBookmark = null;
this.isEditingBookmark = false;
},
error: () => {
@@ -795,7 +809,7 @@ export class EpubReaderComponent implements OnInit, OnDestroy {
detail: 'Failed to update bookmark',
});
this.showEditBookmarkDialog = false;
this.editingBookmark = null; // Reset the editing bookmark even on error
this.editingBookmark = null;
this.isEditingBookmark = false;
}
});
@@ -803,41 +817,16 @@ export class EpubReaderComponent implements OnInit, OnDestroy {
onBookmarkCancel(): void {
this.showEditBookmarkDialog = false;
this.editingBookmark = null; // Reset the editing bookmark when dialog is cancelled
this.editingBookmark = null;
}
updateBookmarkPosition(bookmarkId: number): void {
if (!this.currentCfi || this.isUpdatingPosition) {
return;
closeReader(): void {
if (this.readingSessionService.isSessionActive()) {
this.readingSessionService.endSession(
this.currentCfi || undefined,
this.progressPercentage
);
}
this.isUpdatingPosition = true;
const updateRequest = {
cfi: this.currentCfi
};
this.bookMarkService.updateBookmark(bookmarkId, updateRequest).subscribe({
next: (updatedBookmark) => {
const index = this.bookmarks.findIndex(b => b.id === bookmarkId);
if (index !== -1) {
this.bookmarks[index] = updatedBookmark;
this.bookmarks = [...this.bookmarks];
}
this.messageService.add({
severity: 'success',
summary: 'Success',
detail: 'Bookmark position updated successfully',
});
this.isUpdatingPosition = false;
},
error: () => {
this.messageService.add({
severity: 'error',
summary: 'Error',
detail: 'Failed to update bookmark position',
});
this.isUpdatingPosition = false;
}
});
this.location.back();
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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}`);
}
}
}

View File

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