feat(reader): jump to reading session checkpoints

Expose each session's originating file so the reader can restore a validated historical progress position without losing exact highlight navigation.

Closes #1045
This commit is contained in:
neon
2026-08-25 01:20:12 +00:00
parent e165812792
commit 6702d5b729
13 changed files with 250 additions and 10 deletions
@@ -229,6 +229,7 @@ const quickFilters = computed<{ label: string; value: QuickFilter }[]>(() => [
</div>
<ReadingLogTable
:book-id="book.id"
:sessions="sessions"
:total="total"
:sort-by="sortBy"
@@ -4,8 +4,10 @@ import { useI18n } from 'vue-i18n'
import { formatDate as formatLocaleDate } from '@/i18n/formatters'
import { ChevronDown, ChevronUp, ChevronsUpDown, Loader2, Trash2, X } from '@lucide/vue'
import type { BookReadingSession, ReadingSessionSource } from '@bookorbit/types'
import { buildReadingCheckpointRoute } from '@/lib/reading-checkpoint'
const props = defineProps<{
bookId: number
sessions: BookReadingSession[]
total: number
sortBy: string
@@ -79,11 +81,19 @@ function formatProgressDelta(progressDelta: number | null): string {
return `${prefix}${progressDelta.toFixed(1)}%`
}
function formatEndProgress(endProgress: number | null): string {
return endProgress == null ? '-' : `${endProgress.toFixed(1)}%`
}
function formatPace(session: BookReadingSession): string {
if (session.progressDelta == null || session.progressDelta <= 0 || session.durationSeconds === 0) return '-'
return `${((session.progressDelta / session.durationSeconds) * 3600).toFixed(1)}%/hr`
}
function checkpointRoute(session: BookReadingSession) {
return buildReadingCheckpointRoute(props.bookId, session)
}
const PILL_BASE = 'inline-flex items-center gap-1 rounded border px-1.5 py-0.5 text-[10px] font-medium'
const SESSION_SOURCE_PILLS = computed<Record<ReadingSessionSource, { label: string; class: string }>>(() => ({
@@ -304,7 +314,15 @@ const rows = computed<TableRow[]>(() => {
<div>
<dt class="text-muted-foreground">{{ t('book.detail.readingLog.table.colEndProgressMobile') }}</dt>
<dd class="mt-0.5 font-medium text-foreground">
{{ row.session.endProgress != null ? `${row.session.endProgress.toFixed(1)}%` : '-' }}
<RouterLink
v-if="checkpointRoute(row.session)"
:to="checkpointRoute(row.session)!"
class="rounded underline decoration-border underline-offset-2 transition-colors hover:decoration-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2"
:aria-label="t('book.detail.readingLog.table.jumpToProgress', { progress: row.session.endProgress?.toFixed(1) })"
>
{{ formatEndProgress(row.session.endProgress) }}
</RouterLink>
<template v-else>{{ formatEndProgress(row.session.endProgress) }}</template>
</dd>
</div>
</dl>
@@ -372,7 +390,15 @@ const rows = computed<TableRow[]>(() => {
{{ formatProgressDelta(row.session.progressDelta) }}
</td>
<td class="whitespace-nowrap px-4 py-1.5 text-foreground">
{{ row.session.endProgress != null ? `${row.session.endProgress.toFixed(1)}%` : '-' }}
<RouterLink
v-if="checkpointRoute(row.session)"
:to="checkpointRoute(row.session)!"
class="rounded underline decoration-border underline-offset-2 transition-colors hover:decoration-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2"
:aria-label="t('book.detail.readingLog.table.jumpToProgress', { progress: row.session.endProgress?.toFixed(1) })"
>
{{ formatEndProgress(row.session.endProgress) }}
</RouterLink>
<template v-else>{{ formatEndProgress(row.session.endProgress) }}</template>
</td>
<td class="hidden whitespace-nowrap px-4 py-1.5 text-muted-foreground sm:table-cell">{{ formatPace(row.session) }}</td>
<td v-if="showSource" class="whitespace-nowrap px-4 py-1.5">
@@ -1,10 +1,11 @@
import { mount } from '@vue/test-utils'
import { mount, RouterLinkStub } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import ReadingLogTable from '../ReadingLogTable.vue'
function makeSession(overrides = {}) {
return {
id: 1,
bookFileId: 17,
startedAt: '2026-04-15T10:00:00.000Z',
endedAt: '2026-04-15T10:30:00.000Z',
durationSeconds: 1800,
@@ -19,6 +20,7 @@ function makeSession(overrides = {}) {
function mountTable(props = {}) {
return mount(ReadingLogTable, {
props: {
bookId: 10,
sessions: [makeSession()],
total: 1,
sortBy: 'startedAt',
@@ -29,6 +31,7 @@ function mountTable(props = {}) {
hasMultipleFormats: false,
...props,
},
global: { stubs: { RouterLink: RouterLinkStub } },
})
}
@@ -102,6 +105,41 @@ describe('ReadingLogTable', () => {
expect(wrapper.text()).toContain('42.0%')
})
it('links EPUB end progress to the originating file and checkpoint', () => {
const wrapper = mountTable({ sessions: [makeSession({ bookFileId: 17, endProgress: 42.25, format: 'EPUB' })] })
const links = wrapper.findAllComponents(RouterLinkStub)
expect(links).toHaveLength(2)
expect(links[0]!.props('to')).toEqual({
name: 'reader',
params: { bookId: 10, fileId: 17 },
query: { format: 'epub', checkpoint: '42.25' },
})
expect(links[0]!.attributes('aria-label')).toBe('Jump to 42.3%')
})
it.each([
['missing file', { bookFileId: null }],
['invalid file', { bookFileId: 0 }],
['missing progress', { endProgress: null }],
['non-finite progress', { endProgress: Number.NaN }],
['progress below range', { endProgress: -0.1 }],
['progress above range', { endProgress: 100.1 }],
['missing format', { format: null }],
['PDF format', { format: 'pdf' }],
['comic format', { format: 'cbz' }],
['audio format', { format: 'm4b' }],
['unsupported format', { format: 'kepub' }],
])('does not link end progress for %s', (_case, overrides) => {
const wrapper = mountTable({ sessions: [makeSession(overrides)] })
expect(wrapper.findAllComponents(RouterLinkStub)).toHaveLength(0)
})
it.each([0, 100])('links the %s%% boundary checkpoint', (endProgress) => {
const wrapper = mountTable({ sessions: [makeSession({ endProgress })] })
expect(wrapper.findAllComponents(RouterLinkStub)[0]!.props('to')).toMatchObject({ query: { checkpoint: String(endProgress) } })
})
it('shows "-" for null endProgress', () => {
const wrapper = mountTable({ sessions: [makeSession({ endProgress: null })] })
expect(wrapper.find('tbody').text()).toContain('-')
@@ -35,6 +35,7 @@ const emptyStats = {
function makeSession(id: number, overrides?: Record<string, unknown>) {
return {
id,
bookFileId: null,
startedAt: '2026-04-15T10:00:00.000Z',
endedAt: '2026-04-15T10:30:00.000Z',
durationSeconds: 1800,
+7 -4
View File
@@ -39,6 +39,7 @@ import type { FoliateLocationContext, FoliateRenderer } from './epub/composables
import type { EpubReaderSettings } from '@bookorbit/types'
import { findMatchingCfiRange } from './epub/utils'
import { getFormatGroup } from '@bookorbit/types'
import { resolveReaderResumeTarget } from '@/lib/reading-checkpoint'
const PdfV4ReaderView = defineAsyncComponent(() => import('./pdf-v4/PdfV4ReaderView.vue'))
@@ -304,8 +305,10 @@ onMounted(async () => {
shouldApplyStyles.value = false
}
const deepLinkCfi = typeof route.query.cfi === 'string' ? route.query.cfi : null
const hadProgress = progress.percentage.value > 0
await open(bookId, fileId, fileFormat, progress.cfi.value, hadProgress ? progress.percentage.value / 100 : undefined, {
const resumeTarget = resolveReaderResumeTarget(deepLinkCfi ? undefined : route.query.checkpoint, progress.cfi.value, progress.percentage.value)
await open(bookId, fileId, fileFormat, resumeTarget.cfi, resumeTarget.fraction, {
fixedLayoutSpread: state.value.fixedLayoutSpread,
})
setChapters(getChapters())
@@ -318,7 +321,6 @@ onMounted(async () => {
}
void hydrateSidebarLocationMeta()
const deepLinkCfi = typeof route.query.cfi === 'string' ? route.query.cfi : null
if (deepLinkCfi) {
try {
await goTo(deepLinkCfi)
@@ -327,8 +329,9 @@ onMounted(async () => {
}
}
if (hadProgress) {
const pct = Math.round(progress.percentage.value)
const resumedPercentage = resumeTarget.checkpointPercentage ?? (hadProgress ? progress.percentage.value : null)
if (resumedPercentage !== null) {
const pct = Math.round(resumedPercentage)
const label = chapterTitle.value || t('reader.chapterNumber', { number: sectionIndex.value + 1 })
toast.info(t('reader.toast.resumed', { pct, label }), { duration: 2500 })
}
@@ -0,0 +1,85 @@
import { describe, expect, it } from 'vitest'
import type { BookReadingSession } from '@bookorbit/types'
import { buildReadingCheckpointRoute, parseReadingCheckpointPercentage, resolveReaderResumeTarget } from '../reading-checkpoint'
function makeSession(overrides: Partial<BookReadingSession> = {}): BookReadingSession {
return {
id: 1,
bookFileId: 17,
startedAt: '2026-04-15T10:00:00.000Z',
endedAt: '2026-04-15T10:30:00.000Z',
durationSeconds: 1800,
progressDelta: 5,
endProgress: 42.25,
format: 'epub',
source: 'web',
...overrides,
}
}
describe('parseReadingCheckpointPercentage', () => {
it.each([
['0', 0],
['42.25', 42.25],
[' 75 ', 75],
['100', 100],
])('parses %s', (value, expected) => {
expect(parseReadingCheckpointPercentage(value)).toBe(expected)
})
it.each([undefined, null, '', ' ', [], ['42'], 'NaN', 'Infinity', '-0.1', '100.1'])('rejects invalid checkpoint %j', (value) => {
expect(parseReadingCheckpointPercentage(value)).toBeNull()
})
})
describe('resolveReaderResumeTarget', () => {
it('uses a valid checkpoint instead of the saved CFI and percentage', () => {
expect(resolveReaderResumeTarget('42.25', 'epubcfi(saved)', 81)).toEqual({
checkpointPercentage: 42.25,
cfi: null,
fraction: 0.4225,
})
})
it('treats a zero checkpoint as an explicit jump to the start', () => {
expect(resolveReaderResumeTarget('0', 'epubcfi(saved)', 81)).toEqual({
checkpointPercentage: 0,
cfi: null,
fraction: 0,
})
})
it('falls back to saved progress when the checkpoint is invalid', () => {
expect(resolveReaderResumeTarget('not-a-number', 'epubcfi(saved)', 81)).toEqual({
checkpointPercentage: null,
cfi: 'epubcfi(saved)',
fraction: 0.81,
})
})
it('omits a fallback fraction for unread books', () => {
expect(resolveReaderResumeTarget(undefined, null, 0)).toEqual({
checkpointPercentage: null,
cfi: null,
fraction: undefined,
})
})
})
describe('buildReadingCheckpointRoute', () => {
it('normalizes the format and preserves raw checkpoint precision', () => {
expect(buildReadingCheckpointRoute(10, makeSession({ format: ' EPUB ' }))).toEqual({
name: 'reader',
params: { bookId: 10, fileId: 17 },
query: { format: 'epub', checkpoint: '42.25' },
})
})
it.each(['epub', 'mobi', 'azw3', 'azw', 'fb2'])('supports the %s Foliate format', (format) => {
expect(buildReadingCheckpointRoute(10, makeSession({ format }))).not.toBeNull()
})
it.each(['pdf', 'cbz', 'm4b', 'kepub', 'unknown'])('rejects the %s format', (format) => {
expect(buildReadingCheckpointRoute(10, makeSession({ format }))).toBeNull()
})
})
+54
View File
@@ -0,0 +1,54 @@
import type { BookReadingSession } from '@bookorbit/types'
import { getFormatGroup, READER_OPENABLE_FORMATS } from '@bookorbit/types'
import type { RouteLocationRaw } from 'vue-router'
export interface ReaderResumeTarget {
checkpointPercentage: number | null
cfi: string | null
fraction: number | undefined
}
export function parseReadingCheckpointPercentage(value: unknown): number | null {
if (typeof value !== 'string') return null
const raw = value.trim()
if (raw.length === 0) return null
const percentage = Number(raw)
if (!Number.isFinite(percentage) || percentage < 0 || percentage > 100) return null
return percentage
}
export function resolveReaderResumeTarget(checkpointValue: unknown, savedCfi: string | null, savedPercentage: number): ReaderResumeTarget {
const checkpointPercentage = parseReadingCheckpointPercentage(checkpointValue)
if (checkpointPercentage !== null) {
return {
checkpointPercentage,
cfi: null,
fraction: checkpointPercentage / 100,
}
}
return {
checkpointPercentage: null,
cfi: savedCfi,
fraction: savedPercentage > 0 ? savedPercentage / 100 : undefined,
}
}
export function buildReadingCheckpointRoute(bookId: number, session: BookReadingSession): RouteLocationRaw | null {
const bookFileId = session.bookFileId
const format = session.format?.trim().toLowerCase()
const percentage = session.endProgress
if (!Number.isInteger(bookId) || bookId <= 0) return null
if (bookFileId === null || !Number.isInteger(bookFileId) || bookFileId <= 0) return null
if (typeof percentage !== 'number' || !Number.isFinite(percentage) || percentage < 0 || percentage > 100) return null
if (!format || !READER_OPENABLE_FORMATS.has(format) || getFormatGroup(format) !== 'epub') return null
return {
name: 'reader',
params: { bookId, fileId: bookFileId },
query: { format, checkpoint: String(percentage) },
}
}
+1
View File
@@ -4732,6 +4732,7 @@
"colProgressChangeMobile": "Delta",
"colEndProgress": "End Progress",
"colEndProgressMobile": "End",
"jumpToProgress": "Jump to {progress}%",
"empty": "No reading sessions recorded yet.",
"emptyHint": "Add a session to start building this book's reading history.",
"colPace": "Pace",
+1
View File
@@ -5,6 +5,7 @@ export type ReadingSessionSource = (typeof READING_SESSION_SOURCES)[number];
export interface BookReadingSession {
id: number;
bookFileId: number | null;
startedAt: string;
endedAt: string;
durationSeconds: number;
@@ -326,7 +326,19 @@ describe('ReadingSessionRepository - listByBook', () => {
const later = new Date('2026-04-15T10:30:00.000Z');
const { db } = makeListDb({
rows: [{ id: 1, startedAt: now, endedAt: later, durationSeconds: 1800, progressDelta: 5, endProgress: 50, format: 'epub', source: 'web' }],
rows: [
{
id: 1,
bookFileId: 42,
startedAt: now,
endedAt: later,
durationSeconds: 1800,
progressDelta: 5,
endProgress: 50,
format: 'epub',
source: 'web',
},
],
count: [{ total: 1 }],
stats: [
{
@@ -352,6 +364,7 @@ describe('ReadingSessionRepository - listByBook', () => {
expect(result.stats.paceDurationSeconds).toBe(1800);
expect(result.stats.progressSummary).toEqual([{ day: '2026-04-15', endProgress: 50 }]);
expect(result.items).toHaveLength(1);
expect(result.items[0]?.bookFileId).toBe(42);
expect(result.items[0]?.startedAt).toBe(now.toISOString());
expect(result.items[0]?.source).toBe('web');
});
@@ -427,7 +440,19 @@ describe('ReadingSessionRepository - listByBook', () => {
it('maps null format and null source to null in items', async () => {
const now = new Date('2026-04-15T10:00:00.000Z');
const { db } = makeListDb({
rows: [{ id: 1, startedAt: now, endedAt: now, durationSeconds: 60, progressDelta: null, endProgress: null, format: null, source: null }],
rows: [
{
id: 1,
bookFileId: null,
startedAt: now,
endedAt: now,
durationSeconds: 60,
progressDelta: null,
endProgress: null,
format: null,
source: null,
},
],
count: [{ total: 1 }],
stats: [{ totalSessions: 1, totalSeconds: 60, avgDurationSeconds: 60, firstSessionAt: null, lastSessionAt: null }],
});
@@ -435,6 +460,7 @@ describe('ReadingSessionRepository - listByBook', () => {
const result = await repo.listByBook(1, 2, 1, 25, 'startedAt', 'desc');
expect(result.items[0]?.bookFileId).toBeNull();
expect(result.items[0]?.format).toBeNull();
expect(result.items[0]?.source).toBeNull();
});
@@ -220,6 +220,7 @@ export class ReadingSessionRepository {
this.db
.select({
id: readingSessions.id,
bookFileId: readingSessions.bookFileId,
startedAt: readingSessions.startedAt,
endedAt: readingSessions.endedAt,
durationSeconds: readingSessions.durationSeconds,
@@ -337,6 +338,7 @@ export class ReadingSessionRepository {
const items: BookReadingSession[] = rows.map((r) => ({
id: r.id,
bookFileId: r.bookFileId ?? null,
startedAt: (r.startedAt as Date).toISOString(),
endedAt: (r.endedAt as Date).toISOString(),
durationSeconds: r.durationSeconds,
@@ -348,7 +348,7 @@ describe('ReadingSessionService - createManualSession', () => {
);
const { sessionId } = mockRepoExtended.insertManualSession.mock.calls[0][0] as { sessionId: string };
expect(sessionId.startsWith('manual:')).toBe(true);
expect(result).toMatchObject({ id: 555, durationSeconds: 2700, format: 'epub', source: 'manual' });
expect(result).toMatchObject({ id: 555, bookFileId: 42, durationSeconds: 2700, format: 'epub', source: 'manual' });
});
it('computes progressDelta from the latest prior endProgress', async () => {
@@ -435,6 +435,7 @@ describe('ReadingSessionService - createManualSession', () => {
const result = await svc.createManualSession(10, { startedAt: '2026-04-15T10:00:00.000Z', durationMinutes: 30 }, makeUser({ id: 5 }));
expect(mockRepoExtended.insertManualSession).toHaveBeenCalledWith(expect.objectContaining({ bookFileId: null }));
expect(result.bookFileId).toBeNull();
expect(result.format).toBeNull();
});
@@ -168,6 +168,7 @@ export class ReadingSessionService {
return {
id: created.id,
bookFileId,
startedAt: startedAt.toISOString(),
endedAt: endedAt.toISOString(),
durationSeconds,