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:
neon
2026-09-10 15:35:06 +00:00
parent c2f9eea839
commit 72871fad65
8 changed files with 164 additions and 18 deletions
@@ -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)
}