feat(stats): add a reading history of every finished read

Add a History page listing every read newest first, rereads included, as a
virtualized ledger or month cover walls, with period totals, per-read ratings,
export and a share image. Deleting a book keeps its finished reads under a
title and author snapshot (migration 0128).

Closes #1630
This commit is contained in:
neon
2026-10-22 14:45:09 +00:00
parent d21c68d13c
commit 1c593d7403
75 changed files with 31742 additions and 75 deletions
+2
View File
@@ -77,6 +77,8 @@ export type ReadingAttempt = {
origin: ReadingAttemptOrigin;
externalProvider: string | null;
externalId: string | null;
/** This read's own stars; null when unrated. */
rating: number | null;
totalSessions: number;
totalSeconds: number;
createdAt: string;
+1
View File
@@ -73,6 +73,7 @@ export * from "./font";
export * from "./dictionary";
export * from "./achievement";
export * from "./activity";
export * from "./reading-history";
export * from "./reading-session";
export * from "./reading-session-source-bucket";
export * from "./shared-reading-insights";
+134
View File
@@ -0,0 +1,134 @@
import type { ReadingAttemptOutcome } from "./book";
import type { ReadingSessionSource } from "./reading-session";
import type { ReadingSessionSourceBucket } from "./reading-session-source-bucket";
export const READING_HISTORY_SORTS = ["newest", "oldest"] as const;
export type ReadingHistorySort = (typeof READING_HISTORY_SORTS)[number];
export const READING_HISTORY_PAGE_SIZE_MAX = 200;
/** Month key (`YYYY-MM`) of the bucket that holds finished reads with no finish date. */
export const READING_HISTORY_UNDATED_MONTH = "undated";
/** One finished read: a completed, skimmed or abandoned attempt, newest first by default. */
export type ReadingHistoryItem = {
id: number;
/** Null once the book was deleted; the read keeps the snapshot taken when it went. */
bookId: number | null;
title: string | null;
authors: string[];
seriesName: string | null;
seriesIndex: string | null;
format: string | null;
pageCount: number | null;
coverVersion: string | null;
startedOn: string | null;
endedOn: string | null;
outcome: ReadingAttemptOutcome;
/** Which finish of this book it was, counting from the first. Null unless completed. */
readNumber: number | null;
/** Completed reads of this book in all. */
completedReads: number;
/** How far a skimmed or abandoned read got, in percent. */
progressPercent: number | null;
sessions: number;
seconds: number;
sources: ReadingSessionSource[];
/** This read's stars. The latest completed read falls back to the book's rating. */
rating: number | null;
/** Highlights and journal entries made between the read's start and finish. */
highlightCount: number;
journalCount: number;
removedAt: string | null;
};
/** The list's months in order, so the client can lay out every row before it loads. */
export type ReadingHistoryMonth = {
/** `YYYY-MM`, or `undated`. */
month: string;
/** Rows in this month under the current filters. */
reads: number;
completed: number;
rereads: number;
pages: number;
seconds: number;
};
export type ReadingHistoryPage = {
items: ReadingHistoryItem[];
offset: number;
limit: number;
total: number;
/** Sent with the first page only (offset 0). */
months: ReadingHistoryMonth[] | null;
};
export type ReadingHistoryRecord = {
item: Pick<ReadingHistoryItem, "id" | "bookId" | "title" | "coverVersion" | "removedAt">;
value: number;
};
export type ReadingHistorySummary = {
year: number | null;
quarter: number | null;
/** Every year with a completed read, newest first, for the year picker. */
years: { year: number; reads: number }[];
/** Completed reads with no finish date; only All time lists them. */
undatedReads: number;
reads: number;
books: number;
rereads: number;
pages: number;
/** Reading time in the period, from every session, finished or not. */
seconds: number;
daysRead: number;
averageRating: number | null;
ratedReads: number;
/** Skimmed and abandoned reads in the period. They never count toward the totals above. */
notCounted: number;
/** Every finished read of the period by how it ended, for the outcome filter. */
outcomes: Record<ReadingAttemptOutcome, number>;
/** Every month of the period's year, even when a quarter is chosen; empty for All time. */
months: { month: number; reads: number; rereads: number; seconds: number }[];
records: {
longestBook: ReadingHistoryRecord | null;
mostReread: ReadingHistoryRecord | null;
longestRead: ReadingHistoryRecord | null;
fastest: ReadingHistoryRecord | null;
};
formats: { format: string; reads: number }[];
/** Completed reads with a session from each app or device, grouped as the charts group them. */
sources: { source: ReadingSessionSourceBucket; reads: number }[];
/** The reading goal of the period's year, when one is set. */
goal: { target: number; reached: number; metOn: string | null } | null;
};
export type ReadingHistoryMark = {
id: number;
text: string;
note: string | null;
color: string;
chapterTitle: string | null;
createdAt: string;
};
export type ReadingHistoryJournalEntry = {
id: number;
body: string;
chapterTitle: string | null;
createdAt: string;
};
/** The marks a read left: the first few highlights and journal entries, and how many in all. */
export type ReadingHistoryMarks = {
highlights: ReadingHistoryMark[];
highlightCount: number;
journal: ReadingHistoryJournalEntry[];
journalCount: number;
};
export type ReadingHistoryRatingResponse = {
rating: number | null;
/** True when the read is its book's latest completed read, so the book's rating changed too. */
bookRatingChanged: boolean;
};