feat(books): allow progress updates for manual want_to_read books

Allow the system to automatically update the reading status of a book from 'want_to_read' to 'reading' or 'read' when reading progress is recorded, even if the status was originally set manually. Ensure that manually cataloged books are updated when the user begins reading them, while still protecting other manually set statuses from being overridden by auto-updates.

Closes #566
This commit is contained in:
neon
2026-07-07 04:56:24 +00:00
parent bf4102e8df
commit 55b99858fc
3 changed files with 31 additions and 3 deletions
+1 -1
View File
@@ -17,7 +17,7 @@ export const userBookStatus = pgTable(
.references(() => books.id, { onDelete: 'cascade' }),
// 'unread' | 'want_to_read' | 'reading' | 'on_hold' | 'rereading' | 'read' | 'skimmed' | 'abandoned'
status: varchar('status', { length: 20 }).$type<ReadStatus>().notNull().default('unread'),
// 'auto' (derived from progress) | 'manual' (user-set; never auto-overridden)
// 'auto' (derived from progress) | 'manual' (user-set/imported; protected from progress updates except want_to_read)
source: varchar('source', { length: 10 }).$type<ReadStatusSource>().notNull().default('auto'),
startedAt: timestamp('started_at', { withTimezone: true }),
finishedAt: timestamp('finished_at', { withTimezone: true }),
@@ -374,6 +374,34 @@ describe('autoUpdate normalization and guard behavior', () => {
expect(mockRepo.upsertState).not.toHaveBeenCalled();
});
it.each([
{ percentage: 50, expectedStatus: 'reading' },
{ percentage: 98, expectedStatus: 'read' },
] as const)('updates manual want_to_read to $expectedStatus when progress crosses thresholds', async ({ percentage, expectedStatus }) => {
const existing = makeRow({ status: 'want_to_read', source: 'manual' });
mockRepo.findOne.mockResolvedValue(existing);
await service.autoUpdate(1, 10, percentage);
expect(mockRepo.upsert).toHaveBeenCalledOnce();
expect(mockRepo.upsert).toHaveBeenCalledWith(1, 10, expectedStatus, 'auto', expect.any(Date), existing);
expect(mockRepo.upsertState).not.toHaveBeenCalled();
expect(mockAchievementEvents.emit).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({ userId: 1, bookId: 10, previousStatus: 'want_to_read', newStatus: expectedStatus }),
);
});
it('does not change manual want_to_read to unread below the reading threshold', async () => {
mockRepo.findOne.mockResolvedValue(makeRow({ status: 'want_to_read', source: 'manual' }));
await service.autoUpdate(1, 10, 0.1);
expect(mockRepo.upsert).not.toHaveBeenCalled();
expect(mockRepo.upsertState).not.toHaveBeenCalled();
expect(mockAchievementEvents.emit).not.toHaveBeenCalled();
});
it('skips updates when derived status is unchanged', async () => {
mockRepo.findOne.mockResolvedValue(makeRow({ status: 'reading', source: 'auto' }));
@@ -124,13 +124,13 @@ export class UserBookStatusService {
): Promise<void> {
const existing = await this.repo.findOne(userId, bookId);
if (existing?.source === 'manual') return;
const normalizedPercentage = this.normalizePercentage(percentage);
const { readThreshold, finishThreshold: normalizedFinishThreshold } = this.normalizeThresholds(readingThreshold, finishThreshold);
const derived: ReadStatus =
normalizedPercentage >= normalizedFinishThreshold ? 'read' : normalizedPercentage >= readThreshold ? 'reading' : 'unread';
if (existing?.source === 'manual' && (existing.status !== 'want_to_read' || derived === 'unread')) return;
if (!existing && derived === 'unread') return;
if (existing?.status === derived) return;