mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
fix(books): let manual reading dates drive the reading lifecycle
Editing Date Started or Date Finished sent a date-only patch that the attempt layer had nothing to attach it to. On an unread book no attempt was created, so the request returned 200 with null dates and the field cleared itself. On a book being read the date landed on the open attempt as an end date with no outcome, violating reading_attempts_closed_has_outcome_chk and surfacing as a 500. Treat a date-only patch as a lifecycle statement: a start date opens an attempt and projects reading, a finish date closes one as completed and projects read, and endedOn is never written without its outcome. Clearing a finish date keeps the completion, because an outcome with no completion date is legal and is already what re-read seeding writes. Setting a finish date on an abandoned book now completes it. Reject a status that contradicts its dates with a stable error code the client can translate, and derive statusChanged from the resulting status so a date-driven transition still reaches achievements and the Kobo projection. Keep unread and want_to_read free of dates they cannot own. The attempt keeps its own, so a book marked unread no longer projects a start and finish date once a historical attempt is logged, and marking a finished book unread no longer strands its finish date on the projection. Stop the Hardcover import sending dates a status cannot carry. A want-to-read row that still holds a start date would otherwise fail its row and skip the status import entirely. Closes #1073
This commit is contained in:
@@ -348,7 +348,7 @@ describe('DetailsTab - present state', () => {
|
||||
await finishedInput.setValue('2026-04-05')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('Date Finished must be on or after Date Started.')
|
||||
expect(wrapper.text()).toContain('Date Finished must be on or after Date Started (Apr 10, 2026).')
|
||||
const saveButton = wrapper.findAll('button').find((button) => button.text() === 'Save')
|
||||
expect(saveButton?.exists()).toBe(true)
|
||||
await saveButton!.trigger('click')
|
||||
|
||||
@@ -532,10 +532,19 @@ function validateReadingDates(values: { startedAt: string; finishedAt: string })
|
||||
const { startedAt, finishedAt } = values
|
||||
if (startedAt && startedAt > todayDateInput.value) return t('book.detail.details.dateStartedFutureError')
|
||||
if (finishedAt && finishedAt > todayDateInput.value) return t('book.detail.details.dateFinishedFutureError')
|
||||
if (startedAt && finishedAt && finishedAt < startedAt) return t('book.detail.details.dateFinishedBeforeStartedError')
|
||||
if (startedAt && finishedAt && finishedAt < startedAt)
|
||||
return t('book.detail.details.dateFinishedBeforeStartedErrorWithDate', { date: formatDisplayDate(startedAt) })
|
||||
return null
|
||||
}
|
||||
|
||||
function readingDateSaveError(error: unknown): string {
|
||||
const errorCode = typeof error === 'object' && error !== null && 'errorCode' in error ? (error as { errorCode?: unknown }).errorCode : null
|
||||
if (errorCode === 'READING_DATE_STARTED_IN_FUTURE') return t('book.detail.details.dateStartedFutureError')
|
||||
if (errorCode === 'READING_DATE_FINISHED_IN_FUTURE') return t('book.detail.details.dateFinishedFutureError')
|
||||
if (errorCode === 'READING_DATES_INVALID_ORDER') return t('book.detail.details.dateFinishedBeforeStartedError')
|
||||
return t('book.detail.details.saveReadingDatesError')
|
||||
}
|
||||
|
||||
const isEditingAnyReadingDate = computed(() => activeReadingDateField.value !== null)
|
||||
const isEditingAnyDate = computed(() => editingAddedDate.value || isEditingAnyReadingDate.value)
|
||||
|
||||
@@ -624,8 +633,8 @@ async function saveReadingDateField(field: 'startedAt' | 'finishedAt') {
|
||||
const updatedReadStatus = await updateStatus(props.book.id, patch)
|
||||
applyReadStatusUpdate(updatedReadStatus)
|
||||
activeReadingDateField.value = null
|
||||
} catch {
|
||||
readingDatesError.value = t('book.detail.details.saveReadingDatesError')
|
||||
} catch (error) {
|
||||
readingDatesError.value = readingDateSaveError(error)
|
||||
} finally {
|
||||
savingReadingDates.value = false
|
||||
}
|
||||
|
||||
@@ -109,10 +109,18 @@ function validateDates(values: { startedAt: string; finishedAt: string }): strin
|
||||
if (values.startedAt && values.startedAt > todayDateInput.value) return t('book.detail.readingLog.hero.dateErrors.startFuture')
|
||||
if (values.finishedAt && values.finishedAt > todayDateInput.value) return t('book.detail.readingLog.hero.dateErrors.finishFuture')
|
||||
if (values.startedAt && values.finishedAt && values.finishedAt < values.startedAt)
|
||||
return t('book.detail.readingLog.hero.dateErrors.finishBeforeStart')
|
||||
return t('book.detail.readingLog.hero.dateErrors.finishBeforeStartWithDate', { date: formatDisplayDate(values.startedAt) })
|
||||
return null
|
||||
}
|
||||
|
||||
function dateSaveError(error: unknown): string {
|
||||
const errorCode = typeof error === 'object' && error !== null && 'errorCode' in error ? (error as { errorCode?: unknown }).errorCode : null
|
||||
if (errorCode === 'READING_DATE_STARTED_IN_FUTURE') return t('book.detail.readingLog.hero.dateErrors.startFuture')
|
||||
if (errorCode === 'READING_DATE_FINISHED_IN_FUTURE') return t('book.detail.readingLog.hero.dateErrors.finishFuture')
|
||||
if (errorCode === 'READING_DATES_INVALID_ORDER') return t('book.detail.readingLog.hero.dateErrors.finishBeforeStart')
|
||||
return t('book.detail.readingLog.hero.dateErrors.saveFailed')
|
||||
}
|
||||
|
||||
function applyReadStatusUpdate(updated: UserBookStatus) {
|
||||
localReadStatus.value = updated.status
|
||||
const normalized = normalizeDates(updated)
|
||||
@@ -163,8 +171,8 @@ async function saveDateField(field: 'startedAt' | 'finishedAt') {
|
||||
const updated = await updateStatus(props.book.id, patch)
|
||||
applyReadStatusUpdate(updated)
|
||||
activeDateField.value = null
|
||||
} catch {
|
||||
datesError.value = t('book.detail.readingLog.hero.dateErrors.saveFailed')
|
||||
} catch (error) {
|
||||
datesError.value = dateSaveError(error)
|
||||
} finally {
|
||||
savingDates.value = false
|
||||
}
|
||||
@@ -363,7 +371,7 @@ function handleAddSession() {
|
||||
|
||||
<div class="mt-1.5 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground">
|
||||
<span>
|
||||
Started
|
||||
{{ t('book.detail.readingLog.hero.dateStarted') }}
|
||||
<input
|
||||
v-if="activeDateField === 'startedAt'"
|
||||
v-model="draftDates.startedAt"
|
||||
@@ -380,8 +388,8 @@ function handleAddSession() {
|
||||
{{ formatDisplayDate(savedDates.startedAt) }}
|
||||
</button>
|
||||
</span>
|
||||
<span v-if="savedDates.finishedAt || activeDateField === 'finishedAt' || localReadStatus === 'read' || localReadStatus === 'abandoned'">
|
||||
Finished
|
||||
<span>
|
||||
{{ t('book.detail.readingLog.hero.dateFinished') }}
|
||||
<input
|
||||
v-if="activeDateField === 'finishedAt'"
|
||||
v-model="draftDates.finishedAt"
|
||||
@@ -395,7 +403,7 @@ function handleAddSession() {
|
||||
@keydown.esc.prevent="handleFinishedCancel"
|
||||
/>
|
||||
<button v-else class="ml-1 font-medium text-foreground transition-colors hover:text-primary" @click="handleFinishedClick">
|
||||
{{ savedDates.finishedAt ? formatDisplayDate(savedDates.finishedAt) : 'Set date' }}
|
||||
{{ formatDisplayDate(savedDates.finishedAt) }}
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -189,6 +189,28 @@ describe('ReadingLogHero', () => {
|
||||
expect(wrapper.emitted('saved')?.[0]).toEqual([updated])
|
||||
})
|
||||
|
||||
it('allows a finish date to complete an unread book', async () => {
|
||||
const updated = makeReadStatus({ status: 'read', startedAt: null, finishedAt: '2026-04-20' })
|
||||
mocks.updateStatus.mockResolvedValue(updated)
|
||||
const wrapper = mountHero(makeBook({ readStatus: makeReadStatus({ status: 'unread', startedAt: null, finishedAt: null }) }))
|
||||
await flushPromises()
|
||||
|
||||
const finishButton = wrapper
|
||||
.findAll('button')
|
||||
.filter((button) => button.text() === 'Not set')
|
||||
.at(-1)
|
||||
expect(finishButton).toBeDefined()
|
||||
await finishButton!.trigger('click')
|
||||
await wrapper.find('input[type="date"]').setValue('2026-04-20')
|
||||
await wrapper.find('input[type="date"]').trigger('blur')
|
||||
await flushPromises()
|
||||
|
||||
expect(mocks.updateStatus).toHaveBeenCalledWith(10, { finishedAt: '2026-04-20' })
|
||||
expect(wrapper.text()).toContain('Read')
|
||||
expect(wrapper.text()).toContain('Apr 20, 2026')
|
||||
expect(wrapper.emitted('saved')?.[0]).toEqual([updated])
|
||||
})
|
||||
|
||||
it('validates lifecycle dates before saving', async () => {
|
||||
const wrapper = mountHero()
|
||||
await flushPromises()
|
||||
@@ -216,7 +238,7 @@ describe('ReadingLogHero', () => {
|
||||
await invalidWrapper.find('input[type="date"]').trigger('blur')
|
||||
await flushPromises()
|
||||
|
||||
expect(invalidWrapper.text()).toContain('Finish date must be on or after the start date.')
|
||||
expect(invalidWrapper.text()).toContain('Finish date must be on or after the start date (Apr 1, 2026).')
|
||||
|
||||
mocks.updateStatus.mockRejectedValue(new Error('save failed'))
|
||||
const failedWrapper = mountHero()
|
||||
@@ -231,6 +253,20 @@ describe('ReadingLogHero', () => {
|
||||
expect(failedWrapper.text()).toContain('Failed to save reading dates.')
|
||||
})
|
||||
|
||||
it('maps stable server date errors to localized copy', async () => {
|
||||
mocks.updateStatus.mockRejectedValue(Object.assign(new Error('rejected'), { errorCode: 'READING_DATE_STARTED_IN_FUTURE' }))
|
||||
const wrapper = mountHero()
|
||||
await flushPromises()
|
||||
|
||||
const startButton = wrapper.findAll('button').find((button) => button.text() === 'Apr 1, 2026')
|
||||
await startButton!.trigger('click')
|
||||
await wrapper.find('input[type="date"]').setValue('2026-04-02')
|
||||
await wrapper.find('input[type="date"]').trigger('blur')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('Start date cannot be in the future.')
|
||||
})
|
||||
|
||||
it('closes lifecycle date editing when unchanged or canceled', async () => {
|
||||
const wrapper = mountHero()
|
||||
await flushPromises()
|
||||
|
||||
@@ -6,7 +6,7 @@ const mocks = vi.hoisted(() => ({
|
||||
|
||||
vi.mock('@/lib/api', () => ({ api: mocks.api }))
|
||||
|
||||
import { useBookStatus } from '../useBookStatus'
|
||||
import { ReadStatusUpdateError, useBookStatus } from '../useBookStatus'
|
||||
|
||||
describe('useBookStatus', () => {
|
||||
beforeEach(() => {
|
||||
@@ -73,4 +73,18 @@ describe('useBookStatus', () => {
|
||||
const { setStatus } = useBookStatus()
|
||||
await expect(setStatus(2, 'read')).rejects.toThrow('HTTP 400')
|
||||
})
|
||||
|
||||
it('preserves stable server error codes on failed updates', async () => {
|
||||
;(mocks.api as Mock).mockResolvedValue({
|
||||
ok: false,
|
||||
status: 400,
|
||||
json: async () => ({ errorCode: 'READING_DATES_INVALID_ORDER' }),
|
||||
} as Response)
|
||||
const { updateStatus } = useBookStatus()
|
||||
|
||||
const error = await updateStatus(2, { startedAt: '2026-02-02' }).catch((reason: unknown) => reason)
|
||||
|
||||
expect(error).toBeInstanceOf(ReadStatusUpdateError)
|
||||
expect(error).toMatchObject({ message: 'HTTP 400', errorCode: 'READING_DATES_INVALID_ORDER' })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -46,6 +46,16 @@ export type ReadStatusPatch = {
|
||||
finishedAt?: string | null
|
||||
}
|
||||
|
||||
export class ReadStatusUpdateError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly errorCode: string | null,
|
||||
) {
|
||||
super(message)
|
||||
this.name = 'ReadStatusUpdateError'
|
||||
}
|
||||
}
|
||||
|
||||
export function useBookStatus() {
|
||||
async function updateStatus(bookId: number, patch: ReadStatusPatch): Promise<UserBookStatus> {
|
||||
const res = await api(`/api/v1/books/${bookId}/status`, {
|
||||
@@ -53,7 +63,16 @@ export function useBookStatus() {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(patch),
|
||||
})
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
if (!res.ok) {
|
||||
let errorCode: string | null = null
|
||||
try {
|
||||
const body = (await res.json()) as { errorCode?: unknown }
|
||||
if (typeof body.errorCode === 'string') errorCode = body.errorCode
|
||||
} catch {
|
||||
errorCode = null
|
||||
}
|
||||
throw new ReadStatusUpdateError(`HTTP ${res.status}`, errorCode)
|
||||
}
|
||||
return (await res.json()) as UserBookStatus
|
||||
}
|
||||
|
||||
|
||||
@@ -3900,6 +3900,7 @@
|
||||
"dateAddedFutureError": "Date Added cannot be in the future.",
|
||||
"dateFinishedFutureError": "Date Finished cannot be in the future.",
|
||||
"dateFinishedBeforeStartedError": "Date Finished must be on or after Date Started.",
|
||||
"dateFinishedBeforeStartedErrorWithDate": "Date Finished must be on or after Date Started ({date}).",
|
||||
"saveReadingDatesError": "Failed to save reading dates.",
|
||||
"saveAddedDateError": "Failed to save date added.",
|
||||
"koboPendingDelete": "Pending delete from device",
|
||||
@@ -4208,6 +4209,7 @@
|
||||
"startFuture": "Start date cannot be in the future.",
|
||||
"finishFuture": "Finish date cannot be in the future.",
|
||||
"finishBeforeStart": "Finish date must be on or after the start date.",
|
||||
"finishBeforeStartWithDate": "Finish date must be on or after the start date ({date}).",
|
||||
"saveFailed": "Failed to save reading dates."
|
||||
},
|
||||
"eta": {
|
||||
|
||||
Reference in New Issue
Block a user