Files
bookorbit/server/src/modules/user-statistics/user-statistics.service.ts
T
neon 68134a9f85 fix(stats): use local timezone for peak reading hours
Pass the user's timezone settings to the database query to ensure the
Reading Clock widget groups peak reading hours correctly, instead of
defaulting to UTC.

Also includes a minor UI update to the KOReader plugin to expose a
download options button when multiple formats are available.

Closes #513
2026-07-03 05:11:14 +00:00

622 lines
27 KiB
TypeScript

import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common';
import type {
ChordDiagramData,
ReadingSessionSource,
ReadingSessionSourceBucket,
UserCompletionLatencyDistribution,
UserCompletionRaceBook,
UserCompletionTimelinePoint,
UserDailyReadingStat,
UserFavoriteDayStat,
UserGenreReadingTimeItem,
UserGoalTrajectory,
UserGoalTrajectoryPoint,
UserPeakHourStat,
UserProgressFunnelComparison,
UserProgressFunnel,
UserReadingPacePoint,
UserReadingSessionTimeline,
UserReadingSessionTimelineItem,
UserReadingSourceDistribution,
UserReadingSurvivalPoint,
UserSessionArchetypePoint,
UserStatisticsSummary,
} from '@bookorbit/types';
import { READING_SESSION_SOURCE_BUCKETS, emptySourceBucketRecord, toReadingSessionSourceBucket } from '@bookorbit/types';
import type { RequestUser } from '../../common/types/request-user';
import { StatsCache } from '../../common/cache/stats-cache';
import { resolveTimeZone } from '../../common/utils/timezone.utils';
import type { UserDailyReadingQueryDto } from './dto/user-daily-reading-query.dto';
import type { UserGoalTrajectoryQueryDto } from './dto/user-goal-trajectory-query.dto';
import type { UserSessionTimelineQueryDto } from './dto/user-session-timeline-query.dto';
import type { UpdateUserSessionTimelineSessionDto } from './dto/update-user-session-timeline-session.dto';
import type { UserStatisticsFilterQueryDto } from './dto/user-statistics-filter-query.dto';
import { UserStatisticsRepository } from './user-statistics.repository';
const HEATMAP_DEFAULT_DAYS = 365;
const BEHAVIOR_DEFAULT_DAYS = 365;
const COMPLETION_TIMELINE_DEFAULT_DAYS = 1825;
const GOAL_TRAJECTORY_DEFAULT_DAYS = 365;
const GOAL_TRAJECTORY_DEFAULT_GOAL_BOOKS = 12;
const PROGRESS_FUNNEL_DEFAULT_DAYS = 365;
const SESSION_TIMELINE_MAX_SESSIONS = 3000;
const COMPLETION_LATENCY_DEFAULT_DAYS = 1825;
const GENRE_READING_TIME_DEFAULT_DAYS = 365;
const READING_PACE_DEFAULT_DAYS = 1825;
const READING_SURVIVAL_DEFAULT_DAYS = 1825;
const COMPLETION_RACE_DEFAULT_DAYS = 1825;
const SESSION_ARCHETYPES_DEFAULT_DAYS = 365;
const USER_STATS_CACHE_TTL_MS = 300_000;
const USER_STATS_CACHE_MAX_ENTRIES = 2_000;
@Injectable()
export class UserStatisticsService {
private readonly cache = new StatsCache({ ttlMs: USER_STATS_CACHE_TTL_MS, maxEntries: USER_STATS_CACHE_MAX_ENTRIES });
constructor(private readonly repo: UserStatisticsRepository) {}
private startOfUtcDay(date: Date): Date {
return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()));
}
private startOfUtcMonth(date: Date): Date {
return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), 1));
}
private startOfUtcIsoWeek(date: Date): Date {
const start = this.startOfUtcDay(date);
const day = (start.getUTCDay() + 6) % 7; // Mon=0 ... Sun=6
start.setUTCDate(start.getUTCDate() - day);
return start;
}
private getUtcIsoWeekYear(date: Date): number {
const d = this.startOfUtcDay(date);
const day = (d.getUTCDay() + 6) % 7;
d.setUTCDate(d.getUTCDate() - day + 3); // Thursday
return d.getUTCFullYear();
}
private getUtcIsoWeek(date: Date): number {
const d = this.startOfUtcDay(date);
const day = (d.getUTCDay() + 6) % 7;
d.setUTCDate(d.getUTCDate() - day + 3); // Thursday
const firstThursday = new Date(Date.UTC(d.getUTCFullYear(), 0, 4));
const firstDay = (firstThursday.getUTCDay() + 6) % 7;
firstThursday.setUTCDate(firstThursday.getUTCDate() - firstDay + 3);
return 1 + Math.round((d.getTime() - firstThursday.getTime()) / 604_800_000);
}
private getUtcIsoWeeksInYear(year: number): number {
return this.getUtcIsoWeek(new Date(Date.UTC(year, 11, 28)));
}
private getUtcIsoWeekStart(year: number, week: number): Date {
const jan4 = new Date(Date.UTC(year, 0, 4));
const week1Start = this.startOfUtcIsoWeek(jan4);
const weekStart = new Date(week1Start);
weekStart.setUTCDate(week1Start.getUTCDate() + (week - 1) * 7);
return weekStart;
}
private sinceDateForDays(days: number): Date {
const normalized = Number.isFinite(days) ? Math.max(1, Math.floor(days)) : 1;
const startToday = this.startOfUtcDay(new Date());
startToday.setUTCDate(startToday.getUTCDate() - (normalized - 1));
return startToday;
}
private formatDayKey(date: Date): string {
return date.toISOString().slice(0, 10);
}
private roundProgressDelta(value: number): number {
return Number(value.toFixed(4));
}
private percentile(sorted: number[], p: number): number | null {
if (sorted.length === 0) return null;
if (sorted.length === 1) return Number(sorted[0].toFixed(1));
const rank = (p / 100) * (sorted.length - 1);
const low = Math.floor(rank);
const high = Math.ceil(rank);
const weight = rank - low;
const value = sorted[low] * (1 - weight) + sorted[high] * weight;
return Number(value.toFixed(1));
}
private normalizeLibraryIds(libraryIds?: number[]): string {
return [...(libraryIds ?? [])].sort((a, b) => a - b).join(',');
}
private buildUserCacheKey(metric: string, user: RequestUser, params: Record<string, string | number | undefined>): string {
const pieces = Object.entries(params)
.filter((entry): entry is [string, string | number] => entry[1] !== undefined)
.sort(([a], [b]) => a.localeCompare(b))
.map(([key, value]) => `${key}=${value}`);
return `${metric}|su=${user.isSuperuser ? 1 : 0}|${pieces.join('|')}`;
}
async getSummary(user: RequestUser, query: UserStatisticsFilterQueryDto): Promise<UserStatisticsSummary> {
const key = this.buildUserCacheKey('summary', user, { libraries: this.normalizeLibraryIds(query.libraryIds) });
return this.cache.get(String(user.id), key, async () => {
const summary = await this.repo.getSummary(user.id, user.isSuperuser, query.libraryIds);
return {
...summary,
meanProgressPercent: Number(summary.meanProgressPercent.toFixed(2)),
};
});
}
async getDailyReading(user: RequestUser, query: UserDailyReadingQueryDto): Promise<UserDailyReadingStat[]> {
const days = query.days ?? 365;
const key = this.buildUserCacheKey('daily-reading', user, { libraries: this.normalizeLibraryIds(query.libraryIds), days });
return this.cache.get(String(user.id), key, async () => {
const items = await this.repo.getDailyReadingStats(user.id, user.isSuperuser, query.libraryIds, days);
return items.map((item) => ({
...item,
progressDelta: this.roundProgressDelta(item.progressDelta),
}));
});
}
async getReadingHeatmap(user: RequestUser, query: UserDailyReadingQueryDto): Promise<UserDailyReadingStat[]> {
const days = query.days ?? HEATMAP_DEFAULT_DAYS;
const key = this.buildUserCacheKey('reading-heatmap', user, { libraries: this.normalizeLibraryIds(query.libraryIds), days });
return this.cache.get(String(user.id), key, async () => {
const items = await this.repo.getDailyReadingStats(user.id, user.isSuperuser, query.libraryIds, days);
const bySourceRows = await this.repo.getDailyReadingSecondsBySource(user.id, user.isSuperuser, query.libraryIds, days);
const byDay = new Map(items.map((item) => [item.day, item]));
const bySourceByDay = new Map<string, Record<ReadingSessionSourceBucket, number>>();
for (const row of bySourceRows) {
let record = bySourceByDay.get(row.day);
if (!record) {
record = emptySourceBucketRecord();
bySourceByDay.set(row.day, record);
}
record[toReadingSessionSourceBucket(row.source)] += row.readingSeconds;
}
const start = this.sinceDateForDays(days);
const end = this.startOfUtcDay(new Date());
const result: UserDailyReadingStat[] = [];
for (const cursor = new Date(start); cursor <= end; cursor.setUTCDate(cursor.getUTCDate() + 1)) {
const day = this.formatDayKey(cursor);
const value = byDay.get(day);
result.push({
day,
readingSeconds: value?.readingSeconds ?? 0,
progressDelta: this.roundProgressDelta(value?.progressDelta ?? 0),
eventsCount: value?.eventsCount ?? 0,
bySource: bySourceByDay.get(day) ?? emptySourceBucketRecord(),
});
}
return result;
});
}
async getReadingSourceDistribution(user: RequestUser, query: UserDailyReadingQueryDto): Promise<UserReadingSourceDistribution> {
const days = query.days ?? BEHAVIOR_DEFAULT_DAYS;
const key = this.buildUserCacheKey('source-distribution', user, { libraries: this.normalizeLibraryIds(query.libraryIds), days });
return this.cache.get(String(user.id), key, async () => {
const rows = await this.repo.getDailyReadingSecondsBySource(user.id, user.isSuperuser, query.libraryIds, days);
const totals = emptySourceBucketRecord();
for (const row of rows) {
totals[toReadingSessionSourceBucket(row.source)] += row.readingSeconds;
}
const slices = READING_SESSION_SOURCE_BUCKETS.filter((bucket) => totals[bucket] > 0).map((bucket) => ({
bucket,
readingSeconds: totals[bucket],
}));
const totalSeconds = slices.reduce((sum, slice) => sum + slice.readingSeconds, 0);
return { totalSeconds, slices };
});
}
async getPeakReadingHours(user: RequestUser, query: UserDailyReadingQueryDto): Promise<UserPeakHourStat[]> {
const days = query.days ?? BEHAVIOR_DEFAULT_DAYS;
const timeZone = resolveTimeZone((user.settings as { timezone?: unknown } | undefined)?.timezone, 'UTC');
const key = this.buildUserCacheKey('peak-hours', user, { libraries: this.normalizeLibraryIds(query.libraryIds), days, timeZone });
return this.cache.get(String(user.id), key, async () => {
const rows = await this.repo.getPeakReadingHours(user.id, user.isSuperuser, query.libraryIds, days, timeZone);
const byHour = new Map<
number,
{ readingSeconds: number; eventsCount: number; byFormat: Record<string, number>; bySource: Record<ReadingSessionSourceBucket, number> }
>();
for (const row of rows) {
if (!byHour.has(row.hour)) {
byHour.set(row.hour, { readingSeconds: 0, eventsCount: 0, byFormat: {}, bySource: emptySourceBucketRecord() });
}
const entry = byHour.get(row.hour)!;
entry.readingSeconds += row.readingSeconds;
entry.eventsCount += row.eventsCount;
// Grouping now splits each hour by (format, source), so accumulate rather than assign.
entry.byFormat[row.format] = (entry.byFormat[row.format] ?? 0) + row.readingSeconds;
entry.bySource[toReadingSessionSourceBucket(row.source)] += row.readingSeconds;
}
return Array.from({ length: 24 }, (_, hour) => {
const entry = byHour.get(hour);
return {
hour,
readingSeconds: entry?.readingSeconds ?? 0,
eventsCount: entry?.eventsCount ?? 0,
byFormat: entry?.byFormat ?? {},
bySource: entry?.bySource ?? emptySourceBucketRecord(),
};
});
});
}
async getFavoriteReadingDays(user: RequestUser, query: UserDailyReadingQueryDto): Promise<UserFavoriteDayStat[]> {
const days = query.days ?? BEHAVIOR_DEFAULT_DAYS;
const key = this.buildUserCacheKey('favorite-days', user, { libraries: this.normalizeLibraryIds(query.libraryIds), days });
return this.cache.get(String(user.id), key, async () => {
const rows = await this.repo.getFavoriteReadingDays(user.id, user.isSuperuser, query.libraryIds, days);
const byDay = new Map<
number,
{ readingSeconds: number; eventsCount: number; byFormat: Record<string, number>; bySource: Record<ReadingSessionSourceBucket, number> }
>();
for (const row of rows) {
let entry = byDay.get(row.dayOfWeek);
if (!entry) {
entry = { readingSeconds: 0, eventsCount: 0, byFormat: {}, bySource: emptySourceBucketRecord() };
byDay.set(row.dayOfWeek, entry);
}
entry.readingSeconds += row.readingSeconds;
entry.eventsCount += row.eventsCount;
entry.byFormat[row.format] = (entry.byFormat[row.format] ?? 0) + row.readingSeconds;
entry.bySource[toReadingSessionSourceBucket(row.source)] += row.readingSeconds;
}
return Array.from({ length: 7 }, (_, dayOfWeek) => {
const entry = byDay.get(dayOfWeek);
return {
dayOfWeek,
readingSeconds: entry?.readingSeconds ?? 0,
eventsCount: entry?.eventsCount ?? 0,
byFormat: entry?.byFormat ?? {},
bySource: entry?.bySource ?? emptySourceBucketRecord(),
};
});
});
}
private toTimelineItem(row: {
sessionId: number;
bookId: number;
bookTitle: string | null;
bookFormat: string | null;
source: ReadingSessionSource | null;
startedAt: Date;
endedAt: Date;
durationSeconds: number;
}): UserReadingSessionTimelineItem {
return {
sessionId: row.sessionId,
bookId: row.bookId,
bookTitle: row.bookTitle,
bookFormat: row.bookFormat,
bookSource: toReadingSessionSourceBucket(row.source),
startedAt: row.startedAt.toISOString(),
endedAt: row.endedAt.toISOString(),
durationSeconds: row.durationSeconds,
};
}
async getSessionTimeline(user: RequestUser, query: UserSessionTimelineQueryDto): Promise<UserReadingSessionTimeline> {
const now = new Date();
const defaultYear = this.getUtcIsoWeekYear(now);
const defaultWeek = this.getUtcIsoWeek(now);
const year = query.year ?? defaultYear;
const weeksInYear = this.getUtcIsoWeeksInYear(year);
const week = Math.min(Math.max(query.week ?? defaultWeek, 1), weeksInYear);
const weekStart = this.getUtcIsoWeekStart(year, week);
const weekEndExclusive = new Date(weekStart);
weekEndExclusive.setUTCDate(weekEndExclusive.getUTCDate() + 7);
const key = this.buildUserCacheKey('session-timeline', user, {
libraries: this.normalizeLibraryIds(query.libraryIds),
year,
week,
});
return this.cache.get(String(user.id), key, async () => {
const rows = await this.repo.getSessionTimelineItems(
user.id,
user.isSuperuser,
query.libraryIds,
weekStart,
weekEndExclusive,
SESSION_TIMELINE_MAX_SESSIONS,
);
const weekEnd = new Date(weekStart);
weekEnd.setUTCDate(weekEnd.getUTCDate() + 6);
return {
year,
week,
weekStart: this.formatDayKey(weekStart),
weekEnd: this.formatDayKey(weekEnd),
items: rows.map((row) => this.toTimelineItem(row)),
};
});
}
async updateSessionTimelineSession(
user: RequestUser,
sessionId: number,
dto: UpdateUserSessionTimelineSessionDto,
query: UserStatisticsFilterQueryDto,
): Promise<UserReadingSessionTimelineItem> {
const startedAt = new Date(dto.startedAt);
const endedAt = new Date(dto.endedAt);
if (!Number.isFinite(startedAt.getTime()) || !Number.isFinite(endedAt.getTime())) {
throw new BadRequestException('Invalid session timestamps');
}
if (endedAt <= startedAt) {
throw new BadRequestException('Session end time must be after start time');
}
const existing = await this.repo.getSessionTimelineSessionById(user.id, user.isSuperuser, query.libraryIds, sessionId);
if (!existing) {
throw new NotFoundException('Reading session not found');
}
const proposedDuration = Math.round((endedAt.getTime() - startedAt.getTime()) / 1000);
if (proposedDuration !== existing.durationSeconds) {
throw new BadRequestException('Dragging can move a session only; duration cannot change');
}
const moveResult = await this.repo.moveSessionTimelineSessionAtomic(
user.id,
sessionId,
existing.libraryId,
existing.startedAt,
existing.endedAt,
startedAt,
endedAt,
proposedDuration,
resolveTimeZone((user.settings as { timezone?: unknown } | undefined)?.timezone, 'UTC'),
);
if (moveResult.conflict) {
const conflictStart = moveResult.conflict.startedAt.toISOString();
throw new ConflictException(`Session overlaps with #${moveResult.conflict.sessionId} starting at ${conflictStart}`);
}
if (!moveResult.updated) {
throw new NotFoundException('Reading session not found');
}
this.cache.clearForScope(String(user.id));
return this.toTimelineItem(moveResult.updated);
}
async getCompletionTimeline(user: RequestUser, query: UserDailyReadingQueryDto): Promise<UserCompletionTimelinePoint[]> {
const days = query.days ?? COMPLETION_TIMELINE_DEFAULT_DAYS;
const key = this.buildUserCacheKey('completion-timeline', user, { libraries: this.normalizeLibraryIds(query.libraryIds), days });
return this.cache.get(String(user.id), key, async () => {
const rows = await this.repo.getCompletionTimeline(user.id, user.isSuperuser, query.libraryIds, days);
const byMonth = new Map(rows.map((row) => [`${row.year}-${row.month}`, row.count]));
const start = this.startOfUtcMonth(this.sinceDateForDays(days));
const end = this.startOfUtcMonth(new Date());
const result: UserCompletionTimelinePoint[] = [];
for (const cursor = new Date(start); cursor <= end; cursor.setUTCMonth(cursor.getUTCMonth() + 1)) {
const year = cursor.getUTCFullYear();
const month = cursor.getUTCMonth() + 1;
result.push({
year,
month,
count: byMonth.get(`${year}-${month}`) ?? 0,
});
}
return result;
});
}
async getGoalTrajectory(user: RequestUser, query: UserGoalTrajectoryQueryDto): Promise<UserGoalTrajectory> {
const days = query.days ?? GOAL_TRAJECTORY_DEFAULT_DAYS;
const goalBooks = query.goalBooks ?? GOAL_TRAJECTORY_DEFAULT_GOAL_BOOKS;
const key = this.buildUserCacheKey('goal-trajectory', user, {
libraries: this.normalizeLibraryIds(query.libraryIds),
days,
goalBooks,
});
return this.cache.get(String(user.id), key, async () => {
const rows = await this.repo.getMonthlyCompletions(user.id, user.isSuperuser, query.libraryIds, days);
const byMonth = new Map(rows.map((row) => [`${row.year}-${row.month}`, row.count]));
const start = this.startOfUtcMonth(this.sinceDateForDays(days));
const end = this.startOfUtcMonth(new Date());
const points: UserGoalTrajectoryPoint[] = [];
const targetPerMonth = goalBooks / 12;
let actualCumulative = 0;
let monthIndex = 0;
for (const cursor = new Date(start); cursor <= end; cursor.setUTCMonth(cursor.getUTCMonth() + 1)) {
monthIndex += 1;
const year = cursor.getUTCFullYear();
const month = cursor.getUTCMonth() + 1;
const monthActual = byMonth.get(`${year}-${month}`) ?? 0;
actualCumulative += monthActual;
points.push({
year,
month,
actualCumulative,
targetCumulative: Number((targetPerMonth * monthIndex).toFixed(2)),
});
}
return { goalBooks, points };
});
}
async getProgressFunnel(user: RequestUser, query: UserDailyReadingQueryDto): Promise<UserProgressFunnelComparison> {
const days = query.days ?? PROGRESS_FUNNEL_DEFAULT_DAYS;
const comparePrevious = query.comparePrevious ?? false;
const key = this.buildUserCacheKey('progress-funnel', user, {
libraries: this.normalizeLibraryIds(query.libraryIds),
days,
comparePrevious: comparePrevious ? 1 : 0,
});
return this.cache.get(String(user.id), key, async () => {
const currentSince = this.sinceDateForDays(days);
const currentUntilExclusive = new Date(this.startOfUtcDay(new Date()));
currentUntilExclusive.setUTCDate(currentUntilExclusive.getUTCDate() + 1);
const current = await this.repo.getProgressFunnelInRange(user.id, user.isSuperuser, query.libraryIds, currentSince, currentUntilExclusive);
let previous: UserProgressFunnel | null = null;
if (comparePrevious) {
const previousSince = new Date(currentSince);
previousSince.setUTCDate(previousSince.getUTCDate() - days);
previous = await this.repo.getProgressFunnelInRange(user.id, user.isSuperuser, query.libraryIds, previousSince, currentSince);
}
return {
days,
current,
previous,
};
});
}
async getCompletionLatency(user: RequestUser, query: UserDailyReadingQueryDto): Promise<UserCompletionLatencyDistribution> {
const days = query.days ?? COMPLETION_LATENCY_DEFAULT_DAYS;
const key = this.buildUserCacheKey('completion-latency', user, { libraries: this.normalizeLibraryIds(query.libraryIds), days });
return this.cache.get(String(user.id), key, async () => {
const values = await this.repo.getCompletionLatencyDays(user.id, user.isSuperuser, query.libraryIds, days);
const sorted = [...values].sort((a, b) => a - b);
const buckets = [
{ label: '0-7d', minDays: 0, maxDays: 7, count: 0 },
{ label: '8-30d', minDays: 8, maxDays: 30, count: 0 },
{ label: '31-90d', minDays: 31, maxDays: 90, count: 0 },
{ label: '91-180d', minDays: 91, maxDays: 180, count: 0 },
{ label: '181-365d', minDays: 181, maxDays: 365, count: 0 },
{ label: '366-730d', minDays: 366, maxDays: 730, count: 0 },
{ label: '731d+', minDays: 731, maxDays: null, count: 0 },
];
for (const value of sorted) {
const rounded = Math.round(value);
const target =
buckets.find((bucket) => rounded >= bucket.minDays && (bucket.maxDays === null || rounded <= bucket.maxDays)) ??
buckets[buckets.length - 1];
target.count += 1;
}
return {
totalCompletions: sorted.length,
medianDays: this.percentile(sorted, 50),
percentile75Days: this.percentile(sorted, 75),
percentile90Days: this.percentile(sorted, 90),
buckets,
};
});
}
async getReadingSurvival(user: RequestUser, query: UserDailyReadingQueryDto): Promise<UserReadingSurvivalPoint[]> {
const days = query.days ?? READING_SURVIVAL_DEFAULT_DAYS;
const key = this.buildUserCacheKey('reading-survival', user, { libraries: this.normalizeLibraryIds(query.libraryIds), days });
return this.cache.get(String(user.id), key, async () => {
const values = await this.repo.getReadingSurvivalMaxProgress(user.id, user.isSuperuser, query.libraryIds, days);
const total = values.length;
const thresholds = Array.from({ length: 21 }, (_, i) => i * 5);
return thresholds.map((threshold) => {
const survivedCount = values.filter((v) => v >= threshold).length;
return {
threshold,
survivedCount,
survivedPct: total > 0 ? Number(((survivedCount / total) * 100).toFixed(1)) : 0,
};
});
});
}
async getCompletionRace(user: RequestUser, query: UserDailyReadingQueryDto): Promise<UserCompletionRaceBook[]> {
const days = query.days ?? COMPLETION_RACE_DEFAULT_DAYS;
const key = this.buildUserCacheKey('completion-race', user, { libraries: this.normalizeLibraryIds(query.libraryIds), days });
return this.cache.get(String(user.id), key, async () => {
const rows = await this.repo.getCompletionRaceRawSessions(user.id, user.isSuperuser, query.libraryIds, days);
const byBook = new Map<number, { title: string; sessions: { startedAt: Date; endProgress: number }[] }>();
for (const row of rows) {
if (!byBook.has(row.bookId)) {
byBook.set(row.bookId, { title: row.title ?? `Book ${row.bookId}`, sessions: [] });
}
byBook.get(row.bookId)!.sessions.push({ startedAt: row.startedAt, endProgress: row.endProgress });
}
const result: UserCompletionRaceBook[] = [];
for (const [bookId, { title, sessions }] of byBook.entries()) {
if (sessions.length < 2) continue;
const firstMs = sessions[0].startedAt.getTime();
result.push({
bookId,
title: title.length > 40 ? `${title.slice(0, 37)}...` : title,
points: sessions.map((s) => ({
daysSinceStart: Number(((s.startedAt.getTime() - firstMs) / 86_400_000).toFixed(2)),
progress: Number(s.endProgress.toFixed(1)),
})),
});
}
return result;
});
}
async getSessionArchetypes(user: RequestUser, query: UserDailyReadingQueryDto): Promise<UserSessionArchetypePoint[]> {
const days = query.days ?? SESSION_ARCHETYPES_DEFAULT_DAYS;
const key = this.buildUserCacheKey('session-archetypes', user, { libraries: this.normalizeLibraryIds(query.libraryIds), days });
return this.cache.get(String(user.id), key, () => this.repo.getSessionArchetypePoints(user.id, user.isSuperuser, query.libraryIds, days));
}
async getGenreReadingTime(user: RequestUser, query: UserDailyReadingQueryDto): Promise<UserGenreReadingTimeItem[]> {
const days = query.days ?? GENRE_READING_TIME_DEFAULT_DAYS;
const key = this.buildUserCacheKey('genre-reading-time', user, { libraries: this.normalizeLibraryIds(query.libraryIds), days });
return this.cache.get(String(user.id), key, async () => {
const rows = await this.repo.getGenreReadingTime(user.id, user.isSuperuser, query.libraryIds, days);
const byGenre = new Map<string, { readingSeconds: number; bySource: Record<ReadingSessionSourceBucket, number> }>();
for (const row of rows) {
let entry = byGenre.get(row.genre);
if (!entry) {
entry = { readingSeconds: 0, bySource: emptySourceBucketRecord() };
byGenre.set(row.genre, entry);
}
entry.readingSeconds += row.readingSeconds;
entry.bySource[toReadingSessionSourceBucket(row.source)] += row.readingSeconds;
}
return Array.from(byGenre, ([genre, entry]) => ({ genre, readingSeconds: entry.readingSeconds, bySource: entry.bySource }))
.sort((a, b) => b.readingSeconds - a.readingSeconds)
.slice(0, 30);
});
}
async getReadingPace(user: RequestUser, query: UserDailyReadingQueryDto): Promise<UserReadingPacePoint[]> {
const days = query.days ?? READING_PACE_DEFAULT_DAYS;
const key = this.buildUserCacheKey('reading-pace', user, { libraries: this.normalizeLibraryIds(query.libraryIds), days });
return this.cache.get(String(user.id), key, () => this.repo.getReadingPacePoints(user.id, user.isSuperuser, query.libraryIds, days));
}
async getAuthorGenreChord(user: RequestUser, query: UserDailyReadingQueryDto): Promise<ChordDiagramData> {
const days = query.days ?? 1825;
const key = this.buildUserCacheKey('author-genre-chord', user, { libraries: this.normalizeLibraryIds(query.libraryIds), days });
return this.cache.get(String(user.id), key, () => this.repo.getAuthorGenreChord(user.id, user.isSuperuser, query.libraryIds, days));
}
async recomputeRecentDailyStats(days = 2) {
const result = await this.repo.recomputeRecentDailyStats(days);
this.cache.clear();
return result;
}
}