mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
fix(client): preserve synced reading calendar dates
Treat exact UTC-midnight lifecycle projections as calendar dates while preserving local conversion for real timestamps. Fixes #1322
This commit is contained in:
@@ -9,6 +9,7 @@ import { formatDate, formatNumber, formatRelativeFromNow } from '@/i18n/formatte
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
|
||||
import { STATUS_COLORS, STATUS_ICONS, STATUS_OPTIONS, useBookStatus } from '@/features/book/composables/useBookStatus'
|
||||
import { useReadingLogInsights } from '@/features/book/composables/useReadingLogInsights'
|
||||
import { readingDateToDateKey } from '@/features/book/lib/reading-date'
|
||||
import AchievementProgressRing from '@/features/achievements/components/AchievementProgressRing.vue'
|
||||
import ReadingLogSourceSplit from './ReadingLogSourceSplit.vue'
|
||||
|
||||
@@ -31,8 +32,6 @@ const { setStatus, updateStatus } = useBookStatus()
|
||||
const statsRef = computed(() => props.stats)
|
||||
const { activeDays, spanDays, pacePercentPerHour, momentum } = useReadingLogInsights(statsRef)
|
||||
|
||||
const DATE_KEY_RE = /^\d{4}-\d{2}-\d{2}$/
|
||||
|
||||
function dateToDateKey(value: Date): string {
|
||||
const year = value.getFullYear()
|
||||
const month = String(value.getMonth() + 1).padStart(2, '0')
|
||||
@@ -40,14 +39,6 @@ function dateToDateKey(value: Date): string {
|
||||
return `${year}-${month}-${day}`
|
||||
}
|
||||
|
||||
function toDateInputValue(value: string | null | undefined): string {
|
||||
if (!value) return ''
|
||||
if (DATE_KEY_RE.test(value)) return value
|
||||
const parsed = new Date(value)
|
||||
if (Number.isNaN(parsed.getTime())) return ''
|
||||
return dateToDateKey(parsed)
|
||||
}
|
||||
|
||||
function formatDisplayDate(dateKey: string): string {
|
||||
if (!dateKey) return t('book.detail.readingLog.vitals.notSet')
|
||||
const [year, month, day] = dateKey.split('-').map(Number)
|
||||
@@ -73,8 +64,8 @@ const datesError = ref<string | null>(null)
|
||||
|
||||
function normalizeDates(readStatus: UserBookStatus | null | undefined) {
|
||||
return {
|
||||
startedAt: toDateInputValue(readStatus?.startedAt),
|
||||
finishedAt: toDateInputValue(readStatus?.finishedAt),
|
||||
startedAt: readingDateToDateKey(readStatus?.startedAt),
|
||||
finishedAt: readingDateToDateKey(readStatus?.finishedAt),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ import { DialogClose, DialogContent, DialogOverlay, DialogPortal, DialogRoot } f
|
||||
import { getFormatColor } from '@/features/book/lib/format-colors'
|
||||
import { providerIconPathSafe } from '@/features/book/lib/provider-icons'
|
||||
import { createBookProviderLinks } from '@/features/book/lib/provider-links'
|
||||
import { readingDateToDateKey } from '@/features/book/lib/reading-date'
|
||||
import { getProviderColor, PROVIDER_SHORT_LABELS } from '@/lib/provider-colors'
|
||||
import { useCoverVersions } from '@/features/book/composables/useCoverVersions'
|
||||
import { COVER_ASPECT_RATIO_KEY, DEFAULT_COVER_ASPECT_RATIO } from '@/features/book/lib/cover-aspect-ratio'
|
||||
@@ -512,8 +513,8 @@ const addedDateEditButton = ref<HTMLButtonElement | null>(null)
|
||||
|
||||
const localReadStatus = ref<ReadStatus | null>(props.book.readStatus?.status ?? null)
|
||||
const savedReadingDates = ref<{ startedAt: string; finishedAt: string }>({
|
||||
startedAt: toDateInputValue(props.book.readStatus?.startedAt),
|
||||
finishedAt: toDateInputValue(props.book.readStatus?.finishedAt),
|
||||
startedAt: readingDateToDateKey(props.book.readStatus?.startedAt, userTimeZone.value),
|
||||
finishedAt: readingDateToDateKey(props.book.readStatus?.finishedAt, userTimeZone.value),
|
||||
})
|
||||
const draftReadingDates = ref<{ startedAt: string; finishedAt: string }>({
|
||||
startedAt: savedReadingDates.value.startedAt,
|
||||
@@ -582,8 +583,8 @@ function clearAddedDateError() {
|
||||
|
||||
function normalizeReadStatusDates(readStatus: UserBookStatus | null | undefined) {
|
||||
return {
|
||||
startedAt: toDateInputValue(readStatus?.startedAt),
|
||||
finishedAt: toDateInputValue(readStatus?.finishedAt),
|
||||
startedAt: readingDateToDateKey(readStatus?.startedAt, userTimeZone.value),
|
||||
finishedAt: readingDateToDateKey(readStatus?.finishedAt, userTimeZone.value),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -695,6 +695,25 @@ describe('DetailsTab cover surface', () => {
|
||||
expect((wrapper.get('input[aria-label="Added"]').element as HTMLInputElement).value).toBe('2022-07-13')
|
||||
})
|
||||
|
||||
it('keeps projected reading dates on their canonical day west of UTC', async () => {
|
||||
mocks.user.value.settings.timezone = 'America/Sao_Paulo'
|
||||
const wrapper = mountDetails(
|
||||
makeBook({
|
||||
readStatus: {
|
||||
status: 'read',
|
||||
source: 'auto',
|
||||
startedAt: '2026-09-07T00:00:00.000Z',
|
||||
finishedAt: '2026-09-07T00:00:00Z',
|
||||
updatedAt: '2026-09-07T00:00:00.000Z',
|
||||
},
|
||||
}),
|
||||
)
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text().match(/Sep 7, 2026/g)).toHaveLength(2)
|
||||
expect(wrapper.text()).not.toContain('Sep 6, 2026')
|
||||
})
|
||||
|
||||
it('hides added date editing when the user cannot edit metadata', async () => {
|
||||
mocks.hasPermission.mockReturnValue(false)
|
||||
const wrapper = mountDetails(makeBook())
|
||||
|
||||
@@ -111,6 +111,24 @@ function makeStats(items: BookReadingSession[]): BookReadingSessionStats {
|
||||
}
|
||||
}
|
||||
|
||||
function makeAttempt(overrides: Partial<ReadingAttempt> = {}): ReadingAttempt {
|
||||
return {
|
||||
id: 1,
|
||||
bookId: 10,
|
||||
startedOn: '2026-09-07',
|
||||
endedOn: null,
|
||||
outcome: null,
|
||||
origin: 'koreader',
|
||||
externalProvider: null,
|
||||
externalId: null,
|
||||
totalSessions: 0,
|
||||
totalSeconds: 0,
|
||||
createdAt: '2026-09-07T00:00:00.000Z',
|
||||
updatedAt: '2026-09-07T00:00:00.000Z',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function jsonResponse(body: unknown) {
|
||||
return { ok: true, status: 200, json: async () => body } as Response
|
||||
}
|
||||
@@ -180,6 +198,33 @@ describe('ReadingLogTab', () => {
|
||||
expect(text).toContain('This year')
|
||||
})
|
||||
|
||||
it('shows a projected UTC-midnight lifecycle date on its canonical day west of UTC', async () => {
|
||||
const originalTimeZone = process.env.TZ
|
||||
process.env.TZ = 'America/Sao_Paulo'
|
||||
try {
|
||||
mocks.api.mockImplementation(routeApi({ attempts: [makeAttempt()] }))
|
||||
const wrapper = mountTab(
|
||||
makeBook({
|
||||
readStatus: {
|
||||
status: 'reading',
|
||||
source: 'auto',
|
||||
startedAt: '2026-09-07T00:00:00.000Z',
|
||||
finishedAt: null,
|
||||
updatedAt: '2026-09-07T00:00:00.000Z',
|
||||
},
|
||||
}),
|
||||
)
|
||||
await flushPromises()
|
||||
|
||||
const startedDate = wrapper.get('button[aria-label="Edit the date you started this book"]')
|
||||
expect(new Date('2026-09-07T00:00:00.000Z').getDate()).toBe(6)
|
||||
expect(startedDate.text()).toContain('Sep 7, 2026')
|
||||
expect(startedDate.text()).not.toContain('Sep 6, 2026')
|
||||
} finally {
|
||||
process.env.TZ = originalTimeZone
|
||||
}
|
||||
})
|
||||
|
||||
it('narrows the session request when a quick filter is chosen', async () => {
|
||||
mocks.api.mockImplementation(routeApi({ sessions: [makeSession()] }))
|
||||
const wrapper = mountTab()
|
||||
|
||||
@@ -213,7 +213,23 @@ describe('tableColumnSchema', () => {
|
||||
readStatus: { status: 'read', source: 'manual', startedAt: null, finishedAt: '2025-06-01T00:00:00Z', updatedAt: '2025-06-01T00:00:00Z' },
|
||||
})
|
||||
const def = COLUMN_DEFS.find((c) => c.id === 'finishedAt')!
|
||||
expect(def.accessor!(book)).toBe('2025-06-01T00:00:00Z')
|
||||
expect(def.accessor!(book)).toBe('2025-06-01')
|
||||
})
|
||||
|
||||
it('finishedAt accessor keeps a projected UTC-midnight date west of UTC', () => {
|
||||
const originalTimeZone = process.env.TZ
|
||||
process.env.TZ = 'America/Sao_Paulo'
|
||||
try {
|
||||
const book = makeBook({
|
||||
readStatus: { status: 'read', source: 'auto', startedAt: null, finishedAt: '2025-06-01T00:00:00.000Z', updatedAt: '2025-06-01T00:00:00Z' },
|
||||
})
|
||||
const def = COLUMN_DEFS.find((c) => c.id === 'finishedAt')!
|
||||
|
||||
expect(new Date('2025-06-01T00:00:00.000Z').getDate()).toBe(31)
|
||||
expect(def.accessor!(book)).toBe('2025-06-01')
|
||||
} finally {
|
||||
process.env.TZ = originalTimeZone
|
||||
}
|
||||
})
|
||||
|
||||
it('finishedAt accessor returns null when readStatus is null', () => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { BookCard, BookMetadataLockField, CustomMetadataFieldSummary, CustomMetadataFieldType, SortField } from '@bookorbit/types'
|
||||
import { customSortField } from '@bookorbit/types'
|
||||
import { readingDateToDateKey } from '@/features/book/lib/reading-date'
|
||||
import { formatBytes } from '@/lib/formatting'
|
||||
|
||||
function getPrimaryFile(book: BookCard) {
|
||||
@@ -364,7 +365,7 @@ export const COLUMN_DEFS: ColumnDef[] = [
|
||||
minWidth: 80,
|
||||
defaultVisible: false,
|
||||
pinned: null,
|
||||
accessor: (book) => book.readStatus?.finishedAt ?? null,
|
||||
accessor: (book) => readingDateToDateKey(book.readStatus?.finishedAt) || null,
|
||||
},
|
||||
{
|
||||
id: 'readStatus',
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { readingDateToDateKey } from './reading-date'
|
||||
|
||||
const originalTimeZone = process.env.TZ
|
||||
|
||||
afterEach(() => {
|
||||
process.env.TZ = originalTimeZone
|
||||
})
|
||||
|
||||
describe('readingDateToDateKey', () => {
|
||||
it('keeps projected UTC-midnight dates unchanged west of UTC', () => {
|
||||
process.env.TZ = 'America/Sao_Paulo'
|
||||
|
||||
expect(new Date('2026-09-07T00:00:00.000Z').getDate()).toBe(6)
|
||||
expect(readingDateToDateKey('2026-09-07T00:00:00.000Z')).toBe('2026-09-07')
|
||||
expect(readingDateToDateKey('2026-09-07T00:00:00Z')).toBe('2026-09-07')
|
||||
})
|
||||
|
||||
it('does not let projected dates cross forward east of UTC', () => {
|
||||
process.env.TZ = 'Pacific/Kiritimati'
|
||||
|
||||
expect(new Date('2026-09-07T12:00:00.000Z').getDate()).toBe(8)
|
||||
expect(readingDateToDateKey('2026-09-07T00:00:00.000Z')).toBe('2026-09-07')
|
||||
})
|
||||
|
||||
it('continues to localize lifecycle values that carry a real time', () => {
|
||||
process.env.TZ = 'America/Sao_Paulo'
|
||||
|
||||
expect(readingDateToDateKey('2026-09-07T02:00:00.000Z')).toBe('2026-09-06')
|
||||
expect(readingDateToDateKey('2026-09-07T03:00:00.000Z')).toBe('2026-09-07')
|
||||
expect(readingDateToDateKey('2026-09-07T02:00:00.000Z', 'Europe/Rome')).toBe('2026-09-07')
|
||||
})
|
||||
|
||||
it('passes date keys through and rejects unusable values', () => {
|
||||
expect(readingDateToDateKey('2026-09-07')).toBe('2026-09-07')
|
||||
expect(readingDateToDateKey('not-a-date')).toBe('')
|
||||
expect(readingDateToDateKey(null)).toBe('')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,34 @@
|
||||
const DATE_KEY_RE = /^\d{4}-\d{2}-\d{2}$/
|
||||
const UTC_MIDNIGHT_DATE_RE = /^(\d{4}-\d{2}-\d{2})T00:00:00(?:\.0+)?Z$/
|
||||
|
||||
function localDateKey(value: Date, timeZone?: string): string {
|
||||
if (timeZone) {
|
||||
const parts = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone,
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
}).formatToParts(value)
|
||||
const year = parts.find((part) => part.type === 'year')?.value
|
||||
const month = parts.find((part) => part.type === 'month')?.value
|
||||
const day = parts.find((part) => part.type === 'day')?.value
|
||||
return year && month && day ? `${year}-${month}-${day}` : ''
|
||||
}
|
||||
|
||||
const year = String(value.getFullYear()).padStart(4, '0')
|
||||
const month = String(value.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(value.getDate()).padStart(2, '0')
|
||||
return `${year}-${month}-${day}`
|
||||
}
|
||||
|
||||
export function readingDateToDateKey(value: string | null | undefined, timeZone?: string): string {
|
||||
if (!value) return ''
|
||||
if (DATE_KEY_RE.test(value)) return value
|
||||
|
||||
// Reading-attempt dates are projected into the legacy status columns as UTC midnight.
|
||||
const projectedDate = UTC_MIDNIGHT_DATE_RE.exec(value)?.[1]
|
||||
if (projectedDate) return projectedDate
|
||||
|
||||
const parsed = new Date(value)
|
||||
return Number.isNaN(parsed.getTime()) ? '' : localDateKey(parsed, timeZone)
|
||||
}
|
||||
Reference in New Issue
Block a user