feat(book-detail): rebuild the highlights tab as a single-screen ledger

Replace the paginated card list with a three-column ledger: a marks rail, a
grouped stream that loads on scroll, an index that regroups by chapter, colour
or day, and a chapter position band. Add chapterBreakdown and activity to the
annotation stats so those panels describe the whole book without loading every
row, and move the metadata score colour onto a shared oklch ramp.
This commit is contained in:
neon
2026-08-26 17:04:48 +00:00
parent 4c42e0c73f
commit 75ccb8dd1a
32 changed files with 2874 additions and 308 deletions
@@ -0,0 +1,153 @@
import { foldActivityRows, foldChapterRows, spinePosition, type ActivityStatRow, type ChapterStatRow } from './annotation-stats.utils';
function chapterRow(overrides: Partial<ChapterStatRow> = {}): ChapterStatRow {
return {
title: 'Loomings',
color: 'yellow',
count: 1,
chapterIndex: null,
cfiSpineStep: null,
firstCreatedAt: new Date('2026-04-02T00:00:00Z'),
...overrides,
};
}
describe('spinePosition', () => {
it('prefers the chapter index the converter recorded', () => {
expect(spinePosition(6, 99)).toBe(6);
});
it('reads a spine index out of the doubled CFI step', () => {
expect(spinePosition(null, 2)).toBe(0);
expect(spinePosition(null, 14)).toBe(6);
});
it('has no position when neither source is present', () => {
expect(spinePosition(null, null)).toBeNull();
});
it('rejects a step that cannot address a spine item', () => {
expect(spinePosition(null, 0)).toBeNull();
});
});
describe('foldChapterRows', () => {
it('orders a part-synced book correctly across both position sources', () => {
// Kobo highlights arrive as raw CFIs and KOReader ones as a converted chapter index. Sorting
// the two raw numbers against each other interleaved the chapters of the same book.
const result = foldChapterRows([
chapterRow({ title: 'The Spouter-Inn', cfiSpineStep: 6 }),
chapterRow({ title: 'The Lee Shore', chapterIndex: 7 }),
chapterRow({ title: 'The Sermon', cfiSpineStep: 8 }),
]);
expect(result.map((c) => c.title)).toEqual(['The Spouter-Inn', 'The Sermon', 'The Lee Shore']);
});
it('collapses the colour grouping into one entry per chapter', () => {
const result = foldChapterRows([
chapterRow({ title: 'Loomings', color: 'yellow', count: 5, chapterIndex: 2 }),
chapterRow({ title: 'Loomings', color: '#38BDF8', count: 3, chapterIndex: 2 }),
chapterRow({ title: 'Cetology', color: 'yellow', count: 1, chapterIndex: 8 }),
]);
expect(result).toHaveLength(2);
expect(result[0]).toMatchObject({ title: 'Loomings', count: 8 });
expect(result[0].colors).toEqual([
{ color: 'yellow', count: 5 },
{ color: '#38BDF8', count: 3 },
]);
expect(result[1]).toMatchObject({ title: 'Cetology', count: 1 });
});
it('orders chapters by their position through the book', () => {
const result = foldChapterRows([
chapterRow({ title: 'Epilogue', chapterIndex: 120 }),
chapterRow({ title: 'Loomings', chapterIndex: 2 }),
chapterRow({ title: 'The Whiteness of the Whale', chapterIndex: 42 }),
]);
expect(result.map((c) => c.title)).toEqual(['Loomings', 'The Whiteness of the Whale', 'Epilogue']);
});
it('falls back to first-marked order for chapters with no resolvable position', () => {
const result = foldChapterRows([
chapterRow({ title: 'Later', firstCreatedAt: new Date('2026-05-01T00:00:00Z') }),
chapterRow({ title: 'Earlier', firstCreatedAt: new Date('2026-04-01T00:00:00Z') }),
]);
expect(result.map((c) => c.title)).toEqual(['Earlier', 'Later']);
});
it('sorts positioned chapters ahead of unpositioned ones', () => {
const result = foldChapterRows([
chapterRow({ title: 'No position', firstCreatedAt: new Date('2026-01-01T00:00:00Z') }),
chapterRow({ title: 'Positioned', chapterIndex: 90, firstCreatedAt: new Date('2026-09-01T00:00:00Z') }),
]);
expect(result.map((c) => c.title)).toEqual(['Positioned', 'No position']);
});
it('keeps highlights with no chapter title as their own entry', () => {
const result = foldChapterRows([chapterRow({ title: null, count: 4 })]);
expect(result).toHaveLength(1);
expect(result[0].title).toBeNull();
expect(result[0].count).toBe(4);
});
it('takes the lowest chapter index and the earliest timestamp across the group', () => {
const result = foldChapterRows([
chapterRow({ color: 'yellow', chapterIndex: 7, firstCreatedAt: new Date('2026-04-05T00:00:00Z') }),
chapterRow({ color: '#F472B6', chapterIndex: 6, firstCreatedAt: new Date('2026-04-02T00:00:00Z') }),
]);
expect(result[0].chapterIndex).toBe(6);
expect(result[0].order).toBe(6);
expect(result[0].firstCreatedAt).toBe('2026-04-02T00:00:00.000Z');
});
it('ignores null indexes rather than treating them as zero', () => {
const result = foldChapterRows([
chapterRow({ color: 'yellow', chapterIndex: null, cfiSpineStep: null }),
chapterRow({ color: '#38BDF8', chapterIndex: 4 }),
]);
expect(result[0].chapterIndex).toBe(4);
expect(result[0].order).toBe(4);
});
it('returns nothing for a book with no highlights', () => {
expect(foldChapterRows([])).toEqual([]);
});
});
describe('foldActivityRows', () => {
function activityRow(overrides: Partial<ActivityStatRow> = {}): ActivityStatRow {
return { day: '2026-08-19', origin: 'kobo', count: 1, ...overrides };
}
it('collapses the origin grouping into one entry per day, newest first', () => {
const result = foldActivityRows([
activityRow({ day: '2026-08-13', origin: 'web', count: 2 }),
activityRow({ day: '2026-08-19', origin: 'kobo', count: 3 }),
activityRow({ day: '2026-08-19', origin: 'koreader', count: 1 }),
]);
expect(result).toEqual([
{
day: '2026-08-19',
count: 4,
origins: [
{ origin: 'kobo', count: 3 },
{ origin: 'koreader', count: 1 },
],
},
{ day: '2026-08-13', count: 2, origins: [{ origin: 'web', count: 2 }] },
]);
});
it('returns nothing for a book with no highlights', () => {
expect(foldActivityRows([])).toEqual([]);
});
});
@@ -0,0 +1,115 @@
import type { AnnotationRow } from '../../db/schema';
import type { AnnotationActivityResult, AnnotationChapterStatResult } from './annotation.repository';
export interface ChapterStatRow {
title: string | null;
color: string;
count: number;
/** Recorded by the converter for anything that arrived as an xpointer. Already a spine index. */
chapterIndex: number | null;
/** The `N` in `epubcfi(/6/N!...)`: the 1-based child position of the spine itemref, doubled. */
cfiSpineStep: number | null;
firstCreatedAt: Date | string;
}
/**
* Puts both position sources on one scale. A CFI addresses the spine itemref by its doubled
* 1-based child position, so `/6/14` is the seventh item, index 6; the converter's chapterIndex
* is already that index. Mixing the two raw would interleave a KOReader chapter between two
* Kobo ones, which is what the chapter index of a part-synced book looked like before this.
*/
export function spinePosition(chapterIndex: number | null, cfiSpineStep: number | null): number | null {
if (chapterIndex != null) return chapterIndex;
if (cfiSpineStep == null || cfiSpineStep < 2) return null;
return Math.floor(cfiSpineStep / 2) - 1;
}
export interface ActivityStatRow {
day: string;
origin: AnnotationRow['origin'];
count: number;
}
function toIso(value: Date | string): string {
return value instanceof Date ? value.toISOString() : new Date(value).toISOString();
}
function minNullable(current: number | null, next: number | null): number | null {
if (next == null) return current;
if (current == null) return next;
return Math.min(current, next);
}
/**
* Collapses the `(chapter_title, color)` grouping the repository asks Postgres for into one
* entry per chapter. Grouping by both in a single query gives the per-chapter totals and the
* colour composition together, so the index and the position band cost one aggregate rather
* than one query each.
*
* Chapters are ordered by their position through the book. `order` is absent for formats that
* carry no resolvable position (PDF), so those fall back to when they were first marked, which
* matches reading order for anything read once.
*/
export function foldChapterRows(rows: ChapterStatRow[]): AnnotationChapterStatResult[] {
// Keyed by the title itself, null included, so no sentinel string can collide with a
// chapter that is genuinely called that.
const byTitle = new Map<string | null, AnnotationChapterStatResult>();
for (const row of rows) {
const key = row.title;
const existing = byTitle.get(key);
const createdAt = toIso(row.firstCreatedAt);
const order = spinePosition(row.chapterIndex, row.cfiSpineStep);
if (!existing) {
byTitle.set(key, {
title: row.title,
count: row.count,
colors: [{ color: row.color, count: row.count }],
chapterIndex: row.chapterIndex,
order,
firstCreatedAt: createdAt,
});
continue;
}
existing.count += row.count;
existing.colors.push({ color: row.color, count: row.count });
existing.chapterIndex = minNullable(existing.chapterIndex, row.chapterIndex);
existing.order = minNullable(existing.order, order);
if (createdAt < existing.firstCreatedAt) existing.firstCreatedAt = createdAt;
}
const chapters = [...byTitle.values()];
for (const chapter of chapters) {
chapter.colors.sort((a, b) => b.count - a.count || a.color.localeCompare(b.color));
}
return chapters.sort((a, b) => {
if (a.order != null && b.order != null && a.order !== b.order) return a.order - b.order;
if (a.order != null && b.order == null) return -1;
if (a.order == null && b.order != null) return 1;
if (a.firstCreatedAt !== b.firstCreatedAt) return a.firstCreatedAt < b.firstCreatedAt ? -1 : 1;
return (a.title ?? '').localeCompare(b.title ?? '');
});
}
/** Collapses the `(day, origin)` grouping into one entry per day, newest first. */
export function foldActivityRows(rows: ActivityStatRow[]): AnnotationActivityResult[] {
const byDay = new Map<string, AnnotationActivityResult>();
for (const row of rows) {
const existing = byDay.get(row.day);
if (!existing) {
byDay.set(row.day, { day: row.day, count: row.count, origins: [{ origin: row.origin, count: row.count }] });
continue;
}
existing.count += row.count;
existing.origins.push({ origin: row.origin, count: row.count });
}
const days = [...byDay.values()];
for (const day of days) {
day.origins.sort((a, b) => b.count - a.count || a.origin.localeCompare(b.origin));
}
return days.sort((a, b) => b.day.localeCompare(a.day));
}
@@ -306,6 +306,30 @@ describe('AnnotationRepository', () => {
]);
});
it('folds the chapter and activity groupings into per-chapter and per-day entries', async () => {
const db = makeDb(
[{ totalHighlights: 3, chaptersWithHighlights: 2, highlightsWithNotes: 0 }],
[],
[],
[
{ title: 'Cetology', color: 'yellow', count: 1, chapterIndex: 28, cfiSpineStep: 58, firstCreatedAt: new Date('2026-04-09T00:00:00Z') },
{ title: 'Loomings', color: 'yellow', count: 1, chapterIndex: 0, cfiSpineStep: 2, firstCreatedAt: new Date('2026-04-02T00:00:00Z') },
{ title: 'Loomings', color: '#38BDF8', count: 1, chapterIndex: 0, cfiSpineStep: 2, firstCreatedAt: new Date('2026-04-03T00:00:00Z') },
],
[
{ day: '2026-04-02', origin: 'kobo', count: 2 },
{ day: '2026-04-09', origin: 'web', count: 1 },
],
);
const repo = new AnnotationRepository(db as never);
const result = await repo.getStats(5, 10, {});
expect(result.chapterBreakdown.map((c) => c.title)).toEqual(['Loomings', 'Cetology']);
expect(result.chapterBreakdown[0]).toMatchObject({ count: 2, chapterIndex: 0, order: 0 });
expect(result.activity.map((a) => a.day)).toEqual(['2026-04-09', '2026-04-02']);
});
it('returns zero stats when no annotations exist', async () => {
const db = makeDb([{ totalHighlights: 0, chaptersWithHighlights: 0, highlightsWithNotes: 0 }], [], []);
const repo = new AnnotationRepository(db as never);
@@ -21,6 +21,7 @@ import {
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
import { DB } from '../../db';
import { foldActivityRows, foldChapterRows } from './annotation-stats.utils';
import { accentInsensitiveIlike } from '../../common/utils/accent-insensitive-search.utils';
import * as schema from '../../db/schema';
import {
@@ -83,12 +84,29 @@ export interface PaginatedAnnotations {
total: number;
}
export interface AnnotationChapterStatResult {
title: string | null;
count: number;
colors: { color: string; count: number }[];
chapterIndex: number | null;
order: number | null;
firstCreatedAt: string;
}
export interface AnnotationActivityResult {
day: string;
count: number;
origins: { origin: AnnotationRow['origin']; count: number }[];
}
export interface AnnotationStatsResult {
totalHighlights: number;
colorBreakdown: { color: string; count: number }[];
originBreakdown: { origin: AnnotationRow['origin']; count: number }[];
chaptersWithHighlights: number;
highlightsWithNotes: number;
chapterBreakdown: AnnotationChapterStatResult[];
activity: AnnotationActivityResult[];
}
@Injectable()
@@ -182,7 +200,7 @@ export class AnnotationRepository {
const conditions = this.buildConditions(bookId, userId, filters);
const cfiJoin = and(eq(annotationPositions.annotationId, annotations.id), eq(annotationPositions.format, 'cfi'));
const [aggregateResult, colorResult, originResult] = await Promise.all([
const [aggregateResult, colorResult, originResult, chapterResult, activityResult] = await Promise.all([
this.db
.select({
totalHighlights: count(),
@@ -212,6 +230,31 @@ export class AnnotationRepository {
.where(and(...conditions))
.groupBy(annotations.origin)
.orderBy(desc(count())),
this.db
.select({
title: annotations.chapterTitle,
color: annotations.color,
count: count(),
chapterIndex: sql<number | null>`min((${annotationPositions.extras} ->> 'chapterIndex')::int)`,
// The step after `/6` in an epub CFI addresses the spine itemref. Kept raw here and
// converted to a spine index in foldChapterRows, where it can be tested.
cfiSpineStep: sql<number | null>`min(nullif(substring(${annotationPositions.pos0} from 'epubcfi[(]/6/([0-9]+)'), '')::int)`,
firstCreatedAt: sql<Date>`min(${annotations.createdAt})`,
})
.from(annotations)
.leftJoin(annotationPositions, cfiJoin)
.where(and(...conditions))
.groupBy(annotations.chapterTitle, annotations.color),
this.db
.select({
day: sql<string>`to_char(${annotations.createdAt} at time zone 'UTC', 'YYYY-MM-DD')`,
origin: annotations.origin,
count: count(),
})
.from(annotations)
.leftJoin(annotationPositions, cfiJoin)
.where(and(...conditions))
.groupBy(sql`1`, annotations.origin),
]);
const agg = aggregateResult[0];
@@ -222,6 +265,8 @@ export class AnnotationRepository {
highlightsWithNotes: agg?.highlightsWithNotes ?? 0,
colorBreakdown: colorResult.map((r) => ({ color: r.color, count: r.count })),
originBreakdown: originResult.map((r) => ({ origin: r.origin, count: r.count })),
chapterBreakdown: foldChapterRows(chapterResult),
activity: foldActivityRows(activityResult),
};
}
@@ -397,6 +397,8 @@ describe('AnnotationService', () => {
originBreakdown: [{ origin: 'web', count: 3 }],
chaptersWithHighlights: 2,
highlightsWithNotes: 1,
chapterBreakdown: [],
activity: [],
...overrides,
};
}