mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
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:
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user