mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
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:
@@ -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>
|
||||
|
||||
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user