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": {