feat(library): support fractional finished thresholds

Allow 0.05% adjustments while preserving existing integer settings and the 98% default. Keep finish detection stable when progress is stored as PostgreSQL real.
This commit is contained in:
neon
2026-09-26 18:22:02 +00:00
parent 244924a3aa
commit 8dfb093301
22 changed files with 22645 additions and 20 deletions
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { formatNumber, formatPercent } from '@/i18n/formatters'
const { t } = useI18n()
@@ -19,9 +20,13 @@ function onReadingThresholdInput(e: Event) {
}
function onFinishPercentInput(e: Event) {
const val = parseInt((e.target as HTMLInputElement).value, 10)
const val = parseFloat((e.target as HTMLInputElement).value)
if (!isNaN(val)) emit('update:markAsFinishedPercentComplete', val)
}
function formatFinishedThreshold(value: number): string {
return formatNumber(value / 100, { style: 'percent', maximumFractionDigits: 2 })
}
</script>
<template>
@@ -35,7 +40,7 @@ function onFinishPercentInput(e: Event) {
<output for="reading-threshold" class="text-sm font-medium text-foreground tabular-nums">{{ readingThreshold }}%</output>
</div>
<p id="reading-threshold-help" class="text-xs text-muted-foreground mb-3">
A book is marked as "reading" once this percentage of progress is reached.
{{ t('library.creator.reading.readingStart.hint') }}
</p>
<input
id="reading-threshold"
@@ -60,10 +65,12 @@ function onFinishPercentInput(e: Event) {
<label for="finished-threshold" class="text-[11px] font-semibold uppercase tracking-widest text-foreground">
{{ t('library.creator.reading.markAsFinished.title') }}
</label>
<output for="finished-threshold" class="text-sm font-medium text-foreground tabular-nums"> {{ markAsFinishedPercentComplete }}% </output>
<output for="finished-threshold" class="text-sm font-medium text-foreground tabular-nums">
{{ formatFinishedThreshold(markAsFinishedPercentComplete) }}
</output>
</div>
<p id="finished-threshold-help" class="text-xs text-muted-foreground mb-3">
A book is automatically marked as "read" when this percentage of progress is reached.
{{ t('library.creator.reading.markAsFinished.hint') }}
</p>
<input
id="finished-threshold"
@@ -71,14 +78,14 @@ function onFinishPercentInput(e: Event) {
:value="markAsFinishedPercentComplete"
min="90"
max="100"
step="1"
step="0.05"
class="w-full accent-primary"
aria-describedby="finished-threshold-help"
@input="onFinishPercentInput"
/>
<div class="flex justify-between text-xs text-muted-foreground mt-1">
<span>90%</span>
<span>100%</span>
<span>{{ formatPercent(0.9) }}</span>
<span>{{ formatPercent(1) }}</span>
</div>
</div>
</div>
@@ -0,0 +1,19 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import LibraryCreatorReading from '../LibraryCreatorReading.vue'
describe('LibraryCreatorReading', () => {
it('shows and emits a finished threshold at the requested 0.05% precision', async () => {
const wrapper = mount(LibraryCreatorReading, {
props: { readingThreshold: 0.25, markAsFinishedPercentComplete: 99.95 },
})
const slider = wrapper.get<HTMLInputElement>('#finished-threshold')
expect(slider.attributes('step')).toBe('0.05')
expect(wrapper.get('output[for="finished-threshold"]').text()).toBe('99.95%')
await slider.setValue('98.05')
expect(wrapper.emitted('update:markAsFinishedPercentComplete')).toContainEqual([98.05])
})
})
@@ -125,6 +125,23 @@ describe('useLibraryCreator', () => {
expect(creator.form.fileWriteAudioMaxFileSizeMb).toBe(500)
})
it('accepts and sends a fractional finished threshold', async () => {
const { useLibraryCreator } = await import('../useLibraryCreator')
const creator = useLibraryCreator()
apiMock.mockResolvedValue(jsonResponse(makeLibrary({ markAsFinishedPercentComplete: 99.95 }), 201))
creator.form.name = 'Main Library'
creator.form.icon = 'BookOpen'
creator.form.folders = ['/books']
creator.form.markAsFinishedPercentComplete = 99.95
expect(creator.validationErrors.value.reading).toBeUndefined()
await creator.save()
expect(JSON.parse(apiMock.mock.calls[0]?.[1]?.body as string).markAsFinishedPercentComplete).toBe(99.95)
creator.form.markAsFinishedPercentComplete = 100.05
expect(creator.validationErrors.value.reading).toBe('Finished progress must be between 90% and 100%.')
})
it('hydrates file rename and audio write settings when editing a library', async () => {
const { useLibraryCreator } = await import('../useLibraryCreator')
const creator = useLibraryCreator()
@@ -7,6 +7,7 @@ import {
withReadAlongFormatPriority,
} from '@bookorbit/types'
import type { AddedAtSource, CoverAspectRatio, Library, LibraryType, OrganizationMode, PrescanResult } from '@bookorbit/types'
import { i18n } from '@/i18n'
import { coveringFolderPath, normalizeFolderPath } from './folder-paths'
export { DEFAULT_FORMAT_PRIORITY }
@@ -86,11 +87,11 @@ export function useLibraryCreator() {
if (form.readingThreshold < 0.05 || form.readingThreshold > 5) {
errors.reading = 'Reading start must be between 0.05% and 5%.'
} else if (
!Number.isInteger(form.markAsFinishedPercentComplete) ||
!Number.isFinite(form.markAsFinishedPercentComplete) ||
form.markAsFinishedPercentComplete < 90 ||
form.markAsFinishedPercentComplete > 100
) {
errors.reading = 'Finished progress must be a whole number between 90% and 100%.'
errors.reading = i18n.global.t('library.creator.reading.markAsFinished.invalidThreshold')
}
const fileSizes = [
form.fileWriteEpubMaxFileSizeMb,
@@ -546,6 +546,20 @@ describe('LibrariesSettings ledger', () => {
expect(finishedLabel?.element.nextElementSibling?.textContent?.trim()).toBe('98%')
})
it.each([
{ threshold: 90, expected: '90%' },
{ threshold: 98.05, expected: '98.05%' },
{ threshold: 99.95, expected: '99.95%' },
{ threshold: 100, expected: '100%' },
])('shows a $threshold% finished threshold as $expected', async ({ threshold, expected }) => {
librariesRef.value = [makeLibrary({ id: 4, readingThreshold: 0.25, markAsFinishedPercentComplete: threshold })]
const wrapper = await mountLoaded()
await expandFirst(wrapper)
const panel = wrapper.get('[id="library-detail-4"]')
const finishedLabel = panel.findAll('dt').find((label) => label.text() === 'Counts as finished')
expect(finishedLabel?.element.nextElementSibling?.textContent?.trim()).toBe(expected)
})
it('renders the scan history with trigger and deltas', async () => {
historyRef.value = [
{ id: 9, ...makeScan({ triggeredBy: 'watcher', addedCount: 3, updatedCount: 1 }) },
@@ -6,7 +6,7 @@ import type { Library, LibraryScanHistoryEntry } from '@bookorbit/types'
import { formatKeyName } from '@/features/book/lib/book-formats'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import { formatDate, formatList, formatNumber, formatPercent } from '@/i18n/formatters'
import { formatDate, formatList, formatNumber } from '@/i18n/formatters'
import { METADATA_LABELS } from '@/features/library/composables/useLibraryCreator'
import LibraryScanHistory from './LibraryScanHistory.vue'
@@ -42,6 +42,9 @@ const formatsLabel = computed(() =>
)
const accessLabel = computed(() => (props.accessCount === null ? '' : t('settings.admin.libraries.detail.peopleCount', { count: props.accessCount })))
const readingThresholdLabel = computed(() => formatNumber(props.library.readingThreshold / 100, { style: 'percent', maximumFractionDigits: 2 }))
const finishedThresholdLabel = computed(() =>
formatNumber(props.library.markAsFinishedPercentComplete / 100, { style: 'percent', maximumFractionDigits: 2 }),
)
function requestEdit() {
emit('edit', props.library)
@@ -121,7 +124,7 @@ function requestEdit() {
<div class="flex items-center gap-3 border-t border-border py-1.5">
<dt class="shrink-0 text-[12.5px] text-muted-foreground">{{ t('settings.admin.libraries.detail.countsAsFinished') }}</dt>
<dd class="ms-auto text-[12.5px] font-medium tabular-nums text-foreground">
{{ formatPercent(library.markAsFinishedPercentComplete / 100) }}
{{ finishedThresholdLabel }}
</dd>
</div>
</dl>
+2 -1
View File
@@ -8650,7 +8650,8 @@
},
"markAsFinished": {
"title": "Mark as finished",
"hint": "A book is automatically marked as \"read\" when this percentage of progress is reached."
"hint": "A book is automatically marked as \"read\" when this percentage of progress is reached.",
"invalidThreshold": "Finished progress must be between 90% and 100%."
}
},
"schedule": {
@@ -0,0 +1,7 @@
const REAL_PERCENTAGE_TOLERANCE = 0.00001;
export function hasReachedProgressThreshold(progress: number, threshold: number): boolean {
if (progress >= threshold) return true;
// PostgreSQL real can store 99.95 as 99.949996; whole-number thresholds retain exact comparisons.
return !Number.isInteger(threshold) && progress + REAL_PERCENTAGE_TOLERANCE >= threshold;
}
@@ -0,0 +1,2 @@
ALTER TABLE "libraries" ALTER COLUMN "mark_as_finished_percent_complete" SET DATA TYPE double precision;--> statement-breakpoint
ALTER TABLE "libraries" ALTER COLUMN "mark_as_finished_percent_complete" SET DEFAULT 98;
File diff suppressed because it is too large Load Diff
@@ -694,6 +694,13 @@
"when": 1790398181669,
"tag": "0098_add_subtitle_search_index",
"breakpoints": true
},
{
"idx": 99,
"version": "7",
"when": 1790446233976,
"tag": "0099_fractional_finished_threshold",
"breakpoints": true
}
]
}
+1 -1
View File
@@ -49,7 +49,7 @@ export const libraries = pgTable(
// Reading progress thresholds
readingThreshold: doublePrecision('reading_threshold').notNull().default(0.25),
markAsFinishedPercentComplete: integer('mark_as_finished_percent_complete').notNull().default(98),
markAsFinishedPercentComplete: doublePrecision('mark_as_finished_percent_complete').notNull().default(98),
// File write-back settings
fileWriteEnabled: boolean('file_write_enabled').notNull().default(false),
@@ -1548,6 +1548,9 @@ describe('BookRepository', () => {
{ percentage: 100, threshold: 98, expected: 'Finished' },
{ percentage: 95, threshold: 95, expected: 'Finished' },
{ percentage: 99, threshold: 100, expected: 'Reading' },
{ percentage: 99.94, threshold: 99.95, expected: 'Reading' },
{ percentage: Math.fround(99.95), threshold: 99.95, expected: 'Finished' },
{ percentage: 99.999995, threshold: 100, expected: 'Reading' },
])('reports $expected at $percentage% with a $threshold% threshold', async ({ percentage, threshold, expected }) => {
await expect(syncStatusFor(percentage, threshold)).resolves.toBe(expected);
});
+2 -1
View File
@@ -22,6 +22,7 @@ import { advanceIsoTimestamp } from '../../common/utils/iso-timestamp.utils';
import { parsePgTimestamptz } from '../../common/utils/pg-timestamp.utils';
import { scanStateInvalidationPaths } from '../../common/utils/scan-state-paths.utils';
import { seriesIndexSortKeySql } from '../../common/utils/series-index-sql.utils';
import { hasReachedProgressThreshold } from '../../common/utils/progress-threshold.utils';
import { SeriesIdentityService } from '../../common/services/series-identity.service';
import { SeriesMembershipService } from '../../common/services/series-membership.service';
import { BookQueryBuilder } from './book-query-builder.service';
@@ -2806,7 +2807,7 @@ export class BookRepository {
*/
private deriveKoboStatus(percentage: number, markAsFinishedPercentComplete: number): string {
const threshold = Number.isFinite(markAsFinishedPercentComplete) ? Math.min(100, Math.max(1, markAsFinishedPercentComplete)) : 100;
if (percentage >= threshold) return 'Finished';
if (hasReachedProgressThreshold(percentage, threshold)) return 'Finished';
return percentage > 0 ? 'Reading' : 'ReadyToRead';
}
@@ -123,7 +123,7 @@ export class CreateLibraryDto {
readingThreshold?: number;
@IsOptional()
@IsInt()
@IsNumber()
@Min(LIBRARY_MARK_AS_FINISHED_MIN)
@Max(LIBRARY_MARK_AS_FINISHED_MAX)
markAsFinishedPercentComplete?: number;
@@ -120,7 +120,7 @@ export class UpdateLibraryDto {
readingThreshold?: number;
@IsOptional()
@IsInt()
@IsNumber()
@Min(LIBRARY_MARK_AS_FINISHED_MIN)
@Max(LIBRARY_MARK_AS_FINISHED_MAX)
markAsFinishedPercentComplete?: number;
@@ -123,7 +123,9 @@ describe('Library DTO validation', () => {
const base = { name: 'x', icon: 'BookOpen', folders: ['/a'] };
expect(await hasErrors(plainToInstance(CreateLibraryDto, { ...base, markAsFinishedPercentComplete: 89 }))).toBe(true);
expect(await hasErrors(plainToInstance(CreateLibraryDto, { ...base, markAsFinishedPercentComplete: 101 }))).toBe(true);
expect(await hasErrors(plainToInstance(CreateLibraryDto, { ...base, markAsFinishedPercentComplete: 90.5 }))).toBe(true);
expect(await hasErrors(plainToInstance(CreateLibraryDto, { ...base, markAsFinishedPercentComplete: 90.05 }))).toBe(false);
expect(await hasErrors(plainToInstance(CreateLibraryDto, { ...base, markAsFinishedPercentComplete: 99.95 }))).toBe(false);
expect(await hasErrors(plainToInstance(CreateLibraryDto, { ...base, markAsFinishedPercentComplete: 100.05 }))).toBe(true);
expect(await hasErrors(plainToInstance(CreateLibraryDto, { ...base, markAsFinishedPercentComplete: 90 }))).toBe(false);
expect(await hasErrors(plainToInstance(CreateLibraryDto, { ...base, markAsFinishedPercentComplete: 100 }))).toBe(false);
});
@@ -133,6 +135,7 @@ describe('Library DTO validation', () => {
expect(await hasErrors(plainToInstance(UpdateLibraryDto, { readingThreshold: 2.5 }))).toBe(false);
expect(await hasErrors(plainToInstance(UpdateLibraryDto, { markAsFinishedPercentComplete: 89 }))).toBe(true);
expect(await hasErrors(plainToInstance(UpdateLibraryDto, { markAsFinishedPercentComplete: 95 }))).toBe(false);
expect(await hasErrors(plainToInstance(UpdateLibraryDto, { markAsFinishedPercentComplete: 99.95 }))).toBe(false);
});
describe('file write settings validation', () => {
@@ -261,6 +261,25 @@ describe('ReadingAttemptService', () => {
expect(fake.rows[0]).toMatchObject({ outcome: 'completed', endedOn: '2026-07-12' });
});
it.each([
{ progress: 99.94, expectedStatus: 'reading', expectedOutcome: null },
{ progress: Math.fround(99.95), expectedStatus: 'read', expectedOutcome: 'completed' },
])('uses a fractional finish threshold at $progress% progress', async ({ progress, expectedStatus, expectedOutcome }) => {
const result = await service.recordActivity({
userId: 1,
bookId: 10,
occurredOn: '2026-07-12',
origin: 'bookorbit',
progress,
finishThreshold: 99.95,
strongRereadEvidence: false,
meaningfulActivity: true,
});
expect(result?.status).toBe(expectedStatus);
expect(fake.rows[0]?.outcome).toBe(expectedOutcome);
});
it('keeps repeated manual read operations idempotent', async () => {
const first = await service.applyManualStatus(1, 10, 'read', '2026-01-01', '2026-01-10', '2026-07-12');
const second = await service.applyManualStatus(1, 10, 'read', undefined, undefined, '2026-07-12');
@@ -11,6 +11,7 @@ import type {
import { ReadingAttemptRepository } from './reading-attempt.repository';
import { READING_DATE_ERROR_CODES } from './user-book-status.constants';
import { hasReachedProgressThreshold } from '../../common/utils/progress-threshold.utils';
function dateToUtcDate(value: string | null): Date | null {
return value ? new Date(`${value}T00:00:00.000Z`) : null;
@@ -259,7 +260,7 @@ export class ReadingAttemptService {
let active: Awaited<ReturnType<ReadingAttemptRepository['findActive']>> | null = await this.repo.findActive(tx, input.userId, input.bookId);
let latest = active ?? (await this.repo.findLatest(tx, input.userId, input.bookId));
const hasCompleted = await this.repo.hasCompleted(tx, input.userId, input.bookId);
const isFinished = input.progress >= input.finishThreshold;
const isFinished = hasReachedProgressThreshold(input.progress, input.finishThreshold);
if (!active && hasCompleted && !input.strongRereadEvidence && !input.meaningfulActivity) return null;
if (!active && isFinished && !hasCompleted) {
@@ -384,6 +384,9 @@ describe('autoUpdate with custom thresholds', () => {
{ percentage: 0.5, readingThreshold: 1, finishThreshold: 90, existingStatus: 'reading', expectedStatus: 'unread' },
{ percentage: 1, readingThreshold: 1, finishThreshold: 90, existingStatus: 'unread', expectedStatus: 'reading' },
{ percentage: 90, readingThreshold: 1, finishThreshold: 90, existingStatus: 'reading', expectedStatus: 'read' },
{ percentage: 99.94, readingThreshold: 1, finishThreshold: 99.95, existingStatus: 'unread', expectedStatus: 'reading' },
{ percentage: Math.fround(99.95), readingThreshold: 1, finishThreshold: 99.95, existingStatus: 'reading', expectedStatus: 'read' },
{ percentage: 99.999995, readingThreshold: 1, finishThreshold: 100, existingStatus: 'unread', expectedStatus: 'reading' },
])(
'derives expected status for percentage=$percentage',
async ({ percentage, readingThreshold, finishThreshold, existingStatus, expectedStatus }) => {
@@ -7,6 +7,7 @@ import { AchievementEventsService, ACHIEVEMENT_EVENT_BOOK_STATUS_CHANGED } from
import { KoboStatusProjectionRepository } from './kobo-status-projection.repository';
import { ReadingAttemptService } from './reading-attempt.service';
import { sanitizeLogValue } from '../../common/utils/log-sanitize.utils';
import { hasReachedProgressThreshold } from '../../common/utils/progress-threshold.utils';
import { toDateKeyInTimeZone } from '../../common/utils/timezone.utils';
const DEFAULT_FINISH_THRESHOLD = 98;
@@ -250,8 +251,11 @@ export class UserBookStatusService {
const normalizedPercentage = this.normalizePercentage(percentage);
const { readThreshold, finishThreshold: normalizedFinishThreshold } = this.normalizeThresholds(readingThreshold, finishThreshold);
const derived: ReadStatus =
normalizedPercentage >= normalizedFinishThreshold ? 'read' : normalizedPercentage >= readThreshold ? 'reading' : 'unread';
const derived: ReadStatus = hasReachedProgressThreshold(normalizedPercentage, normalizedFinishThreshold)
? 'read'
: normalizedPercentage >= readThreshold
? 'reading'
: 'unread';
if (this.attempts) {
const timeZone = activity.timeZone ?? (await this.repo.findUserTimeZone(userId));
@@ -486,6 +486,31 @@ describe('Library admin workflows (e2e)', { timeout: SCENARIO_TIMEOUT_MS }, () =
});
describe('create library and manage access', () => {
it('persists fractional finished thresholds through create and update', async () => {
const { body: createdLibrary } = await createLibraryViaApi(ctx, manager.accessToken, {
markAsFinishedPercentComplete: 99.95,
});
expect(createdLibrary.markAsFinishedPercentComplete).toBe(99.95);
await grantLibraryAccess(ctx, manager.userId, createdLibrary.id, 'owner');
const update = await ctx.app.inject({
method: 'PATCH',
url: `/api/v1/libraries/${createdLibrary.id}`,
headers: authHeader(manager.accessToken),
payload: { markAsFinishedPercentComplete: 98.05 },
});
expect(update.statusCode, update.body).toBe(200);
expect(update.json().markAsFinishedPercentComplete).toBe(98.05);
const read = await ctx.app.inject({
method: 'GET',
url: `/api/v1/libraries/${createdLibrary.id}`,
headers: authHeader(manager.accessToken),
});
expect(read.statusCode, read.body).toBe(200);
expect(read.json().markAsFinishedPercentComplete).toBe(98.05);
});
it('creates a library with the requested settings, requires explicit access rows, and reflects grant/update/revoke changes', async () => {
const { body: createdLibrary } = await createLibraryViaApi(ctx, manager.accessToken, {
name: `library-contract-${randomUUID()}`,