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