From 72871fad65d9345408294a955ac4506b3e5cf69d Mon Sep 17 00:00:00 2001 From: neon <18559658+neonsolstice@users.noreply.github.com> Date: Thu, 10 Sep 2026 15:35:06 +0000 Subject: [PATCH] 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 --- .../detail/reading-log/ReadingLogVitals.vue | 15 ++----- .../components/detail/tabs/DetailsTab.vue | 9 ++-- .../detail/tabs/__tests__/DetailsTab.spec.ts | 19 ++++++++ .../tabs/__tests__/ReadingLogTab.spec.ts | 45 +++++++++++++++++++ .../__tests__/tableColumnSchema.spec.ts | 18 +++++++- .../book/composables/tableColumnSchema.ts | 3 +- .../features/book/lib/reading-date.spec.ts | 39 ++++++++++++++++ client/src/features/book/lib/reading-date.ts | 34 ++++++++++++++ 8 files changed, 164 insertions(+), 18 deletions(-) create mode 100644 client/src/features/book/lib/reading-date.spec.ts create mode 100644 client/src/features/book/lib/reading-date.ts diff --git a/client/src/features/book/components/detail/reading-log/ReadingLogVitals.vue b/client/src/features/book/components/detail/reading-log/ReadingLogVitals.vue index ed8ef6a98..85baeff8a 100644 --- a/client/src/features/book/components/detail/reading-log/ReadingLogVitals.vue +++ b/client/src/features/book/components/detail/reading-log/ReadingLogVitals.vue @@ -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(null) function normalizeDates(readStatus: UserBookStatus | null | undefined) { return { - startedAt: toDateInputValue(readStatus?.startedAt), - finishedAt: toDateInputValue(readStatus?.finishedAt), + startedAt: readingDateToDateKey(readStatus?.startedAt), + finishedAt: readingDateToDateKey(readStatus?.finishedAt), } } diff --git a/client/src/features/book/components/detail/tabs/DetailsTab.vue b/client/src/features/book/components/detail/tabs/DetailsTab.vue index bf7bf011f..7e8425552 100644 --- a/client/src/features/book/components/detail/tabs/DetailsTab.vue +++ b/client/src/features/book/components/detail/tabs/DetailsTab.vue @@ -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(null) const localReadStatus = ref(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), } } diff --git a/client/src/features/book/components/detail/tabs/__tests__/DetailsTab.spec.ts b/client/src/features/book/components/detail/tabs/__tests__/DetailsTab.spec.ts index 117099b69..c346bb67a 100644 --- a/client/src/features/book/components/detail/tabs/__tests__/DetailsTab.spec.ts +++ b/client/src/features/book/components/detail/tabs/__tests__/DetailsTab.spec.ts @@ -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()) diff --git a/client/src/features/book/components/detail/tabs/__tests__/ReadingLogTab.spec.ts b/client/src/features/book/components/detail/tabs/__tests__/ReadingLogTab.spec.ts index 6b90e7cdc..712d2abda 100644 --- a/client/src/features/book/components/detail/tabs/__tests__/ReadingLogTab.spec.ts +++ b/client/src/features/book/components/detail/tabs/__tests__/ReadingLogTab.spec.ts @@ -111,6 +111,24 @@ function makeStats(items: BookReadingSession[]): BookReadingSessionStats { } } +function makeAttempt(overrides: Partial = {}): 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() diff --git a/client/src/features/book/composables/__tests__/tableColumnSchema.spec.ts b/client/src/features/book/composables/__tests__/tableColumnSchema.spec.ts index 2ab6357aa..c0c9b5959 100644 --- a/client/src/features/book/composables/__tests__/tableColumnSchema.spec.ts +++ b/client/src/features/book/composables/__tests__/tableColumnSchema.spec.ts @@ -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', () => { diff --git a/client/src/features/book/composables/tableColumnSchema.ts b/client/src/features/book/composables/tableColumnSchema.ts index c81e50c85..dbb72d4e7 100644 --- a/client/src/features/book/composables/tableColumnSchema.ts +++ b/client/src/features/book/composables/tableColumnSchema.ts @@ -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', diff --git a/client/src/features/book/lib/reading-date.spec.ts b/client/src/features/book/lib/reading-date.spec.ts new file mode 100644 index 000000000..ba69224b5 --- /dev/null +++ b/client/src/features/book/lib/reading-date.spec.ts @@ -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('') + }) +}) diff --git a/client/src/features/book/lib/reading-date.ts b/client/src/features/book/lib/reading-date.ts new file mode 100644 index 000000000..32ce76f10 --- /dev/null +++ b/client/src/features/book/lib/reading-date.ts @@ -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) +}