mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
fix(stats): show manually logged progress in the reading log ring
The ring read only reading_progress, the stored reading position, which nothing writes except the web reader, Kobo sync and KOReader sync. Manual sessions record endProgress on the session row and touch nothing else, so a book whose whole history was entered by hand sat at 0% no matter how far the sessions ran, and marking it read did not help either. Surface the log's own view of progress instead of writing a reading position from a manual session: that path also drives auto read-status updates, achievement events and Hardcover and StoryGraph sync, and would store a position with no CFI for the reader to restore. Take the most recent session that recorded an endProgress rather than the highest, so a reread still reads correctly once its first session lands. Read it outside the list date and format filters, otherwise narrowing the log would drag the ring down with it. The client folds the value into the max it already computes across ebook and audiobook positions, which also lets the ring update when a session is added or deleted. Closes #1030
This commit is contained in:
@@ -193,7 +193,7 @@ function handleFinishedCancel() {
|
||||
cancelDateEdit('finishedAt')
|
||||
}
|
||||
|
||||
const currentProgress = ref(0)
|
||||
const readerProgress = ref(0)
|
||||
const progressLoaded = ref(false)
|
||||
|
||||
async function loadProgress() {
|
||||
@@ -218,9 +218,9 @@ async function loadProgress() {
|
||||
const data = (await audioRes.json()) as { percentage?: number } | null
|
||||
if (data && Number.isFinite(data.percentage)) max = Math.max(max, data.percentage!)
|
||||
}
|
||||
currentProgress.value = Math.min(100, Math.max(0, max))
|
||||
readerProgress.value = Math.min(100, Math.max(0, max))
|
||||
} catch {
|
||||
currentProgress.value = 0
|
||||
readerProgress.value = 0
|
||||
} finally {
|
||||
progressLoaded.value = true
|
||||
}
|
||||
@@ -228,6 +228,17 @@ async function loadProgress() {
|
||||
|
||||
watch(() => props.book.id, loadProgress, { immediate: true })
|
||||
|
||||
// The stored reading position only moves when a reader or a synced device writes it, so a
|
||||
// book whose progress was only ever logged by hand has none. Sessions are the other current
|
||||
// observation of how far the book got, combined the same way as the ebook and audio positions.
|
||||
const loggedProgress = computed(() => {
|
||||
const value = props.stats?.latestEndProgress
|
||||
if (value == null || !Number.isFinite(value)) return 0
|
||||
return Math.min(100, Math.max(0, value))
|
||||
})
|
||||
|
||||
const currentProgress = computed(() => Math.max(readerProgress.value, loggedProgress.value))
|
||||
|
||||
const progressLabel = computed(() => {
|
||||
const value = currentProgress.value
|
||||
if (value > 0 && value < 1) return '<1%'
|
||||
|
||||
@@ -20,6 +20,7 @@ function makeStats(): BookReadingSessionStats {
|
||||
paceProgressDelta: 0,
|
||||
paceDurationSeconds: 0,
|
||||
progressSummary: [],
|
||||
latestEndProgress: null,
|
||||
bySource: [],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,6 +127,7 @@ function makeStats(overrides: Partial<BookReadingSessionStats> = {}): BookReadin
|
||||
paceProgressDelta: 0,
|
||||
paceDurationSeconds: 0,
|
||||
progressSummary: [],
|
||||
latestEndProgress: null,
|
||||
bySource: [],
|
||||
...overrides,
|
||||
}
|
||||
@@ -395,6 +396,71 @@ describe('ReadingLogHero', () => {
|
||||
expect(noPaceWrapper.text()).not.toContain('to finish')
|
||||
})
|
||||
|
||||
it('shows logged session progress for a book with no stored reading position', async () => {
|
||||
mocks.api.mockResolvedValue(makeResponse([{ fileId: 1, percentage: 0 }]))
|
||||
const wrapper = mountHero(makeBook(), makeStats({ latestEndProgress: 100 }))
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('100%')
|
||||
})
|
||||
|
||||
it('keeps the stored reading position when it is further along than the logged sessions', async () => {
|
||||
mocks.api.mockResolvedValue(makeResponse([{ fileId: 1, percentage: 70 }]))
|
||||
const wrapper = mountHero(makeBook(), makeStats({ latestEndProgress: 40 }))
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('70%')
|
||||
})
|
||||
|
||||
it('prefers logged session progress when it is further along than the stored position', async () => {
|
||||
mocks.api.mockResolvedValue(makeResponse([{ fileId: 1, percentage: 12 }]))
|
||||
const wrapper = mountHero(makeBook(), makeStats({ latestEndProgress: 65 }))
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('65%')
|
||||
})
|
||||
|
||||
it('ignores absent and malformed logged session progress', async () => {
|
||||
mocks.api.mockResolvedValue(makeResponse([{ fileId: 1, percentage: 30 }]))
|
||||
const nullWrapper = mountHero(makeBook(), makeStats({ latestEndProgress: null }))
|
||||
await flushPromises()
|
||||
expect(nullWrapper.text()).toContain('30%')
|
||||
|
||||
const nanWrapper = mountHero(makeBook(), makeStats({ latestEndProgress: Number.NaN }))
|
||||
await flushPromises()
|
||||
expect(nanWrapper.text()).toContain('30%')
|
||||
})
|
||||
|
||||
it('clamps logged session progress into the 0-100 range', async () => {
|
||||
mocks.api.mockResolvedValue(makeResponse([{ fileId: 1, percentage: 0 }]))
|
||||
const wrapper = mountHero(makeBook(), makeStats({ latestEndProgress: 140 }))
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('100%')
|
||||
})
|
||||
|
||||
it('updates the ring when a new session arrives without refetching the reading position', async () => {
|
||||
mocks.api.mockResolvedValue(makeResponse([{ fileId: 1, percentage: 0 }]))
|
||||
const wrapper = mountHero(makeBook(), makeStats({ latestEndProgress: null }))
|
||||
await flushPromises()
|
||||
expect(wrapper.text()).toContain('0%')
|
||||
|
||||
const callsBefore = mocks.api.mock.calls.length
|
||||
await wrapper.setProps({ stats: makeStats({ latestEndProgress: 55 }) })
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('55%')
|
||||
expect(mocks.api.mock.calls).toHaveLength(callsBefore)
|
||||
})
|
||||
|
||||
it('drops the eta once logged sessions reach the end of the book', async () => {
|
||||
mocks.api.mockResolvedValue(makeResponse([{ fileId: 1, percentage: 0 }]))
|
||||
const wrapper = mountHero(makeBook(), makeStats({ latestEndProgress: 100, paceProgressDelta: 50, paceDurationSeconds: 3600 }))
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).not.toContain('to finish')
|
||||
})
|
||||
|
||||
it('falls back to zero progress when progress loading fails', async () => {
|
||||
mocks.api.mockRejectedValue(new Error('progress failed'))
|
||||
const wrapper = mountHero()
|
||||
|
||||
+1
@@ -15,6 +15,7 @@ function makeStats(bySource: BookReadingSourceSlice[]) {
|
||||
paceProgressDelta: 0,
|
||||
paceDurationSeconds: 0,
|
||||
progressSummary: [],
|
||||
latestEndProgress: null,
|
||||
bySource,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,6 +93,7 @@ function makeListResponse(items = []) {
|
||||
paceProgressDelta: 0,
|
||||
paceDurationSeconds: 0,
|
||||
progressSummary: [],
|
||||
latestEndProgress: null,
|
||||
},
|
||||
}),
|
||||
} as Response
|
||||
|
||||
@@ -29,6 +29,7 @@ const emptyStats = {
|
||||
paceProgressDelta: 0,
|
||||
paceDurationSeconds: 0,
|
||||
progressSummary: [],
|
||||
latestEndProgress: null,
|
||||
}
|
||||
|
||||
function makeSession(id: number, overrides?: Record<string, unknown>) {
|
||||
|
||||
@@ -30,6 +30,10 @@ export interface BookReadingSessionStats {
|
||||
paceProgressDelta: number;
|
||||
paceDurationSeconds: number;
|
||||
progressSummary: { day: string; endProgress: number }[];
|
||||
// endProgress of the most recently ended session that recorded one, or null when no
|
||||
// session has. Unlike every other field here it ignores the list filters, because it
|
||||
// feeds the book's progress ring rather than a summary of the filtered rows.
|
||||
latestEndProgress: number | null;
|
||||
// Reading time/sessions split across the 3 display buckets, ordered by
|
||||
// READING_SESSION_SOURCE_BUCKETS; only buckets with activity are included.
|
||||
bySource: BookReadingSourceSlice[];
|
||||
|
||||
@@ -302,14 +302,22 @@ describe('ReadingSessionRepository - listByBook', () => {
|
||||
return self;
|
||||
}
|
||||
|
||||
function makeListDb(results: { rows?: unknown[]; count?: unknown[]; stats?: unknown[]; summary?: unknown[]; bySource?: unknown[] }) {
|
||||
function makeListDb(results: {
|
||||
rows?: unknown[];
|
||||
count?: unknown[];
|
||||
stats?: unknown[];
|
||||
summary?: unknown[];
|
||||
bySource?: unknown[];
|
||||
latest?: unknown[];
|
||||
}) {
|
||||
const select = vi
|
||||
.fn()
|
||||
.mockReturnValueOnce({ from: vi.fn().mockReturnValue(makeQueryChain(results.rows ?? [])) })
|
||||
.mockReturnValueOnce({ from: vi.fn().mockReturnValue(makeQueryChain(results.count ?? [])) })
|
||||
.mockReturnValueOnce({ from: vi.fn().mockReturnValue(makeQueryChain(results.stats ?? [])) })
|
||||
.mockReturnValueOnce({ from: vi.fn().mockReturnValue(makeQueryChain(results.summary ?? [])) })
|
||||
.mockReturnValueOnce({ from: vi.fn().mockReturnValue(makeQueryChain(results.bySource ?? [])) });
|
||||
.mockReturnValueOnce({ from: vi.fn().mockReturnValue(makeQueryChain(results.bySource ?? [])) })
|
||||
.mockReturnValueOnce({ from: vi.fn().mockReturnValue(makeQueryChain(results.latest ?? [])) });
|
||||
return { db: { select }, select };
|
||||
}
|
||||
|
||||
@@ -348,13 +356,13 @@ describe('ReadingSessionRepository - listByBook', () => {
|
||||
expect(result.items[0]?.source).toBe('web');
|
||||
});
|
||||
|
||||
it('fires five select queries', async () => {
|
||||
it('fires six select queries', async () => {
|
||||
const { db, select } = makeListDb({});
|
||||
const repo = new ReadingSessionRepository(db as never);
|
||||
|
||||
await repo.listByBook(1, 2, 2, 25, 'startedAt', 'desc');
|
||||
|
||||
expect(select).toHaveBeenCalledTimes(5);
|
||||
expect(select).toHaveBeenCalledTimes(6);
|
||||
});
|
||||
|
||||
it('splits per-book daily summaries across local midnight', async () => {
|
||||
@@ -475,6 +483,71 @@ describe('ReadingSessionRepository - listByBook', () => {
|
||||
|
||||
expect(result.stats.bySource).toEqual([]);
|
||||
});
|
||||
|
||||
it('exposes the latest recorded end progress on stats', async () => {
|
||||
const { db } = makeListDb({ latest: [{ endProgress: 76.5 }] });
|
||||
const repo = new ReadingSessionRepository(db as never);
|
||||
|
||||
const result = await repo.listByBook(1, 2, 1, 25, 'startedAt', 'desc');
|
||||
|
||||
expect(result.stats.latestEndProgress).toBe(76.5);
|
||||
});
|
||||
|
||||
it('reports a null latest end progress when no session recorded one', async () => {
|
||||
const { db } = makeListDb({ latest: [] });
|
||||
const repo = new ReadingSessionRepository(db as never);
|
||||
|
||||
const result = await repo.listByBook(1, 2, 1, 25, 'startedAt', 'desc');
|
||||
|
||||
expect(result.stats.latestEndProgress).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps the latest end progress outside the date and format filters', async () => {
|
||||
const { db } = makeListDb({ rows: [], count: [{ total: 0 }], summary: [], latest: [{ endProgress: 100 }] });
|
||||
const repo = new ReadingSessionRepository(db as never);
|
||||
|
||||
const result = await repo.listByBook(1, 2, 1, 25, 'startedAt', 'desc', '2026-01-01', '2026-01-31', 'EPUB');
|
||||
|
||||
expect(result.stats.progressSummary).toEqual([]);
|
||||
expect(result.stats.latestEndProgress).toBe(100);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ReadingSessionRepository - findLatestEndProgress', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
function makeLatestHarness(rows: Array<{ endProgress: number | null }>) {
|
||||
const limit = vi.fn().mockResolvedValue(rows);
|
||||
const orderBy = vi.fn().mockReturnValue({ limit });
|
||||
const where = vi.fn().mockReturnValue({ orderBy });
|
||||
const from = vi.fn().mockReturnValue({ where });
|
||||
const select = vi.fn().mockReturnValue({ from });
|
||||
const repo = new ReadingSessionRepository({ select } as never);
|
||||
return { repo, orderBy, limit, where };
|
||||
}
|
||||
|
||||
it('returns the end progress of the most recent session that recorded one', async () => {
|
||||
const { repo, orderBy, limit } = makeLatestHarness([{ endProgress: 42.5 }]);
|
||||
|
||||
await expect(repo.findLatestEndProgress(1, 2)).resolves.toBe(42.5);
|
||||
expect(orderBy).toHaveBeenCalledTimes(1);
|
||||
expect(orderBy.mock.calls[0]).toHaveLength(2);
|
||||
expect(limit).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('returns null when the book has no session with an end progress', async () => {
|
||||
const { repo } = makeLatestHarness([]);
|
||||
|
||||
await expect(repo.findLatestEndProgress(1, 2)).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when the most recent row carries a null end progress', async () => {
|
||||
const { repo } = makeLatestHarness([{ endProgress: null }]);
|
||||
|
||||
await expect(repo.findLatestEndProgress(1, 2)).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ReadingSessionRepository - deleteSessionByBook', () => {
|
||||
|
||||
@@ -167,6 +167,19 @@ export class ReadingSessionRepository {
|
||||
return row?.endProgress ?? null;
|
||||
}
|
||||
|
||||
// Deliberately unscoped by the list filters: this feeds the book's progress ring, which
|
||||
// must not move when the reading log is filtered by date range or format.
|
||||
async findLatestEndProgress(userId: number, bookId: number): Promise<number | null> {
|
||||
const [row] = await this.db
|
||||
.select({ endProgress: readingSessions.endProgress })
|
||||
.from(readingSessions)
|
||||
.where(and(eq(readingSessions.userId, userId), eq(readingSessions.bookId, bookId), isNotNull(readingSessions.endProgress)))
|
||||
.orderBy(desc(readingSessions.startedAt), desc(readingSessions.id))
|
||||
.limit(1);
|
||||
|
||||
return row?.endProgress ?? null;
|
||||
}
|
||||
|
||||
async listByBook(
|
||||
userId: number,
|
||||
bookId: number,
|
||||
@@ -203,7 +216,7 @@ export class ReadingSessionRepository {
|
||||
const orderExpr = sortDir === 'asc' ? asc(orderCol) : desc(orderCol);
|
||||
const offset = (page - 1) * pageSize;
|
||||
|
||||
const [rows, countRows, statsRows, summaryRows, sourceRows] = await Promise.all([
|
||||
const [rows, countRows, statsRows, summaryRows, sourceRows, latestEndProgress] = await Promise.all([
|
||||
this.db
|
||||
.select({
|
||||
id: readingSessions.id,
|
||||
@@ -261,6 +274,8 @@ export class ReadingSessionRepository {
|
||||
.leftJoin(bookFiles, eq(bookFiles.id, readingSessions.bookFileId))
|
||||
.where(whereClause)
|
||||
.groupBy(readingSessions.source),
|
||||
|
||||
this.findLatestEndProgress(userId, bookId),
|
||||
]);
|
||||
|
||||
const total = countRows[0]?.total ?? 0;
|
||||
@@ -316,6 +331,7 @@ export class ReadingSessionRepository {
|
||||
paceProgressDelta: statsRow?.paceProgressDelta ?? 0,
|
||||
paceDurationSeconds: statsRow?.paceDurationSeconds ?? 0,
|
||||
progressSummary,
|
||||
latestEndProgress,
|
||||
bySource,
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user