feat(annotations): add bounded dashboard highlights route

Return a user-scoped highlights shelf while preserving the existing single-highlight endpoint. Align calendar dashboard figures with each reader's timezone and completed attempts.
This commit is contained in:
neon
2026-09-28 23:50:27 +00:00
parent a35e222cb2
commit 826584e5da
11 changed files with 902 additions and 230 deletions
@@ -15,11 +15,47 @@ import {
computeSpeedScore,
computeTimeScore,
computeVarietyScore,
daysBetweenDateKeys,
findEligibleChallenges,
pickAnnotationIndex,
resolveReaderClock,
selectChallenge,
} from './dashboard-widget.calculations';
describe('resolveReaderClock', () => {
it("reads today, the month and the year off the reader's own clock", () => {
// 7:58 PM on Sep 27 in Denver is already Sep 28 in UTC.
expect(resolveReaderClock('America/Denver', new Date('2026-09-28T01:58:00Z'))).toEqual({
timeZone: 'America/Denver',
today: '2026-09-27',
year: 2026,
month: 9,
});
});
it("keeps New Year's Eve in the old year west of UTC", () => {
expect(resolveReaderClock('America/Denver', new Date('2027-01-01T03:00:00Z'))).toMatchObject({ today: '2026-12-31', year: 2026, month: 12 });
});
it('moves to the next day east of UTC before UTC does', () => {
expect(resolveReaderClock('Asia/Tokyo', new Date('2026-09-27T20:00:00Z'))).toMatchObject({ today: '2026-09-28' });
});
it('falls back to UTC when no zone is set or the zone is invalid', () => {
const now = new Date('2026-09-28T01:58:00Z');
expect(resolveReaderClock(undefined, now)).toMatchObject({ timeZone: 'UTC', today: '2026-09-28' });
expect(resolveReaderClock('Not/AZone', now)).toMatchObject({ timeZone: 'UTC', today: '2026-09-28' });
});
});
describe('daysBetweenDateKeys', () => {
it('counts calendar days, including across a leap day', () => {
expect(daysBetweenDateKeys('2026-01-01', '2026-09-27')).toBe(269);
expect(daysBetweenDateKeys('2026-01-01', '2027-01-01')).toBe(365);
expect(daysBetweenDateKeys('2028-01-01', '2029-01-01')).toBe(366);
});
});
describe('computeDailySeed', () => {
it('returns a non-negative integer', () => {
const seed = computeDailySeed(42, '2026-04-28');
@@ -480,16 +516,14 @@ describe('Diversity Score calculations', () => {
describe('Reading Rhythm calculations', () => {
describe('buildDaysSeries', () => {
it('fills missing days with zeroes', () => {
const today = new Date('2026-04-28T12:00:00Z');
const result = buildDaysSeries([], today, 7);
const result = buildDaysSeries([], '2026-04-28', 7);
expect(result).toHaveLength(7);
expect(result.every((d) => d.readingSeconds === 0)).toBe(true);
});
it('maps existing data to correct dates', () => {
const today = new Date('2026-04-28T12:00:00Z');
const data = [{ day: '2026-04-28', readingSeconds: 600 }];
const result = buildDaysSeries(data, today, 3);
const result = buildDaysSeries(data, '2026-04-28', 3);
expect(result).toHaveLength(3);
expect(result[2]!.date).toBe('2026-04-28');
@@ -498,11 +532,26 @@ describe('Reading Rhythm calculations', () => {
});
it('orders oldest to newest', () => {
const today = new Date('2026-04-28T12:00:00Z');
const result = buildDaysSeries([], today, 3);
const result = buildDaysSeries([], '2026-04-28', 3);
expect(result[0]!.date).toBe('2026-04-26');
expect(result[2]!.date).toBe('2026-04-28');
});
it('ends on the given local day and ignores rows after it', () => {
const data = [
{ day: '2026-09-27', readingSeconds: 900 },
{ day: '2026-09-28', readingSeconds: 60 },
];
const result = buildDaysSeries(data, '2026-09-27', 2);
expect(result).toEqual([
{ date: '2026-09-26', readingSeconds: 0 },
{ date: '2026-09-27', readingSeconds: 900 },
]);
});
it('steps across month and year boundaries', () => {
expect(buildDaysSeries([], '2027-01-01', 3).map((d) => d.date)).toEqual(['2026-12-30', '2026-12-31', '2027-01-01']);
});
});
describe('computeRhythm', () => {
@@ -9,10 +9,33 @@ import type {
YearProjectionWidgetData,
} from '@bookorbit/types';
import { addDateKeyDays } from '../../common/utils/reading-daily-stats.utils';
import { resolveTimeZone, toDateKeyInTimeZone } from '../../common/utils/timezone.utils';
// ── Date Helpers ─────────────────────────────────────────────────────
export function formatDay(date: Date): string {
return date.toISOString().slice(0, 10);
/**
* The reader's own calendar: which day, month and year it is where they are.
*
* Daily reading stats and attempt dates are stored as the reader's local days, so a widget that
* asks "today" or "this year" of the server's UTC clock reads tomorrow's empty row every evening
* west of Greenwich. Falls back to UTC, as the rest of the server does, when no zone is set.
*/
export interface ReaderClock {
timeZone: string;
today: string;
year: number;
month: number;
}
export function resolveReaderClock(timeZoneSetting: unknown, now: Date = new Date()): ReaderClock {
const timeZone = resolveTimeZone(timeZoneSetting, 'UTC');
const today = toDateKeyInTimeZone(now, timeZone);
return { timeZone, today, year: Number(today.slice(0, 4)), month: Number(today.slice(5, 7)) };
}
export function daysBetweenDateKeys(from: string, to: string): number {
return Math.round((Date.parse(`${to}T00:00:00Z`) - Date.parse(`${from}T00:00:00Z`)) / 86_400_000);
}
export function computeLongestStreak(readDays: Set<string>): number {
@@ -38,14 +61,11 @@ export function computeLongestStreak(readDays: Set<string>): number {
return longest;
}
export function computeStreakData(readDays: Set<string>, today: Date): ReadingStreakWidgetData {
const todayStr = formatDay(today);
/** `today` is the reader's local date key, the same calendar the daily stats rows are keyed by. */
export function computeStreakData(readDays: Set<string>, today: string): ReadingStreakWidgetData {
const lastSevenDays: boolean[] = [];
for (let i = 6; i >= 0; i--) {
const d = new Date(today);
d.setUTCDate(d.getUTCDate() - i);
lastSevenDays.push(readDays.has(formatDay(d)));
lastSevenDays.push(readDays.has(addDateKeyDays(today, -i)));
}
if (readDays.size === 0) {
@@ -53,18 +73,17 @@ export function computeStreakData(readDays: Set<string>, today: Date): ReadingSt
}
let currentStreak = 0;
const startDate = new Date(today);
if (!readDays.has(todayStr)) {
startDate.setUTCDate(startDate.getUTCDate() - 1);
if (!readDays.has(formatDay(startDate))) {
let cursor = today;
if (!readDays.has(cursor)) {
cursor = addDateKeyDays(cursor, -1);
if (!readDays.has(cursor)) {
return { currentStreak: 0, longestStreak: computeLongestStreak(readDays), lastSevenDays };
}
}
const cursor = new Date(startDate);
while (readDays.has(formatDay(cursor))) {
while (readDays.has(cursor)) {
currentStreak++;
cursor.setUTCDate(cursor.getUTCDate() - 1);
cursor = addDateKeyDays(cursor, -1);
}
return { currentStreak, longestStreak: computeLongestStreak(readDays), lastSevenDays };
@@ -394,18 +413,17 @@ export function computeDiversityScore(
// ── Reading Rhythm Pulse ────────────────────────────────────────────
/** `today` is the reader's local date key; the series ends on it. */
export function buildDaysSeries(
dailyData: { day: string; readingSeconds: number }[],
today: Date,
today: string,
windowDays: number,
): { date: string; readingSeconds: number }[] {
const lookup = new Map(dailyData.map((d) => [d.day, d.readingSeconds]));
const result: { date: string; readingSeconds: number }[] = [];
for (let i = windowDays - 1; i >= 0; i--) {
const d = new Date(today);
d.setUTCDate(d.getUTCDate() - i);
const dateStr = d.toISOString().slice(0, 10);
const dateStr = addDateKeyDays(today, -i);
result.push({ date: dateStr, readingSeconds: lookup.get(dateStr) ?? 0 });
}
@@ -62,9 +62,50 @@ describe('DashboardWidgetRepository highlight queries', () => {
await expect(repository.getAnnotationCount(42, [])).resolves.toBe(0);
await expect(repository.getAnnotationByOffset(42, [], 0)).resolves.toBeNull();
await expect(repository.getHighlightsFromOtherBooks(42, [], 1, 2)).resolves.toEqual([]);
expect(db.select).not.toHaveBeenCalled();
});
it('selects one active highlight per other book in the same scoped pool', async () => {
const createdAt = new Date('2026-07-01T12:00:00.000Z');
const query = {
from: vi.fn(),
innerJoin: vi.fn(),
where: vi.fn(),
orderBy: vi.fn(),
limit: vi.fn(),
};
query.from.mockReturnValue(query);
query.innerJoin.mockReturnValue(query);
query.where.mockReturnValue(query);
query.orderBy.mockReturnValue(query);
query.limit.mockResolvedValue([
{ text: 'Other book', note: null, bookTitle: 'Other', bookId: 9, coverSource: null, chapterTitle: null, createdAt },
]);
const db = { selectDistinctOn: vi.fn().mockReturnValue(query) };
const repository = new DashboardWidgetRepository(db as never);
await expect(repository.getHighlightsFromOtherBooks(42, [7, 8], 5, 2)).resolves.toEqual([
{
text: 'Other book',
note: null,
bookTitle: 'Other',
bookId: 9,
hasCover: false,
chapterTitle: null,
createdAt: createdAt.toISOString(),
},
]);
expect(db.selectDistinctOn).toHaveBeenCalledWith([annotations.bookId], expect.any(Object));
const rendered = dialect.sqlToQuery(query.where.mock.calls[0]?.[0] as SQL);
expect(rendered.sql).toContain('"annotations"."user_id" = $1');
expect(rendered.sql).toContain('"annotations"."deleted_at" is null');
expect(rendered.sql).toContain('"books"."library_id" in ($2, $3)');
expect(rendered.sql).toContain('"annotations"."book_id" not in ($4)');
expect(rendered.params).toEqual([42, 7, 8, 5]);
expect(query.limit).toHaveBeenCalledWith(2);
});
it('counts only active annotations in the user and library scoped pool', async () => {
const query = makeCountQuery([{ count: 2 }]);
const db = { select: vi.fn().mockReturnValue(query) };
@@ -1,14 +1,25 @@
import { computeLongestStreak, computeStreakData, formatDay, resolveResumeModes } from './dashboard-widget.calculations';
import type { SQL } from 'drizzle-orm';
import { PgDialect } from 'drizzle-orm/pg-core';
describe('formatDay', () => {
it('formats a UTC date as YYYY-MM-DD', () => {
expect(formatDay(new Date('2025-03-15T12:00:00Z'))).toBe('2025-03-15');
});
import { readingAttempts } from '../../db/schema';
import { computeLongestStreak, computeStreakData, resolveResumeModes } from './dashboard-widget.calculations';
import { DashboardWidgetRepository } from './dashboard-widget.repository';
it('uses UTC date even near midnight', () => {
expect(formatDay(new Date('2025-01-01T23:59:59Z'))).toBe('2025-01-01');
});
});
const dialect = new PgDialect();
/** A drizzle-shaped select chain that resolves to `rows` however far it is built. */
function makeQuery(rows: unknown[]) {
const query: Record<string, ReturnType<typeof vi.fn>> & { then?: unknown } = {};
for (const method of ['from', 'innerJoin', 'leftJoin', 'where', 'groupBy', 'orderBy', 'limit']) {
query[method] = vi.fn(() => query);
}
query.then = (resolve: (value: unknown[]) => unknown, reject: (reason: unknown) => unknown) => Promise.resolve(rows).then(resolve, reject);
return query;
}
function render(fragment: unknown) {
return dialect.sqlToQuery(fragment as SQL);
}
describe('computeLongestStreak', () => {
it('returns 0 for empty set', () => {
@@ -42,7 +53,7 @@ describe('computeLongestStreak', () => {
describe('computeStreakData', () => {
it('returns all zeros and false for empty reading days', () => {
const result = computeStreakData(new Set(), new Date('2025-07-10T12:00:00Z'));
const result = computeStreakData(new Set(), '2025-07-10');
expect(result).toEqual({
currentStreak: 0,
longestStreak: 0,
@@ -51,7 +62,7 @@ describe('computeStreakData', () => {
});
it('computes current streak starting from today', () => {
const today = new Date('2025-07-10T12:00:00Z');
const today = '2025-07-10';
const days = new Set(['2025-07-10', '2025-07-09', '2025-07-08']);
const result = computeStreakData(days, today);
@@ -60,7 +71,7 @@ describe('computeStreakData', () => {
});
it('computes current streak starting from yesterday if today has no reading', () => {
const today = new Date('2025-07-10T12:00:00Z');
const today = '2025-07-10';
const days = new Set(['2025-07-09', '2025-07-08', '2025-07-07']);
const result = computeStreakData(days, today);
@@ -69,7 +80,7 @@ describe('computeStreakData', () => {
});
it('returns currentStreak 0 when neither today nor yesterday has reading', () => {
const today = new Date('2025-07-10T12:00:00Z');
const today = '2025-07-10';
const days = new Set(['2025-07-05', '2025-07-06']);
const result = computeStreakData(days, today);
@@ -78,7 +89,7 @@ describe('computeStreakData', () => {
});
it('builds lastSevenDays array oldest-first', () => {
const today = new Date('2025-07-10T12:00:00Z');
const today = '2025-07-10';
const days = new Set(['2025-07-10', '2025-07-08', '2025-07-04']);
const result = computeStreakData(days, today);
@@ -95,7 +106,7 @@ describe('computeStreakData', () => {
});
it('handles a streak of exactly 1 day (today only)', () => {
const today = new Date('2025-07-10T12:00:00Z');
const today = '2025-07-10';
const days = new Set(['2025-07-10']);
const result = computeStreakData(days, today);
@@ -104,7 +115,7 @@ describe('computeStreakData', () => {
});
it('handles longest streak being different from current streak', () => {
const today = new Date('2025-07-10T12:00:00Z');
const today = '2025-07-10';
const days = new Set(['2025-07-10', '2025-06-01', '2025-06-02', '2025-06-03', '2025-06-04', '2025-06-05']);
const result = computeStreakData(days, today);
@@ -113,7 +124,7 @@ describe('computeStreakData', () => {
});
it('handles year boundary in streak', () => {
const today = new Date('2025-01-02T12:00:00Z');
const today = '2025-01-02';
const days = new Set(['2025-01-02', '2025-01-01', '2024-12-31', '2024-12-30']);
const result = computeStreakData(days, today);
@@ -198,3 +209,105 @@ describe('resolveResumeModes', () => {
});
});
});
describe('DashboardWidgetRepository.countCompletedBooks', () => {
it('counts every completed attempt between two local days rather than one status row per book', async () => {
const query = makeQuery([{ count: 44 }]);
const db = { select: vi.fn(() => query) };
const repository = new DashboardWidgetRepository(db as never);
await expect(repository.countCompletedBooks(1, [3, 4], '2026-01-01', '2027-01-01')).resolves.toBe(44);
expect(query.from).toHaveBeenCalledWith(readingAttempts);
const where = render(query.where!.mock.calls[0]![0]);
expect(where.sql).toContain('"reading_attempts"."outcome" = $2');
expect(where.sql).toContain('"reading_attempts"."deleted_at" is null');
expect(where.sql).toContain('"reading_attempts"."ended_on" >= $3');
expect(where.sql).toContain('"reading_attempts"."ended_on" < $4');
expect(where.sql).not.toContain('user_book_status');
expect(where.params).toEqual([1, 'completed', '2026-01-01', '2027-01-01', 3, 4]);
});
it('does not query without accessible libraries', async () => {
const db = { select: vi.fn() };
const repository = new DashboardWidgetRepository(db as never);
await expect(repository.countCompletedBooks(1, [], '2026-01-01', '2027-01-01')).resolves.toBe(0);
expect(db.select).not.toHaveBeenCalled();
});
});
describe('DashboardWidgetRepository.getYearProjectionData', () => {
const window = {
yearStartDay: '2026-01-01',
nextYearStartDay: '2027-01-01',
recentStartDay: '2026-08-29',
recentEndDay: '2026-09-28',
recentStart: new Date('2026-08-29T06:00:00Z'),
};
it('takes finished books from the same count as the reading goal, re-reads included', async () => {
const hoursQuery = makeQuery([{ hours: 22.75 }]);
const db = { select: vi.fn(() => hoursQuery) };
const repository = new DashboardWidgetRepository(db as never);
const count = vi.spyOn(repository, 'countCompletedBooks').mockResolvedValueOnce(44).mockResolvedValueOnce(2);
const pages = vi
.spyOn(repository as unknown as { estimatePagesReadSince: (...args: unknown[]) => Promise<unknown> }, 'estimatePagesReadSince')
.mockResolvedValue({ pagesRead: 4_008, avgPageCount: 350 });
const result = await repository.getYearProjectionData(1, [3], window);
expect(count).toHaveBeenNthCalledWith(1, 1, [3], '2026-01-01', '2027-01-01', undefined);
expect(count).toHaveBeenNthCalledWith(2, 1, [3], '2026-08-29', '2026-09-28', undefined);
expect(pages).toHaveBeenCalledWith(1, [3], window.recentStart, '2026-08-29', undefined);
expect(render(hoursQuery.where!.mock.calls[0]![0]).params).toEqual([1, 3, '2026-08-29']);
expect(result).toEqual({ booksCompletedYtd: 44, booksCompletedLast30Days: 2, pagesReadLast30Days: 4_008, hoursReadLast30Days: 22.75 });
});
it('estimates pages from sessions when the books finished in the window have no page count', async () => {
const db = { select: vi.fn() };
// In the order the queries are built: the page estimate's five, then the hours rollup.
db.select
.mockReturnValueOnce(makeQuery([{ avg: 350 }]))
// The one book finished in the window has no page count, which is what made the old
// finished-books-only figure report zero pages.
.mockReturnValueOnce(makeQuery([{ total: 0 }]))
.mockReturnValueOnce(makeQuery([{ total: 2_794 }]))
.mockReturnValueOnce(makeQuery([{ totalProgress: 346.95 }]))
.mockReturnValueOnce(makeQuery([{ totalProgress: 434.15 }]))
.mockReturnValueOnce(makeQuery([{ hours: 20 }]));
const repository = new DashboardWidgetRepository(db as never);
vi.spyOn(repository, 'countCompletedBooks').mockResolvedValue(0);
const result = await repository.getYearProjectionData(1, [3], window);
// 2,794 pages from sessions on books with a page count, plus 346.95% of a book at the
// reader's 350-page average from sessions on books without one.
expect(result.pagesReadLast30Days).toBe(2_794 + 1_214);
});
});
describe('DashboardWidgetRepository.getReadingDnaData', () => {
it("buckets the peak hour and the lookback's first day on the reader's clock", async () => {
const dailyQuery = makeQuery([]);
const peakQuery = makeQuery([{ hour: 20, total: 3_600 }]);
const db = { select: vi.fn() };
db.select
.mockReturnValueOnce(makeQuery([{ avg: 300, total: 10 }]))
.mockReturnValueOnce(makeQuery([{ count: 4 }]))
.mockReturnValueOnce(dailyQuery)
.mockReturnValueOnce(peakQuery)
.mockReturnValueOnce(makeQuery([{ totalPages: 0, totalSeconds: 0 }]))
.mockReturnValueOnce(makeQuery([{ totalProgress: 0, totalSeconds: 0 }]));
const repository = new DashboardWidgetRepository(db as never);
const result = await repository.getReadingDnaData(1, [3], new Date('2026-03-28T03:00:00Z'), 'America/Denver');
const hour = render((db.select.mock.calls[3]![0] as { hour: unknown }).hour);
expect(hour.sql).toBe('extract(hour from ("reading_sessions"."started_at" at time zone $1))::int');
expect(hour.params).toEqual(['America/Denver']);
expect(render(peakQuery.groupBy!.mock.calls[0]![0]).sql).toBe('1');
expect(render(dailyQuery.where!.mock.calls[0]![0]).params).toContain('2026-03-27');
expect(result.peakHour).toBe(20);
});
});
@@ -34,11 +34,32 @@ import {
userReadingDailyStats,
} from '../../db/schema';
import { buildContentFilterClauses } from '../../common/utils/content-filter-sql.utils';
import { computeLongestStreak, computeStreakData, formatDay, resolveResumeModes } from './dashboard-widget.calculations';
import { toDateKeyInTimeZone } from '../../common/utils/timezone.utils';
import { computeLongestStreak, computeStreakData, resolveResumeModes } from './dashboard-widget.calculations';
import type { ResumeModeFile } from './dashboard-widget.calculations';
type Db = NodePgDatabase<typeof schema>;
/** The reader's current month: the instant it began, its first local day, and today's local day. */
export interface ReaderMonth {
start: Date;
startDay: string;
today: string;
}
/**
* The year projection's two windows on the reader's calendar. Day keys are local dates and the
* `*EndDay` / `nextYearStartDay` bounds are exclusive; `recentStart` is the instant
* `recentStartDay` began where the reader is.
*/
export interface YearProjectionWindow {
yearStartDay: string;
nextYearStartDay: string;
recentStartDay: string;
recentEndDay: string;
recentStart: Date;
}
const CURRENTLY_READING_LIMIT = 10;
const DEFAULT_VIRTUAL_PAGE_COUNT = 300;
@@ -50,11 +71,25 @@ export class DashboardWidgetRepository {
return contentFilters ? buildContentFilterClauses(contentFilters, this.db) : [];
}
async getCompletedBooksThisYear(userId: number, accessibleLibraryIds: number[], contentFilters?: ContentFilterRules): Promise<number> {
/**
* Books finished from `fromDay` up to, not including, `untilDay`: both are the reader's local
* date keys, which is what `endedOn` already stores.
*
* Every widget that counts finished books goes through here, and Activity counts the same
* completed attempts, so no two surfaces can disagree. The per-book status row cannot stand in
* for it: it keeps only the latest finish, so a re-read counts once, and while a re-read is
* under way it holds no finish at all, so that book's earlier finishes vanish from the year.
*/
async countCompletedBooks(
userId: number,
accessibleLibraryIds: number[],
fromDay: string,
untilDay: string,
contentFilters?: ContentFilterRules,
): Promise<number> {
if (accessibleLibraryIds.length === 0) return 0;
const cfClauses = this.getContentFilterClauses(contentFilters);
const yearStart = sql`date_trunc('year', current_date)`;
const [row] = await this.db
.select({ count: sql<number>`count(*)::int` })
.from(readingAttempts)
@@ -65,7 +100,8 @@ export class DashboardWidgetRepository {
eq(readingAttempts.outcome, 'completed'),
isNull(readingAttempts.deletedAt),
isNotNull(readingAttempts.endedOn),
gte(readingAttempts.endedOn, yearStart),
gte(readingAttempts.endedOn, fromDay),
lt(readingAttempts.endedOn, untilDay),
inArray(books.libraryId, accessibleLibraryIds),
...cfClauses,
),
@@ -183,7 +219,12 @@ export class DashboardWidgetRepository {
return { books: result };
}
async getReadingStreak(userId: number, accessibleLibraryIds: number[], contentFilters?: ContentFilterRules): Promise<ReadingStreakWidgetData> {
async getReadingStreak(
userId: number,
accessibleLibraryIds: number[],
today: string,
contentFilters?: ContentFilterRules,
): Promise<ReadingStreakWidgetData> {
void contentFilters;
if (accessibleLibraryIds.length === 0) {
return { currentStreak: 0, longestStreak: 0, lastSevenDays: [false, false, false, false, false, false, false] };
@@ -200,10 +241,11 @@ export class DashboardWidgetRepository {
.orderBy(desc(userReadingDailyStats.day));
const readDays = new Set(rows.filter((r) => r.totalSeconds > 0).map((r) => r.day));
return computeStreakData(readDays, new Date());
return computeStreakData(readDays, today);
}
async getLibraryOverview(accessibleLibraryIds: number[], contentFilters?: ContentFilterRules): Promise<LibraryOverviewWidgetData> {
/** `yearStart` is the instant the reader's local year began. */
async getLibraryOverview(accessibleLibraryIds: number[], yearStart: Date, contentFilters?: ContentFilterRules): Promise<LibraryOverviewWidgetData> {
if (accessibleLibraryIds.length === 0) {
return { totalBooks: 0, totalAuthors: 0, totalSeries: 0, totalStorageBytes: 0, booksAddedThisYear: 0 };
}
@@ -234,7 +276,7 @@ export class DashboardWidgetRepository {
this.db
.select({ count: sql<number>`count(*)::int` })
.from(books)
.where(and(gte(books.addedAt, sql`date_trunc('year', current_date)`), libraryFilter, ...cfClauses)),
.where(and(gte(books.addedAt, yearStart), libraryFilter, ...cfClauses)),
]);
return {
@@ -256,6 +298,7 @@ export class DashboardWidgetRepository {
.select({ count: sql<number>`count(*)::int` })
.from(annotations)
.innerJoin(books, eq(books.id, annotations.bookId))
.innerJoin(bookMetadata, eq(bookMetadata.bookId, books.id))
.where(and(eq(annotations.userId, userId), isNull(annotations.deletedAt), inArray(books.libraryId, accessibleLibraryIds), ...cfClauses));
return row?.count ?? 0;
@@ -302,12 +345,58 @@ export class DashboardWidgetRepository {
};
}
async getHighlightsFromOtherBooks(
userId: number,
accessibleLibraryIds: number[],
excludedBookId: number,
limit: number,
contentFilters?: ContentFilterRules,
): Promise<HighlightOfTheDayWidgetData[]> {
if (accessibleLibraryIds.length === 0 || limit <= 0) return [];
const cfClauses = this.getContentFilterClauses(contentFilters);
const rows = await this.db
.selectDistinctOn([annotations.bookId], {
text: annotations.text,
note: annotations.note,
bookTitle: bookMetadata.title,
bookId: annotations.bookId,
coverSource: bookMetadata.coverSource,
chapterTitle: annotations.chapterTitle,
createdAt: annotations.createdAt,
})
.from(annotations)
.innerJoin(books, eq(books.id, annotations.bookId))
.innerJoin(bookMetadata, eq(bookMetadata.bookId, books.id))
.where(
and(
eq(annotations.userId, userId),
isNull(annotations.deletedAt),
inArray(books.libraryId, accessibleLibraryIds),
notInArray(annotations.bookId, [excludedBookId]),
...cfClauses,
),
)
.orderBy(annotations.bookId, desc(annotations.id))
.limit(limit);
return rows.map((row) => ({
text: row.text,
note: row.note,
bookTitle: row.bookTitle,
bookId: row.bookId,
hasCover: row.coverSource != null,
chapterTitle: row.chapterTitle,
createdAt: row.createdAt.toISOString(),
}));
}
// ── Monthly Challenge (raw data) ────────────────────────────────
async getChallengePatternData(
userId: number,
accessibleLibraryIds: number[],
monthStart: Date,
month: ReaderMonth,
sixMonthsAgo: Date,
contentFilters?: ContentFilterRules,
): Promise<{
@@ -357,27 +446,18 @@ export class DashboardWidgetRepository {
.as('author_counts');
const [
[avgRow],
pagesThisMonth,
[genreRow],
[staleRow],
[authorRow],
[totalRow],
[pagesRow],
[shortRow],
[newGenreRow],
[newAuthorRow],
[sessionPagesKnownRow],
[sessionUnknownProgressRow],
[dailyProgressRow],
[finishedThisMonthRow],
thisMonthReadDays,
] = await Promise.all([
this.db
.select({ avg: sql<number>`coalesce(avg(${bookMetadata.pageCount}), 0)::int` })
.from(userBookStatus)
.innerJoin(books, eq(books.id, userBookStatus.bookId))
.innerJoin(bookMetadata, eq(bookMetadata.bookId, books.id))
.where(and(eq(userBookStatus.userId, userId), eq(userBookStatus.status, 'read'), libFilter, presentFilter, ...cfClauses)),
this.estimatePagesReadSince(userId, accessibleLibraryIds, month.start, month.startDay, contentFilters),
this.db
.select({ count: sql<number>`count(distinct ${bookGenres.genreId})::int` })
.from(userBookStatus)
@@ -413,21 +493,6 @@ export class DashboardWidgetRepository {
.from(userBookStatus)
.innerJoin(books, eq(books.id, userBookStatus.bookId))
.where(and(eq(userBookStatus.userId, userId), eq(userBookStatus.status, 'read'), libFilter, presentFilter, ...cfClauses)),
this.db
.select({ total: sql<number>`coalesce(sum(${bookMetadata.pageCount}), 0)::int` })
.from(userBookStatus)
.innerJoin(books, eq(books.id, userBookStatus.bookId))
.innerJoin(bookMetadata, eq(bookMetadata.bookId, books.id))
.where(
and(
eq(userBookStatus.userId, userId),
inArray(userBookStatus.status, ['read', 'skimmed']),
gte(userBookStatus.finishedAt, monthStart),
libFilter,
presentFilter,
...cfClauses,
),
),
this.db
.select({ count: sql<number>`count(*)::int` })
.from(userBookStatus)
@@ -437,7 +502,7 @@ export class DashboardWidgetRepository {
and(
eq(userBookStatus.userId, userId),
inArray(userBookStatus.status, ['read', 'skimmed']),
gte(userBookStatus.finishedAt, monthStart),
gte(userBookStatus.finishedAt, month.start),
lt(bookMetadata.pageCount, 200),
libFilter,
presentFilter,
@@ -453,7 +518,7 @@ export class DashboardWidgetRepository {
and(
eq(userBookStatus.userId, userId),
inArray(userBookStatus.status, ['read', 'skimmed']),
gte(userBookStatus.finishedAt, monthStart),
gte(userBookStatus.finishedAt, month.start),
libFilter,
presentFilter,
...cfClauses,
@@ -468,7 +533,101 @@ export class DashboardWidgetRepository {
and(
eq(userBookStatus.userId, userId),
inArray(userBookStatus.status, ['read', 'skimmed']),
gte(userBookStatus.finishedAt, monthStart),
gte(userBookStatus.finishedAt, month.start),
libFilter,
presentFilter,
...cfClauses,
),
),
// Any book finished this month - approximates whether the oldest in-progress book was cleared
this.db
.select({ count: sql<number>`count(*)::int` })
.from(userBookStatus)
.innerJoin(books, eq(books.id, userBookStatus.bookId))
.where(
and(
eq(userBookStatus.userId, userId),
inArray(userBookStatus.status, ['read', 'skimmed']),
gte(userBookStatus.finishedAt, month.start),
libFilter,
presentFilter,
...cfClauses,
),
),
// Reading days within this month for max-streak computation
this.db
.select({ day: userReadingDailyStats.day })
.from(userReadingDailyStats)
.where(
and(
eq(userReadingDailyStats.userId, userId),
inArray(userReadingDailyStats.libraryId, accessibleLibraryIds),
gte(userReadingDailyStats.day, month.startDay),
gt(userReadingDailyStats.readingSeconds, 0),
),
)
.groupBy(userReadingDailyStats.day),
]);
const streakData = await this.getReadingStreak(userId, accessibleLibraryIds, month.today, contentFilters);
const pagesReadThisMonth = pagesThisMonth.pagesRead;
const maxStreakThisMonth = computeLongestStreak(new Set(thisMonthReadDays.map((r) => r.day)));
return {
avgPageCount: pagesThisMonth.avgPageCount,
uniqueGenresLast6Months: genreRow?.count ?? 0,
staleInProgressCount: staleRow?.count ?? 0,
currentStreak: streakData.currentStreak,
maxStreakThisMonth,
topAuthorBookCount: authorRow?.count ?? 0,
totalBooksRead: totalRow?.count ?? 0,
pagesThisMonth: pagesReadThisMonth,
shortBooksCompleted: shortRow?.count ?? 0,
newGenresRead: newGenreRow?.count ?? 0,
oldestInProgressFinished: (finishedThisMonthRow?.count ?? 0) > 0,
newAuthorsRead: newAuthorRow?.count ?? 0,
pagesReadThisMonth,
};
}
/**
* Pages read since `since`, the instant the reader's local day `sinceDay` began.
*
* Sessions record progress rather than pages, so each is scaled by its book's page count, and a
* book without one borrows the reader's average, or a nominal 300 pages. The page counts of books
* finished in the window and the daily progress rollup are floors for readers whose sessions are
* sparse, such as books marked read by hand. Counting finished books alone, as the year
* projection once did, reports no pages at all for a month of reading that finished nothing
* with a known page count.
*/
private async estimatePagesReadSince(
userId: number,
accessibleLibraryIds: number[],
since: Date,
sinceDay: string,
contentFilters?: ContentFilterRules,
): Promise<{ pagesRead: number; avgPageCount: number }> {
const cfClauses = this.getContentFilterClauses(contentFilters);
const libFilter = inArray(books.libraryId, accessibleLibraryIds);
const presentFilter = eq(books.status, 'present');
const [[avgRow], [finishedPagesRow], [sessionPagesKnownRow], [sessionUnknownProgressRow], [dailyProgressRow]] = await Promise.all([
this.db
.select({ avg: sql<number>`coalesce(avg(${bookMetadata.pageCount}), 0)::int` })
.from(userBookStatus)
.innerJoin(books, eq(books.id, userBookStatus.bookId))
.innerJoin(bookMetadata, eq(bookMetadata.bookId, books.id))
.where(and(eq(userBookStatus.userId, userId), eq(userBookStatus.status, 'read'), libFilter, presentFilter, ...cfClauses)),
this.db
.select({ total: sql<number>`coalesce(sum(${bookMetadata.pageCount}), 0)::int` })
.from(userBookStatus)
.innerJoin(books, eq(books.id, userBookStatus.bookId))
.innerJoin(bookMetadata, eq(bookMetadata.bookId, books.id))
.where(
and(
eq(userBookStatus.userId, userId),
inArray(userBookStatus.status, ['read', 'skimmed']),
gte(userBookStatus.finishedAt, since),
libFilter,
presentFilter,
...cfClauses,
@@ -484,7 +643,7 @@ export class DashboardWidgetRepository {
.where(
and(
eq(readingSessions.userId, userId),
gte(readingSessions.startedAt, monthStart),
gte(readingSessions.startedAt, since),
gt(readingSessions.progressDelta, 0),
isNotNull(bookMetadata.pageCount),
libFilter,
@@ -502,7 +661,7 @@ export class DashboardWidgetRepository {
.where(
and(
eq(readingSessions.userId, userId),
gte(readingSessions.startedAt, monthStart),
gte(readingSessions.startedAt, since),
gt(readingSessions.progressDelta, 0),
isNull(bookMetadata.pageCount),
libFilter,
@@ -519,64 +678,18 @@ export class DashboardWidgetRepository {
and(
eq(userReadingDailyStats.userId, userId),
inArray(userReadingDailyStats.libraryId, accessibleLibraryIds),
gte(userReadingDailyStats.day, formatDay(monthStart)),
gte(userReadingDailyStats.day, sinceDay),
),
),
// Any book finished this month — approximates whether the oldest in-progress book was cleared
this.db
.select({ count: sql<number>`count(*)::int` })
.from(userBookStatus)
.innerJoin(books, eq(books.id, userBookStatus.bookId))
.where(
and(
eq(userBookStatus.userId, userId),
inArray(userBookStatus.status, ['read', 'skimmed']),
gte(userBookStatus.finishedAt, monthStart),
libFilter,
presentFilter,
...cfClauses,
),
),
// Reading days within this month for max-streak computation
this.db
.select({ day: userReadingDailyStats.day })
.from(userReadingDailyStats)
.where(
and(
eq(userReadingDailyStats.userId, userId),
inArray(userReadingDailyStats.libraryId, accessibleLibraryIds),
gte(userReadingDailyStats.day, formatDay(monthStart)),
gt(userReadingDailyStats.readingSeconds, 0),
),
)
.groupBy(userReadingDailyStats.day),
]);
const streakData = await this.getReadingStreak(userId, accessibleLibraryIds, contentFilters);
const avgPageCount = avgRow?.avg ?? 0;
const inferredPageCount = avgPageCount > 0 ? avgPageCount : DEFAULT_VIRTUAL_PAGE_COUNT;
const pagesFromUnknownPageCountSessions = Math.floor(((sessionUnknownProgressRow?.totalProgress ?? 0) * inferredPageCount) / 100);
const pagesFromSessions = (sessionPagesKnownRow?.total ?? 0) + pagesFromUnknownPageCountSessions;
const pagesFromFinishedBooks = pagesRow?.total ?? 0;
const pagesFromFinishedBooks = finishedPagesRow?.total ?? 0;
const pagesFromDailyProgress = Math.floor(((dailyProgressRow?.totalProgress ?? 0) * avgPageCount) / 100);
const pagesReadThisMonth = Math.max(pagesFromSessions, pagesFromFinishedBooks, pagesFromDailyProgress, 0);
const maxStreakThisMonth = computeLongestStreak(new Set(thisMonthReadDays.map((r) => r.day)));
return {
avgPageCount,
uniqueGenresLast6Months: genreRow?.count ?? 0,
staleInProgressCount: staleRow?.count ?? 0,
currentStreak: streakData.currentStreak,
maxStreakThisMonth,
topAuthorBookCount: authorRow?.count ?? 0,
totalBooksRead: totalRow?.count ?? 0,
pagesThisMonth: pagesReadThisMonth,
shortBooksCompleted: shortRow?.count ?? 0,
newGenresRead: newGenreRow?.count ?? 0,
oldestInProgressFinished: (finishedThisMonthRow?.count ?? 0) > 0,
newAuthorsRead: newAuthorRow?.count ?? 0,
pagesReadThisMonth,
};
return { pagesRead: Math.max(pagesFromSessions, pagesFromFinishedBooks, pagesFromDailyProgress, 0), avgPageCount };
}
// ── Year Projection (raw data) ─────────────────────────────────
@@ -584,8 +697,7 @@ export class DashboardWidgetRepository {
async getYearProjectionData(
userId: number,
accessibleLibraryIds: number[],
yearStart: Date,
thirtyDaysAgo: Date,
window: YearProjectionWindow,
contentFilters?: ContentFilterRules,
): Promise<{
booksCompletedYtd: number;
@@ -597,43 +709,10 @@ export class DashboardWidgetRepository {
return { booksCompletedYtd: 0, pagesReadLast30Days: 0, hoursReadLast30Days: 0, booksCompletedLast30Days: 0 };
}
const cfClauses = this.getContentFilterClauses(contentFilters);
const libFilter = inArray(books.libraryId, accessibleLibraryIds);
const presentFilter = eq(books.status, 'present');
const [[ytdRow], [last30BooksRow], [last30ReadingRow]] = await Promise.all([
this.db
.select({ count: sql<number>`count(*)::int` })
.from(userBookStatus)
.innerJoin(books, eq(books.id, userBookStatus.bookId))
.where(
and(
eq(userBookStatus.userId, userId),
inArray(userBookStatus.status, ['read', 'skimmed']),
gte(userBookStatus.finishedAt, yearStart),
libFilter,
presentFilter,
...cfClauses,
),
),
this.db
.select({
count: sql<number>`count(*)::int`,
pages: sql<number>`coalesce(sum(${bookMetadata.pageCount}), 0)::int`,
})
.from(userBookStatus)
.innerJoin(books, eq(books.id, userBookStatus.bookId))
.innerJoin(bookMetadata, eq(bookMetadata.bookId, books.id))
.where(
and(
eq(userBookStatus.userId, userId),
inArray(userBookStatus.status, ['read', 'skimmed']),
gte(userBookStatus.finishedAt, thirtyDaysAgo),
libFilter,
presentFilter,
...cfClauses,
),
),
const [booksCompletedYtd, booksCompletedLast30Days, recentPages, [recentReadingRow]] = await Promise.all([
this.countCompletedBooks(userId, accessibleLibraryIds, window.yearStartDay, window.nextYearStartDay, contentFilters),
this.countCompletedBooks(userId, accessibleLibraryIds, window.recentStartDay, window.recentEndDay, contentFilters),
this.estimatePagesReadSince(userId, accessibleLibraryIds, window.recentStart, window.recentStartDay, contentFilters),
this.db
.select({
hours: sql<number>`coalesce(sum(${userReadingDailyStats.readingSeconds}), 0)::real / 3600`,
@@ -643,16 +722,16 @@ export class DashboardWidgetRepository {
and(
eq(userReadingDailyStats.userId, userId),
inArray(userReadingDailyStats.libraryId, accessibleLibraryIds),
gte(userReadingDailyStats.day, thirtyDaysAgo.toISOString().slice(0, 10)),
gte(userReadingDailyStats.day, window.recentStartDay),
),
),
]);
return {
booksCompletedYtd: ytdRow?.count ?? 0,
pagesReadLast30Days: last30BooksRow?.pages ?? 0,
hoursReadLast30Days: last30ReadingRow?.hours ?? 0,
booksCompletedLast30Days: last30BooksRow?.count ?? 0,
booksCompletedYtd,
pagesReadLast30Days: recentPages.pagesRead,
hoursReadLast30Days: recentReadingRow?.hours ?? 0,
booksCompletedLast30Days,
};
}
@@ -714,10 +793,12 @@ export class DashboardWidgetRepository {
// ── Reading DNA (raw data) ──────────────────────────────────────
/** `timeZone` places the lookback's first day and the peak reading hour on the reader's clock. */
async getReadingDnaData(
userId: number,
accessibleLibraryIds: number[],
since: Date,
timeZone: string,
contentFilters?: ContentFilterRules,
): Promise<{
avgPageCount: number;
@@ -734,6 +815,7 @@ export class DashboardWidgetRepository {
const cfClauses = this.getContentFilterClauses(contentFilters);
const libFilter = inArray(books.libraryId, accessibleLibraryIds);
const presentFilter = eq(books.status, 'present');
const localStartHour = sql<number>`extract(hour from (${readingSessions.startedAt} at time zone ${timeZone}))::int`;
const [[statsRow], [genreRow], dailyRows, [peakRow], [knownSpeedRow], [unknownSpeedRow]] = await Promise.all([
this.db
@@ -761,19 +843,21 @@ export class DashboardWidgetRepository {
and(
eq(userReadingDailyStats.userId, userId),
inArray(userReadingDailyStats.libraryId, accessibleLibraryIds),
gte(userReadingDailyStats.day, since.toISOString().slice(0, 10)),
gte(userReadingDailyStats.day, toDateKeyInTimeZone(since, timeZone)),
),
)
.groupBy(userReadingDailyStats.day),
// Grouped by position: the zone is a bound parameter, and Postgres will not match the
// select's `$n` to a repeated expression carrying a different parameter number.
this.db
.select({
hour: sql<number>`extract(hour from ${readingSessions.startedAt})::int`,
hour: localStartHour,
total: sql<number>`sum(${readingSessions.durationSeconds})::int`,
})
.from(readingSessions)
.innerJoin(books, eq(books.id, readingSessions.bookId))
.where(and(eq(readingSessions.userId, userId), libFilter, presentFilter, ...cfClauses))
.groupBy(sql`extract(hour from ${readingSessions.startedAt})`)
.groupBy(sql`1`)
.orderBy(desc(sql`sum(${readingSessions.durationSeconds})`))
.limit(1),
this.db
@@ -1,6 +1,7 @@
import type { CurrentlyReadingWidgetData, LibraryOverviewWidgetData, NeglectedGemsWidgetData, ReadingStreakWidgetData } from '@bookorbit/types';
import type { RequestUser } from '../../common/types/request-user';
import { pickAnnotationIndex } from './dashboard-widget.calculations';
import { DashboardWidgetService } from './dashboard-widget.service';
import { EMPTY_CONTENT_FILTER_RULES } from '@bookorbit/types';
@@ -26,12 +27,13 @@ function makeUser(overrides: Partial<RequestUser> = {}): RequestUser {
function makeService() {
const widgetRepo = {
getCompletedBooksThisYear: vi.fn(),
countCompletedBooks: vi.fn(),
getCurrentlyReadingBooks: vi.fn(),
getReadingStreak: vi.fn(),
getLibraryOverview: vi.fn(),
getAnnotationCount: vi.fn(),
getAnnotationByOffset: vi.fn(),
getHighlightsFromOtherBooks: vi.fn().mockResolvedValue([]),
getChallengePatternData: vi.fn(),
getYearProjectionData: vi.fn(),
getNeglectedGems: vi.fn(),
@@ -60,23 +62,24 @@ describe('DashboardWidgetService', () => {
settings: { dashboardConfig: { readingGoal: 24, widgets: [] } },
});
libraryService.findAccessibleLibraryIds.mockResolvedValue([1, 2]);
widgetRepo.getCompletedBooksThisYear.mockResolvedValue(7);
widgetRepo.countCompletedBooks.mockResolvedValue(7);
const result = await service.getReadingGoal(user);
const year = new Date().getUTCFullYear();
expect(libraryService.findAccessibleLibraryIds).toHaveBeenCalledWith(user);
expect(widgetRepo.getCompletedBooksThisYear).toHaveBeenCalledWith(42, [1, 2], EMPTY_CONTENT_FILTER_RULES);
expect(widgetRepo.countCompletedBooks).toHaveBeenCalledWith(42, [1, 2], `${year}-01-01`, `${year + 1}-01-01`, EMPTY_CONTENT_FILTER_RULES);
expect(result).toEqual({
goalBooks: 24,
completedBooks: 7,
year: new Date().getUTCFullYear(),
year,
});
});
it('returns null goalBooks when user has no reading goal set', async () => {
const { service, widgetRepo, libraryService } = makeService();
libraryService.findAccessibleLibraryIds.mockResolvedValue([1]);
widgetRepo.getCompletedBooksThisYear.mockResolvedValue(0);
widgetRepo.countCompletedBooks.mockResolvedValue(0);
const result = await service.getReadingGoal(makeUser());
@@ -88,7 +91,7 @@ describe('DashboardWidgetService', () => {
const { service, widgetRepo, libraryService } = makeService();
const user = makeUser({ settings: { dashboardConfig: { widgets: [] } } });
libraryService.findAccessibleLibraryIds.mockResolvedValue([]);
widgetRepo.getCompletedBooksThisYear.mockResolvedValue(0);
widgetRepo.countCompletedBooks.mockResolvedValue(0);
const result = await service.getReadingGoal(user);
@@ -190,7 +193,7 @@ describe('DashboardWidgetService', () => {
const result = await service.getReadingStreak(user);
expect(libraryService.findAccessibleLibraryIds).toHaveBeenCalledWith(user);
expect(widgetRepo.getReadingStreak).toHaveBeenCalledWith(99, [1, 2], EMPTY_CONTENT_FILTER_RULES);
expect(widgetRepo.getReadingStreak).toHaveBeenCalledWith(99, [1, 2], expect.stringMatching(/^\d{4}-\d{2}-\d{2}$/), EMPTY_CONTENT_FILTER_RULES);
expect(result).toEqual(mockData);
});
});
@@ -212,7 +215,7 @@ describe('DashboardWidgetService', () => {
const result = await service.getLibraryOverview(user);
expect(libraryService.findAccessibleLibraryIds).toHaveBeenCalledWith(user);
expect(widgetRepo.getLibraryOverview).toHaveBeenCalledWith([10], EMPTY_CONTENT_FILTER_RULES);
expect(widgetRepo.getLibraryOverview).toHaveBeenCalledWith([10], expect.any(Date), EMPTY_CONTENT_FILTER_RULES);
expect(result).toEqual(mockData);
});
});
@@ -249,6 +252,57 @@ describe('DashboardWidgetService', () => {
});
});
describe('getHighlights', () => {
it('prefers highlights from other books after the daily highlight', async () => {
const { service, widgetRepo, libraryService } = makeService();
libraryService.findAccessibleLibraryIds.mockResolvedValue([7]);
widgetRepo.getAnnotationCount.mockResolvedValue(10);
const highlight = (bookId: number) => ({
text: `Passage ${bookId}`,
note: null,
bookTitle: `Book ${bookId}`,
bookId,
hasCover: true,
chapterTitle: null,
createdAt: '2026-01-01T00:00:00.000Z',
});
widgetRepo.getAnnotationByOffset.mockResolvedValue(highlight(5));
widgetRepo.getHighlightsFromOtherBooks.mockResolvedValue([highlight(6), highlight(7)]);
const result = await service.getHighlights(makeUser());
expect(result.map((item) => item.bookId)).toEqual([5, 6, 7]);
expect(widgetRepo.getHighlightsFromOtherBooks).toHaveBeenCalledWith(42, [7], 5, 2, EMPTY_CONTENT_FILTER_RULES);
expect(widgetRepo.getAnnotationByOffset).toHaveBeenCalledTimes(1);
});
it('returns up to three real, distinct annotation rows from the scoped pool', async () => {
const { service, widgetRepo, libraryService } = makeService();
libraryService.findAccessibleLibraryIds.mockResolvedValue([7]);
widgetRepo.getAnnotationCount.mockResolvedValue(2);
widgetRepo.getAnnotationByOffset.mockImplementation((_userId: number, _libraries: number[], offset: number) =>
Promise.resolve({
text: `Highlight ${offset}`,
note: null,
bookTitle: 'Book',
bookId: offset + 1,
hasCover: false,
chapterTitle: null,
createdAt: '2026-01-01T00:00:00.000Z',
}),
);
const result = await service.getHighlights(makeUser({ id: 42 }));
expect(result).toHaveLength(2);
expect(new Set(result.map((item) => item.text)).size).toBe(2);
expect(widgetRepo.getAnnotationByOffset).toHaveBeenCalledTimes(2);
for (const call of widgetRepo.getAnnotationByOffset.mock.calls) {
expect(call[0]).toBe(42);
expect(call[1]).toEqual([7]);
expect(call[3]).toEqual(EMPTY_CONTENT_FILTER_RULES);
}
});
});
describe('getMonthlyChallenge', () => {
it('returns a challenge with computed progress', async () => {
const { service, widgetRepo, libraryService } = makeService();
@@ -474,7 +528,7 @@ describe('DashboardWidgetService', () => {
it('getReadingGoal returns fresh goalBooks from user settings but reuses cached completedBooks', async () => {
const { service, widgetRepo, libraryService } = makeService();
libraryService.findAccessibleLibraryIds.mockResolvedValue([1]);
widgetRepo.getCompletedBooksThisYear.mockResolvedValue(5);
widgetRepo.countCompletedBooks.mockResolvedValue(5);
const userWithOldGoal = makeUser({ settings: { dashboardConfig: { readingGoal: 12, widgets: [] } } });
const userWithNewGoal = makeUser({ settings: { dashboardConfig: { readingGoal: 24, widgets: [] } } });
@@ -482,7 +536,7 @@ describe('DashboardWidgetService', () => {
const first = await service.getReadingGoal(userWithOldGoal);
const second = await service.getReadingGoal(userWithNewGoal);
expect(widgetRepo.getCompletedBooksThisYear).toHaveBeenCalledTimes(1);
expect(widgetRepo.countCompletedBooks).toHaveBeenCalledTimes(1);
expect(first.goalBooks).toBe(12);
expect(second.goalBooks).toBe(24);
expect(second.completedBooks).toBe(5);
@@ -529,11 +583,226 @@ describe('DashboardWidgetService', () => {
});
});
describe("the reader's time zone", () => {
const denver = () => makeUser({ id: 42, settings: { timezone: 'America/Denver' } });
const highlight = {
text: 'A line',
note: null,
bookTitle: 'Book',
bookId: 5,
hasCover: false,
chapterTitle: null,
createdAt: '2026-01-01T00:00:00.000Z',
};
beforeEach(() => {
vi.useFakeTimers({ toFake: ['Date'] });
// 7:58 PM on Sep 27 in Denver, already 01:58 on Sep 28 in UTC.
vi.setSystemTime(new Date('2026-09-28T01:58:00Z'));
});
afterEach(() => {
vi.useRealTimers();
});
it('ends the reading rhythm on the local day, not the UTC one', async () => {
const { service, widgetRepo, libraryService } = makeService();
libraryService.findAccessibleLibraryIds.mockResolvedValue([1]);
widgetRepo.getReadingRhythmData.mockResolvedValue([
{ day: '2026-09-27', readingSeconds: 900 },
{ day: '2026-09-28', readingSeconds: 60 },
]);
const result = await service.getReadingRhythm(denver());
expect(widgetRepo.getReadingRhythmData).toHaveBeenCalledWith(42, [1], '2026-09-14', EMPTY_CONTENT_FILTER_RULES);
expect(result.days).toHaveLength(14);
expect(result.days[0]?.date).toBe('2026-09-14');
expect(result.days.at(-1)).toEqual({ date: '2026-09-27', readingSeconds: 900 });
expect(result.activeDays).toBe(1);
});
it('keeps UTC days for a reader with no time zone set', async () => {
const { service, widgetRepo, libraryService } = makeService();
libraryService.findAccessibleLibraryIds.mockResolvedValue([1]);
widgetRepo.getReadingRhythmData.mockResolvedValue([]);
const result = await service.getReadingRhythm(makeUser());
expect(result.days.at(-1)?.date).toBe('2026-09-28');
});
it('asks for the streak as of the local day', async () => {
const { service, widgetRepo, libraryService } = makeService();
libraryService.findAccessibleLibraryIds.mockResolvedValue([1]);
widgetRepo.getReadingStreak.mockResolvedValue({ currentStreak: 3, longestStreak: 3, lastSevenDays: [] });
await service.getReadingStreak(denver());
expect(widgetRepo.getReadingStreak).toHaveBeenCalledWith(42, [1], '2026-09-27', EMPTY_CONTENT_FILTER_RULES);
});
it('does not serve the previous local day from the live cache after local midnight', async () => {
const { service, widgetRepo, libraryService } = makeService();
libraryService.findAccessibleLibraryIds.mockResolvedValue([1]);
widgetRepo.getReadingRhythmData.mockResolvedValue([]);
widgetRepo.getReadingStreak.mockResolvedValue({ currentStreak: 1, longestStreak: 1, lastSevenDays: [] });
vi.setSystemTime(new Date('2026-09-28T05:59:00Z'));
const beforeMidnight = await service.getReadingRhythm(denver());
await service.getReadingStreak(denver());
vi.setSystemTime(new Date('2026-09-28T06:01:00Z'));
const afterMidnight = await service.getReadingRhythm(denver());
await service.getReadingStreak(denver());
expect(beforeMidnight.days.at(-1)?.date).toBe('2026-09-27');
expect(afterMidnight.days.at(-1)?.date).toBe('2026-09-28');
expect(widgetRepo.getReadingRhythmData).toHaveBeenCalledTimes(2);
expect(widgetRepo.getReadingStreak).toHaveBeenNthCalledWith(2, 42, [1], '2026-09-28', EMPTY_CONTENT_FILTER_RULES);
});
it('does not answer a changed time zone from the old zone cache entry', async () => {
const { service, widgetRepo, libraryService } = makeService();
libraryService.findAccessibleLibraryIds.mockResolvedValue([1]);
widgetRepo.getReadingRhythmData.mockResolvedValue([]);
await service.getReadingRhythm(makeUser({ id: 42 }));
const afterChange = await service.getReadingRhythm(denver());
expect(afterChange.days.at(-1)?.date).toBe('2026-09-27');
expect(widgetRepo.getReadingRhythmData).toHaveBeenCalledTimes(2);
});
it("counts the reading goal for the reader's year on New Year's Eve", async () => {
vi.setSystemTime(new Date('2027-01-01T03:00:00Z'));
const { service, widgetRepo, libraryService } = makeService();
libraryService.findAccessibleLibraryIds.mockResolvedValue([1]);
widgetRepo.countCompletedBooks.mockResolvedValue(44);
const result = await service.getReadingGoal(denver());
expect(result.year).toBe(2026);
expect(widgetRepo.countCompletedBooks).toHaveBeenCalledWith(42, [1], '2026-01-01', '2027-01-01', EMPTY_CONTENT_FILTER_RULES);
});
it('projects the year from local windows', async () => {
const { service, widgetRepo, libraryService } = makeService();
libraryService.findAccessibleLibraryIds.mockResolvedValue([1]);
widgetRepo.getYearProjectionData.mockResolvedValue({
booksCompletedYtd: 44,
pagesReadLast30Days: 3_000,
hoursReadLast30Days: 22.5,
booksCompletedLast30Days: 2,
});
const result = await service.getYearProjection(denver());
expect(widgetRepo.getYearProjectionData).toHaveBeenCalledWith(
42,
[1],
{
yearStartDay: '2026-01-01',
nextYearStartDay: '2027-01-01',
recentStartDay: '2026-08-29',
recentEndDay: '2026-09-28',
// Midnight on Aug 29 in Denver, which is on daylight time.
recentStart: new Date('2026-08-29T06:00:00Z'),
},
EMPTY_CONTENT_FILTER_RULES,
);
// Sep 27 is day 270 of 2026.
expect(result.daysRemaining).toBe(95);
expect(result.booksCompletedYtd).toBe(44);
expect(result.projectedPages).toBe(36_500);
});
it('scopes the monthly challenge to the local month', async () => {
vi.setSystemTime(new Date('2026-10-01T03:00:00Z'));
const { service, widgetRepo, libraryService } = makeService();
libraryService.findAccessibleLibraryIds.mockResolvedValue([1]);
widgetRepo.getChallengePatternData.mockResolvedValue({
avgPageCount: 300,
uniqueGenresLast6Months: 3,
staleInProgressCount: 0,
currentStreak: 2,
maxStreakThisMonth: 3,
topAuthorBookCount: 1,
totalBooksRead: 20,
pagesThisMonth: 200,
shortBooksCompleted: 0,
newGenresRead: 0,
oldestInProgressFinished: false,
newAuthorsRead: 0,
pagesReadThisMonth: 200,
});
const result = await service.getMonthlyChallenge(denver());
expect(result).toMatchObject({ month: 9, year: 2026 });
expect(widgetRepo.getChallengePatternData).toHaveBeenCalledWith(
42,
[1],
{ start: new Date('2026-09-01T06:00:00Z'), startDay: '2026-09-01', today: '2026-09-30' },
expect.any(Date),
EMPTY_CONTENT_FILTER_RULES,
);
});
it('seeds the highlight of the day with the local date', async () => {
const { service, widgetRepo, libraryService } = makeService();
libraryService.findAccessibleLibraryIds.mockResolvedValue([1]);
widgetRepo.getAnnotationCount.mockResolvedValue(97);
widgetRepo.getAnnotationByOffset.mockResolvedValue(highlight);
await service.getHighlightOfTheDay(denver());
await service.getHighlights(denver());
const expectedOffset = pickAnnotationIndex(42, '2026-09-27', 97);
expect(expectedOffset).not.toBe(pickAnnotationIndex(42, '2026-09-28', 97));
expect(widgetRepo.getAnnotationByOffset).toHaveBeenNthCalledWith(1, 42, [1], expectedOffset, EMPTY_CONTENT_FILTER_RULES);
expect(widgetRepo.getAnnotationByOffset).toHaveBeenNthCalledWith(2, 42, [1], expectedOffset, EMPTY_CONTENT_FILTER_RULES);
});
it("counts books added since the start of the reader's year", async () => {
const { service, widgetRepo, libraryService } = makeService();
libraryService.findAccessibleLibraryIds.mockResolvedValue([1]);
widgetRepo.getLibraryOverview.mockResolvedValue({
totalBooks: 1,
totalAuthors: 1,
totalSeries: 0,
totalStorageBytes: 1,
booksAddedThisYear: 1,
});
await service.getLibraryOverview(denver());
// Midnight on Jan 1 in Denver, which is on standard time.
expect(widgetRepo.getLibraryOverview).toHaveBeenCalledWith([1], new Date('2026-01-01T07:00:00Z'), EMPTY_CONTENT_FILTER_RULES);
});
it('reads the reading DNA peak hour in the local zone', async () => {
const { service, widgetRepo, libraryService } = makeService();
libraryService.findAccessibleLibraryIds.mockResolvedValue([1]);
widgetRepo.getReadingDnaData.mockResolvedValue({
avgPageCount: 300,
uniqueGenres: 4,
totalBooks: 10,
readingDaysRatio: 0.5,
peakHour: 20,
avgPagesPerHour: null,
});
await service.getReadingDna(denver());
expect(widgetRepo.getReadingDnaData).toHaveBeenCalledWith(42, [1], expect.any(Date), 'America/Denver', EMPTY_CONTENT_FILTER_RULES);
});
});
describe('getWidgets', () => {
it('resolves several widgets in one call', async () => {
const { service, widgetRepo, libraryService } = makeService();
libraryService.findAccessibleLibraryIds.mockResolvedValue([1]);
widgetRepo.getCompletedBooksThisYear.mockResolvedValue(4);
widgetRepo.countCompletedBooks.mockResolvedValue(4);
widgetRepo.getLibraryOverview.mockResolvedValue({ totalBooks: 100, formats: [] });
const response = await service.getWidgets(['reading-goal', 'library-overview'], makeUser());
@@ -547,7 +816,7 @@ describe('DashboardWidgetService', () => {
it('isolates a failing widget so the rest of the dashboard still loads', async () => {
const { service, widgetRepo, libraryService } = makeService();
libraryService.findAccessibleLibraryIds.mockResolvedValue([1]);
widgetRepo.getCompletedBooksThisYear.mockRejectedValue(new Error('statement timeout'));
widgetRepo.countCompletedBooks.mockRejectedValue(new Error('statement timeout'));
widgetRepo.getLibraryOverview.mockResolvedValue({ totalBooks: 100, formats: [] });
const response = await service.getWidgets(['reading-goal', 'library-overview'], makeUser());
@@ -6,6 +6,7 @@ import type {
DashboardWidgetBatchResult,
DiversityScoreWidgetData,
HighlightOfTheDayWidgetData,
HighlightsWidgetData,
LibraryOverviewWidgetData,
LongWaitWidgetData,
MonthlyChallengeWidgetData,
@@ -23,6 +24,8 @@ import type { RequestUser } from '../../common/types/request-user';
import { StatsCache } from '../../common/cache/stats-cache';
import { mapWithConcurrency } from '../../common/utils/batch.utils';
import { sanitizeLogValue } from '../../common/utils/log-sanitize.utils';
import { addDateKeyDays } from '../../common/utils/reading-daily-stats.utils';
import { toTimeZoneStartOfDay } from '../../common/utils/timezone.utils';
import { LibraryService } from '../library/library.service';
import {
buildDaysSeries,
@@ -31,10 +34,12 @@ import {
computeProjection,
computeReadingDna,
computeRhythm,
daysBetweenDateKeys,
findEligibleChallenges,
formatDay,
pickAnnotationIndex,
resolveReaderClock,
selectChallenge,
type ReaderClock,
} from './dashboard-widget.calculations';
import { DashboardWidgetRepository } from './dashboard-widget.repository';
import { dashboardLibraryScopeCacheKey, resolveDashboardLibraryIds } from './dashboard-library-scope';
@@ -44,6 +49,9 @@ const DASHBOARD_STALE_TTL_MS = 300_000;
const DASHBOARD_CACHE_MAX_ENTRIES = 200;
// Matches the scroller batch: enough to overlap query latency without flooding the connection pool.
const WIDGET_QUERY_CONCURRENCY = 3;
const RHYTHM_WINDOW_DAYS = 14;
// computeProjection divides the recent window's totals by 30.
const PROJECTION_RECENT_DAYS = 30;
@Injectable()
export class DashboardWidgetService {
@@ -68,6 +76,19 @@ export class DashboardWidgetService {
return resolveDashboardLibraryIds(await this.libraryService.findAccessibleLibraryIds(user), user);
}
private readerClock(user: RequestUser): ReaderClock {
return resolveReaderClock(user.settings?.timezone);
}
/**
* Cache key for a widget whose answer depends on the reader's calendar. Carrying the local day
* means an entry cached before the reader's midnight is never served after it, and carrying the
* zone means a changed timezone setting is not answered from the old zone's entry.
*/
private calendarCacheKey(widget: string, clock: ReaderClock): string {
return `${widget}:${clock.timeZone}:${clock.today}`;
}
clearCacheForUser(userId: number): void {
const scopePrefix = `${userId}:`;
this.liveCache.clearForScopePrefix(scopePrefix);
@@ -77,12 +98,14 @@ export class DashboardWidgetService {
async getReadingGoal(user: RequestUser): Promise<ReadingGoalWidgetData> {
const settings = user.settings as UserSettings | undefined;
const goalBooks = settings?.dashboardConfig?.readingGoal ?? null;
const year = new Date().getUTCFullYear();
const { year } = this.readerClock(user);
const accessibleLibraryIds = await this.getLibraryIds(user);
// Keyed by the local year alone: attempt end dates are already local days, so the zone only
// decides which year it is.
const completedBooks = await this.staleCache.get(this.cacheOwnerKey(user, accessibleLibraryIds), `reading-goal-completed:${year}`, async () => {
const contentFilters = this.getContentFilters(user);
return this.widgetRepo.getCompletedBooksThisYear(user.id, accessibleLibraryIds, contentFilters);
return this.widgetRepo.countCompletedBooks(user.id, accessibleLibraryIds, `${year}-01-01`, `${year + 1}-01-01`, contentFilters);
});
return { goalBooks, completedBooks, year };
@@ -98,44 +121,80 @@ export class DashboardWidgetService {
async getReadingStreak(user: RequestUser): Promise<ReadingStreakWidgetData> {
const accessibleLibraryIds = await this.getLibraryIds(user);
return this.liveCache.get(this.cacheOwnerKey(user, accessibleLibraryIds), 'reading-streak', async () => {
const clock = this.readerClock(user);
return this.liveCache.get(this.cacheOwnerKey(user, accessibleLibraryIds), this.calendarCacheKey('reading-streak', clock), async () => {
const contentFilters = this.getContentFilters(user);
return this.widgetRepo.getReadingStreak(user.id, accessibleLibraryIds, contentFilters);
return this.widgetRepo.getReadingStreak(user.id, accessibleLibraryIds, clock.today, contentFilters);
});
}
async getLibraryOverview(user: RequestUser): Promise<LibraryOverviewWidgetData> {
const accessibleLibraryIds = await this.getLibraryIds(user);
return this.staleCache.get(this.cacheOwnerKey(user, accessibleLibraryIds), 'library-overview', async () => {
const clock = this.readerClock(user);
return this.staleCache.get(this.cacheOwnerKey(user, accessibleLibraryIds), this.calendarCacheKey('library-overview', clock), async () => {
const contentFilters = this.getContentFilters(user);
return this.widgetRepo.getLibraryOverview(accessibleLibraryIds, contentFilters);
const yearStart = toTimeZoneStartOfDay(`${clock.year}-01-01`, clock.timeZone);
return this.widgetRepo.getLibraryOverview(accessibleLibraryIds, yearStart, contentFilters);
});
}
async getHighlightOfTheDay(user: RequestUser): Promise<HighlightOfTheDayWidgetData | null> {
const accessibleLibraryIds = await this.getLibraryIds(user);
return this.liveCache.get(this.cacheOwnerKey(user, accessibleLibraryIds), 'highlight-of-the-day', async () => {
const clock = this.readerClock(user);
return this.liveCache.get(this.cacheOwnerKey(user, accessibleLibraryIds), this.calendarCacheKey('highlight-of-the-day', clock), async () => {
const contentFilters = this.getContentFilters(user);
const total = await this.widgetRepo.getAnnotationCount(user.id, accessibleLibraryIds, contentFilters);
if (total === 0) return null;
const dateStr = formatDay(new Date());
const offset = pickAnnotationIndex(user.id, dateStr, total);
const offset = pickAnnotationIndex(user.id, clock.today, total);
return this.widgetRepo.getAnnotationByOffset(user.id, accessibleLibraryIds, offset, contentFilters);
});
}
async getHighlights(user: RequestUser): Promise<HighlightsWidgetData> {
const accessibleLibraryIds = await this.getLibraryIds(user);
const clock = this.readerClock(user);
return this.liveCache.get(this.cacheOwnerKey(user, accessibleLibraryIds), this.calendarCacheKey('highlights', clock), async () => {
const contentFilters = this.getContentFilters(user);
const total = await this.widgetRepo.getAnnotationCount(user.id, accessibleLibraryIds, contentFilters);
if (total === 0) return [];
const start = pickAnnotationIndex(user.id, clock.today, total);
const first = await this.widgetRepo.getAnnotationByOffset(user.id, accessibleLibraryIds, start, contentFilters);
if (!first) return [];
const others = await this.widgetRepo.getHighlightsFromOtherBooks(user.id, accessibleLibraryIds, first.bookId, 2, contentFilters);
const highlights = [first, ...others];
// An account with highlights in one or two books can still fill its remaining cards with
// other annotations. The offset scan never repeats the first row and skips selected rows.
for (let step = 1; highlights.length < Math.min(total, 3) && step < total; step++) {
const candidate = await this.widgetRepo.getAnnotationByOffset(user.id, accessibleLibraryIds, (start + step) % total, contentFilters);
if (
candidate &&
!highlights.some((item) => item.bookId === candidate.bookId && item.createdAt === candidate.createdAt && item.text === candidate.text)
) {
highlights.push(candidate);
}
}
return highlights;
});
}
async getMonthlyChallenge(user: RequestUser): Promise<MonthlyChallengeWidgetData> {
const accessibleLibraryIds = await this.getLibraryIds(user);
return this.staleCache.get(this.cacheOwnerKey(user, accessibleLibraryIds), 'monthly-challenge', async () => {
const clock = this.readerClock(user);
return this.staleCache.get(this.cacheOwnerKey(user, accessibleLibraryIds), this.calendarCacheKey('monthly-challenge', clock), async () => {
const contentFilters = this.getContentFilters(user);
const today = new Date();
const year = today.getUTCFullYear();
const month = today.getUTCMonth() + 1;
const monthStart = new Date(Date.UTC(year, month - 1, 1));
const sixMonthsAgo = new Date(today);
const { year, month } = clock;
const monthStartDay = `${clock.today.slice(0, 7)}-01`;
const monthStart = toTimeZoneStartOfDay(monthStartDay, clock.timeZone);
const sixMonthsAgo = new Date();
sixMonthsAgo.setUTCMonth(sixMonthsAgo.getUTCMonth() - 6);
const data = await this.widgetRepo.getChallengePatternData(user.id, accessibleLibraryIds, monthStart, sixMonthsAgo, contentFilters);
const data = await this.widgetRepo.getChallengePatternData(
user.id,
accessibleLibraryIds,
{ start: monthStart, startDay: monthStartDay, today: clock.today },
sixMonthsAgo,
contentFilters,
);
const eligible = findEligibleChallenges(data);
const challengeType = selectChallenge(eligible, user.id, year, month);
@@ -159,19 +218,28 @@ export class DashboardWidgetService {
async getYearProjection(user: RequestUser): Promise<YearProjectionWidgetData> {
const accessibleLibraryIds = await this.getLibraryIds(user);
return this.staleCache.get(this.cacheOwnerKey(user, accessibleLibraryIds), 'year-projection', async () => {
const clock = this.readerClock(user);
return this.staleCache.get(this.cacheOwnerKey(user, accessibleLibraryIds), this.calendarCacheKey('year-projection', clock), async () => {
const contentFilters = this.getContentFilters(user);
const today = new Date();
const year = today.getUTCFullYear();
const yearStart = new Date(Date.UTC(year, 0, 1));
const thirtyDaysAgo = new Date(today);
thirtyDaysAgo.setUTCDate(thirtyDaysAgo.getUTCDate() - 30);
const yearStartDay = `${clock.year}-01-01`;
const nextYearStartDay = `${clock.year + 1}-01-01`;
const recentStartDay = addDateKeyDays(clock.today, -(PROJECTION_RECENT_DAYS - 1));
const isLeapYear = (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0;
const daysInYear = isLeapYear ? 366 : 365;
const dayOfYear = Math.ceil((today.getTime() - yearStart.getTime()) / (1000 * 60 * 60 * 24));
const daysInYear = daysBetweenDateKeys(yearStartDay, nextYearStartDay);
const dayOfYear = daysBetweenDateKeys(yearStartDay, clock.today) + 1;
const data = await this.widgetRepo.getYearProjectionData(user.id, accessibleLibraryIds, yearStart, thirtyDaysAgo, contentFilters);
const data = await this.widgetRepo.getYearProjectionData(
user.id,
accessibleLibraryIds,
{
yearStartDay,
nextYearStartDay,
recentStartDay,
recentEndDay: addDateKeyDays(clock.today, 1),
recentStart: toTimeZoneStartOfDay(recentStartDay, clock.timeZone),
},
contentFilters,
);
return computeProjection({
...data,
@@ -192,11 +260,12 @@ export class DashboardWidgetService {
async getReadingDna(user: RequestUser): Promise<ReadingDnaWidgetData> {
const accessibleLibraryIds = await this.getLibraryIds(user);
return this.staleCache.get(this.cacheOwnerKey(user, accessibleLibraryIds), 'reading-dna', async () => {
const clock = this.readerClock(user);
return this.staleCache.get(this.cacheOwnerKey(user, accessibleLibraryIds), this.calendarCacheKey('reading-dna', clock), async () => {
const contentFilters = this.getContentFilters(user);
const since = new Date();
since.setUTCMonth(since.getUTCMonth() - 6);
const data = await this.widgetRepo.getReadingDnaData(user.id, accessibleLibraryIds, since, contentFilters);
const data = await this.widgetRepo.getReadingDnaData(user.id, accessibleLibraryIds, since, clock.timeZone, contentFilters);
return computeReadingDna(data.avgPageCount, data.uniqueGenres, data.totalBooks, data.readingDaysRatio, data.peakHour, data.avgPagesPerHour);
});
}
@@ -227,14 +296,12 @@ export class DashboardWidgetService {
async getReadingRhythm(user: RequestUser): Promise<ReadingRhythmWidgetData> {
const accessibleLibraryIds = await this.getLibraryIds(user);
return this.liveCache.get(this.cacheOwnerKey(user, accessibleLibraryIds), 'reading-rhythm', async () => {
const clock = this.readerClock(user);
return this.liveCache.get(this.cacheOwnerKey(user, accessibleLibraryIds), this.calendarCacheKey('reading-rhythm', clock), async () => {
const contentFilters = this.getContentFilters(user);
const today = new Date();
const since = new Date(today);
since.setUTCDate(since.getUTCDate() - 13);
const sinceStr = formatDay(since);
const rawDays = await this.widgetRepo.getReadingRhythmData(user.id, accessibleLibraryIds, sinceStr, contentFilters);
const days = buildDaysSeries(rawDays, today, 14);
const since = addDateKeyDays(clock.today, -(RHYTHM_WINDOW_DAYS - 1));
const rawDays = await this.widgetRepo.getReadingRhythmData(user.id, accessibleLibraryIds, since, contentFilters);
const days = buildDaysSeries(rawDays, clock.today, RHYTHM_WINDOW_DAYS);
const rhythm = computeRhythm(days);
return { days, ...rhythm };
});
@@ -34,6 +34,7 @@ function makeController() {
getReadingStreak: vi.fn(),
getLibraryOverview: vi.fn(),
getHighlightOfTheDay: vi.fn(),
getHighlights: vi.fn(),
getMonthlyChallenge: vi.fn(),
getYearProjection: vi.fn(),
getNeglectedGems: vi.fn(),
@@ -172,6 +173,7 @@ describe('DashboardController', () => {
it.each([
['getHighlightOfTheDay', { text: 'quote', bookId: 1 }],
['getHighlights', [{ text: 'quote', bookId: 1 }]],
['getMonthlyChallenge', { challengeType: 'page-milestone', progress: 0, target: 500 }],
['getYearProjection', { projectedBooks: 30, daysRemaining: 200 }],
['getNeglectedGems', { gems: [] }],
@@ -68,6 +68,11 @@ export class DashboardController {
return this.widgetService.getHighlightOfTheDay(user);
}
@Get('widgets/highlights')
getHighlights(@CurrentUser() user: RequestUser) {
return this.widgetService.getHighlights(user);
}
@Get('widgets/monthly-challenge')
getMonthlyChallenge(@CurrentUser() user: RequestUser) {
return this.widgetService.getMonthlyChallenge(user);
+25 -4
View File
@@ -384,8 +384,9 @@ describe('Reading attempts main-flow simulation (docker e2e)', { timeout: TIMEOU
const book = await createBook();
const attempts = ctx.app.get(ReadingAttemptService);
const dashboard = ctx.app.get(DashboardWidgetRepository);
const completedBefore = await dashboard.getCompletedBooksThisYear(adminUserId, [libraryId]);
const year = new Date().getUTCFullYear();
const countThisYear = () => dashboard.countCompletedBooks(adminUserId, [libraryId], `${year}-01-01`, `${year + 1}-01-01`);
const completedBefore = await countThisYear();
await attempts.createHistorical(adminUserId, book.bookId, {
startedOn: `${year}-01-01`,
endedOn: `${year}-01-10`,
@@ -402,7 +403,7 @@ describe('Reading attempts main-flow simulation (docker e2e)', { timeout: TIMEOU
outcome: 'skimmed',
});
await attempts.createHistorical(adminUserId, book.bookId, { startedOn: null, endedOn: null, outcome: 'completed' });
await expect(dashboard.getCompletedBooksThisYear(adminUserId, [libraryId])).resolves.toBe(completedBefore + 2);
await expect(countThisYear()).resolves.toBe(completedBefore + 2);
});
it('16. isolates attempt history between users sharing a library', async () => {
@@ -805,7 +806,7 @@ describe('Reading attempts main-flow simulation (docker e2e)', { timeout: TIMEOU
expect(latencyResponse.json()).toMatchObject({ totalCompletions: 3, medianDays: 2 });
});
it('32. reports the same completed-book count on the reading-goal widget and the activity overview', async () => {
it('32. reports the same completed-book count on the reading-goal and year-projection widgets and the activity overview', async () => {
const username = `activity-goal-${randomUUID()}`;
const password = 'ActivityGoal123!';
const [user] = await ctx.db
@@ -916,6 +917,17 @@ describe('Reading attempts main-flow simulation (docker e2e)', { timeout: TIMEOU
expect(login.statusCode).toBe(200);
const token = (login.json() as { accessToken: string }).accessToken;
// The twice-read book is now on its third read. Its status row holds no finish while that
// runs, which once dropped both of this year's finishes from the year projection.
const reread = await ctx.app.inject({
method: 'POST',
url: `/api/v1/books/${reRead.bookId}/reading-attempts/start-reread`,
headers: auth(token),
payload: { resetProgress: false },
});
expect(reread.statusCode).toBe(201);
expect(reread.json()).toMatchObject({ status: 'rereading' });
const widgetResponse = await ctx.app.inject({
method: 'GET',
url: '/api/v1/dashboard/widgets/reading-goal',
@@ -924,6 +936,14 @@ describe('Reading attempts main-flow simulation (docker e2e)', { timeout: TIMEOU
expect(widgetResponse.statusCode).toBe(200);
const widget = widgetResponse.json() as { completedBooks: number; year: number };
const projectionResponse = await ctx.app.inject({
method: 'GET',
url: '/api/v1/dashboard/widgets/year-projection',
headers: auth(token),
});
expect(projectionResponse.statusCode).toBe(200);
const projection = projectionResponse.json() as { booksCompletedYtd: number };
const overviewResponse = await ctx.app.inject({
method: 'GET',
url: '/api/v1/user-statistics/activity-overview',
@@ -936,9 +956,10 @@ describe('Reading attempts main-flow simulation (docker e2e)', { timeout: TIMEOU
completion: { months: Array<{ year: number; month: number; count: number }> };
};
// Four books finished this year, one of them twice: the two surfaces have to say the same
// Four books finished this year, one of them twice: every surface has to say the same
// thing, and say 5, rather than the 2 a session-derived count would have reported.
expect(widget.completedBooks).toBe(5);
expect(projection.booksCompletedYtd).toBe(widget.completedBooks);
expect(overview.goal.completedBooks).toBe(widget.completedBooks);
expect(overview.snapshot.completedBooksYtd).toBe(widget.completedBooks);
expect(overview.goal.points.at(-1)?.actualCumulative).toBe(5);