diff --git a/Dockerfile b/Dockerfile index ed9b2de78..c7ec79b09 100644 --- a/Dockerfile +++ b/Dockerfile @@ -43,6 +43,7 @@ WORKDIR /app ARG APP_VERSION=dev ENV APP_VERSION=${APP_VERSION} ENV KOBO_CLOUDSCRAPER_PYTHON=/opt/bookorbit-python/bin/python +ENV KOREADER_PLUGIN_PATH=/app/koreader-plugin/bookorbit.koplugin COPY server/requirements/kobo-cloudscraper.txt /tmp/kobo-cloudscraper-requirements.txt @@ -60,6 +61,7 @@ COPY --from=server-builder --chown=node:node /deploy ./ COPY --from=client-builder --chown=node:node /app/client/dist ./public COPY --from=server-builder --chown=node:node /app/server/entrypoint.sh ./entrypoint.sh COPY --chown=node:node server/bin/kepubify/ ./bin/kepubify/ +COPY --chown=node:node koreader-plugin/bookorbit.koplugin/ ./koreader-plugin/bookorbit.koplugin/ RUN chmod +x /app/entrypoint.sh /app/bin/kepubify/* && mkdir -p /books /data/covers /data/book-bucket /tmp && chown -R node:node /data /tmp diff --git a/client/src/assets/theme/tokens.css b/client/src/assets/theme/tokens.css index ac8acf51c..e33be19a3 100644 --- a/client/src/assets/theme/tokens.css +++ b/client/src/assets/theme/tokens.css @@ -74,6 +74,14 @@ --scrollbar-thumb: oklch(0.8 var(--tint-c) var(--tint-h)); --scrollbar-thumb-hover: oklch(0.7 var(--tint-c) var(--tint-h)); + + /* Annotation pill hues — fixed, accent-independent. Sources get distinct + hues; status uses semantic tiers (Exact stays neutral via --muted). */ + --pill-web: oklch(0.55 0.16 245); + --pill-koreader: oklch(0.52 0.2 295); + --pill-kobo: oklch(0.55 0.1 195); + --pill-repaired: oklch(0.62 0.14 70); + --pill-pending: oklch(0.55 0.09 255); } .dark { @@ -137,4 +145,11 @@ --scrollbar-thumb: oklch(0.35 var(--tint-c) var(--tint-h)); --scrollbar-thumb-hover: oklch(0.45 var(--tint-c) var(--tint-h)); + + /* Lifted lightness so pills read on dark surfaces */ + --pill-web: oklch(0.72 0.14 245); + --pill-koreader: oklch(0.72 0.17 295); + --pill-kobo: oklch(0.74 0.1 195); + --pill-repaired: oklch(0.78 0.13 70); + --pill-pending: oklch(0.72 0.09 255); } diff --git a/client/src/components/AppHeader.vue b/client/src/components/AppHeader.vue index 18320e999..7945ab94c 100644 --- a/client/src/components/AppHeader.vue +++ b/client/src/components/AppHeader.vue @@ -18,6 +18,7 @@ import { Star, ExternalLink, Sparkles, + Highlighter, } from '@lucide/vue' import { useRouter, useRoute } from 'vue-router' import { toast } from 'vue-sonner' @@ -74,6 +75,7 @@ const githubStarPopoverOpen = ref(false) const isBookDockActive = computed(() => route.name === 'book-dock') const isAchievementsActive = computed(() => route.name === 'achievements') const isStatisticsActive = computed(() => route.name === 'statistics') +const isAnnotationsActive = computed(() => route.name === 'annotations') const iconRadiusClass = computed(() => (themeStore.radius === 'sharp' ? 'rounded-none' : 'rounded-full')) const canChangePassword = computed( @@ -89,6 +91,10 @@ function navigateToStatistics() { router.push({ name: 'statistics', query: { tab: 'library' } }) } +function navigateToAnnotations() { + router.push({ name: 'annotations' }) +} + function navigateToAchievements() { router.push({ name: 'achievements' }) } @@ -607,6 +613,27 @@ function formatBadgeStyle(fmt: string) { + + + + + + Annotations + + diff --git a/client/src/features/annotations/components/AnnotationBookCombobox.vue b/client/src/features/annotations/components/AnnotationBookCombobox.vue new file mode 100644 index 000000000..b6bcc26a7 --- /dev/null +++ b/client/src/features/annotations/components/AnnotationBookCombobox.vue @@ -0,0 +1,154 @@ + + + diff --git a/client/src/features/annotations/components/AnnotationBookGroup.vue b/client/src/features/annotations/components/AnnotationBookGroup.vue new file mode 100644 index 000000000..3ff1659bc --- /dev/null +++ b/client/src/features/annotations/components/AnnotationBookGroup.vue @@ -0,0 +1,110 @@ + + + diff --git a/client/src/features/annotations/components/AnnotationBookThumb.vue b/client/src/features/annotations/components/AnnotationBookThumb.vue new file mode 100644 index 000000000..8df6cd084 --- /dev/null +++ b/client/src/features/annotations/components/AnnotationBookThumb.vue @@ -0,0 +1,29 @@ + + + diff --git a/client/src/features/annotations/components/AnnotationBulkBar.vue b/client/src/features/annotations/components/AnnotationBulkBar.vue new file mode 100644 index 000000000..b0431e7c2 --- /dev/null +++ b/client/src/features/annotations/components/AnnotationBulkBar.vue @@ -0,0 +1,89 @@ + + + diff --git a/client/src/features/annotations/components/AnnotationCard.vue b/client/src/features/annotations/components/AnnotationCard.vue new file mode 100644 index 000000000..60f051dff --- /dev/null +++ b/client/src/features/annotations/components/AnnotationCard.vue @@ -0,0 +1,83 @@ + + + diff --git a/client/src/features/annotations/components/AnnotationFilterChips.vue b/client/src/features/annotations/components/AnnotationFilterChips.vue new file mode 100644 index 000000000..de70af666 --- /dev/null +++ b/client/src/features/annotations/components/AnnotationFilterChips.vue @@ -0,0 +1,39 @@ + + + diff --git a/client/src/features/annotations/components/AnnotationFiltersPanel.vue b/client/src/features/annotations/components/AnnotationFiltersPanel.vue new file mode 100644 index 000000000..a1d998d15 --- /dev/null +++ b/client/src/features/annotations/components/AnnotationFiltersPanel.vue @@ -0,0 +1,88 @@ + + + diff --git a/client/src/features/annotations/components/AnnotationListItem.vue b/client/src/features/annotations/components/AnnotationListItem.vue new file mode 100644 index 000000000..e7bfe3e8e --- /dev/null +++ b/client/src/features/annotations/components/AnnotationListItem.vue @@ -0,0 +1,486 @@ + + + diff --git a/client/src/features/annotations/components/AnnotationPagination.vue b/client/src/features/annotations/components/AnnotationPagination.vue new file mode 100644 index 000000000..7e3b839fd --- /dev/null +++ b/client/src/features/annotations/components/AnnotationPagination.vue @@ -0,0 +1,57 @@ + + + diff --git a/client/src/features/annotations/components/AnnotationSummaryBar.vue b/client/src/features/annotations/components/AnnotationSummaryBar.vue new file mode 100644 index 000000000..cbf52bcd5 --- /dev/null +++ b/client/src/features/annotations/components/AnnotationSummaryBar.vue @@ -0,0 +1,38 @@ + + + diff --git a/client/src/features/annotations/components/AnnotationSyncDetailPanel.vue b/client/src/features/annotations/components/AnnotationSyncDetailPanel.vue new file mode 100644 index 000000000..0cbd49392 --- /dev/null +++ b/client/src/features/annotations/components/AnnotationSyncDetailPanel.vue @@ -0,0 +1,122 @@ + + + diff --git a/client/src/features/annotations/components/AnnotationToolbar.vue b/client/src/features/annotations/components/AnnotationToolbar.vue new file mode 100644 index 000000000..ac5cc8d44 --- /dev/null +++ b/client/src/features/annotations/components/AnnotationToolbar.vue @@ -0,0 +1,103 @@ + + + diff --git a/client/src/features/annotations/components/__tests__/AnnotationBookCombobox.spec.ts b/client/src/features/annotations/components/__tests__/AnnotationBookCombobox.spec.ts new file mode 100644 index 000000000..cca8ebefd --- /dev/null +++ b/client/src/features/annotations/components/__tests__/AnnotationBookCombobox.spec.ts @@ -0,0 +1,60 @@ +import { mount, flushPromises } from '@vue/test-utils' +import { describe, expect, it, vi } from 'vitest' +import type { AnnotationHubBookFacet } from '@bookorbit/types' +import AnnotationBookCombobox from '../AnnotationBookCombobox.vue' + +const BOOKS: AnnotationHubBookFacet[] = [ + { bookId: 5, bookTitle: 'Dune', author: 'FH', count: 9 }, + { bookId: 6, bookTitle: 'Hyperion', author: 'DS', count: 4 }, +] + +function mountCombobox(props: Partial<{ modelValue: number | 'all'; selectedLabel: string | null }> = {}, books = BOOKS) { + const searchFn = vi.fn<(q: string) => Promise>().mockResolvedValue(books) + const wrapper = mount(AnnotationBookCombobox, { + props: { modelValue: 'all', selectedLabel: null, searchFn, ...props }, + }) + return { wrapper, searchFn } +} + +describe('AnnotationBookCombobox', () => { + it('shows the selected label and a clear control when a book is selected', () => { + const { wrapper } = mountCombobox({ modelValue: 5, selectedLabel: 'Dune' }) + + expect((wrapper.get('input').element as HTMLInputElement).value).toBe('Dune') + expect(wrapper.find('[aria-label="Clear book filter"]').exists()).toBe(true) + }) + + it('loads the recent list on focus and emits id + label on select', async () => { + const { wrapper, searchFn } = mountCombobox() + + await wrapper.get('input').trigger('focus') + await flushPromises() + + expect(searchFn).toHaveBeenCalledWith('') + const options = wrapper.findAll('[role="option"]') + expect(options).toHaveLength(2) + + await options[0]!.trigger('mousedown') + + expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([5]) + expect(wrapper.emitted('update:selectedLabel')?.[0]).toEqual(['Dune']) + }) + + it('clears the selection back to all books', async () => { + const { wrapper } = mountCombobox({ modelValue: 5, selectedLabel: 'Dune' }) + + await wrapper.get('[aria-label="Clear book filter"]').trigger('mousedown') + + expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['all']) + expect(wrapper.emitted('update:selectedLabel')?.[0]).toEqual([null]) + }) + + it('shows an empty state when the search returns nothing', async () => { + const { wrapper } = mountCombobox({}, []) + + await wrapper.get('input').trigger('focus') + await flushPromises() + + expect(wrapper.text()).toContain('No books found') + }) +}) diff --git a/client/src/features/annotations/components/__tests__/AnnotationBulkBar.spec.ts b/client/src/features/annotations/components/__tests__/AnnotationBulkBar.spec.ts new file mode 100644 index 000000000..6be164515 --- /dev/null +++ b/client/src/features/annotations/components/__tests__/AnnotationBulkBar.spec.ts @@ -0,0 +1,50 @@ +import { mount } from '@vue/test-utils' +import { describe, expect, it } from 'vitest' +import { ANNOTATION_HIGHLIGHT_COLORS } from '@bookorbit/types' +import AnnotationBulkBar from '../AnnotationBulkBar.vue' + +const stubs = { + Tooltip: { template: '
' }, + TooltipTrigger: { template: '
' }, + TooltipContent: { template: '
' }, +} + +function mountBar(props: Record = {}) { + return mount(AnnotationBulkBar, { + props: { count: 2, allVisibleSelected: false, ...props }, + global: { stubs }, + }) +} + +describe('AnnotationBulkBar', () => { + it('renders the selected count', () => { + expect(mountBar({ count: 5 }).text()).toContain('5 selected') + }) + + it('labels the select-page toggle by visibility state', () => { + expect(mountBar({ allVisibleSelected: false }).text()).toContain('Select page') + expect(mountBar({ allVisibleSelected: true }).text()).toContain('Page selected') + }) + + it('emits selectPage and clear', async () => { + const wrapper = mountBar() + const buttons = wrapper.findAll('button') + await buttons.find((button) => button.text().includes('Select page'))!.trigger('click') + await buttons.find((button) => button.text() === 'Clear')!.trigger('click') + expect(wrapper.emitted('selectPage')).toBeTruthy() + expect(wrapper.emitted('clear')).toBeTruthy() + }) + + it('hides the restyle controls by default', () => { + expect(mountBar().find('[aria-label="Recolor to Yellow"]').exists()).toBe(false) + }) + + it('emits recolor and restyle when showRestyle is enabled', async () => { + const wrapper = mountBar({ showRestyle: true }) + await wrapper.get('[aria-label="Recolor to Yellow"]').trigger('click') + expect(wrapper.emitted('recolor')!.slice(-1)[0]).toEqual([ANNOTATION_HIGHLIGHT_COLORS[0]!.hex]) + + await wrapper.get('[aria-label="Restyle to Underline"]').trigger('click') + expect(wrapper.emitted('restyle')!.slice(-1)[0]).toEqual(['underline']) + }) +}) diff --git a/client/src/features/annotations/components/__tests__/AnnotationFiltersPanel.spec.ts b/client/src/features/annotations/components/__tests__/AnnotationFiltersPanel.spec.ts new file mode 100644 index 000000000..ad97e4eac --- /dev/null +++ b/client/src/features/annotations/components/__tests__/AnnotationFiltersPanel.spec.ts @@ -0,0 +1,50 @@ +import { mount } from '@vue/test-utils' +import { describe, expect, it } from 'vitest' +import { ANNOTATION_COLOR_FILTER_OPTIONS } from '@bookorbit/types' +import AnnotationFiltersPanel from '../AnnotationFiltersPanel.vue' + +function mountPanel(props: Record = {}) { + return mount(AnnotationFiltersPanel, { + props: { colors: [], dateFrom: '', dateTo: '', ...props }, + }) +} + +describe('AnnotationFiltersPanel', () => { + it('renders one toggleable swatch per filterable color', () => { + const swatches = mountPanel().findAll('button[aria-pressed]') + expect(swatches).toHaveLength(ANNOTATION_COLOR_FILTER_OPTIONS.length) + }) + + it('adds a color to the selection when toggled on', async () => { + const wrapper = mountPanel({ colors: [] }) + await wrapper.get('button[aria-label="Yellow"]').trigger('click') + expect(wrapper.emitted('update:colors')!.slice(-1)[0]).toEqual([['#FACC15']]) + }) + + it('removes a color from the selection when toggled off', async () => { + const wrapper = mountPanel({ colors: ['#FACC15'] }) + expect(wrapper.get('button[aria-label="Yellow"]').attributes('aria-pressed')).toBe('true') + await wrapper.get('button[aria-label="Yellow"]').trigger('click') + expect(wrapper.emitted('update:colors')!.slice(-1)[0]).toEqual([[]]) + }) + + it('updates and clears the date range', async () => { + const wrapper = mountPanel({ dateFrom: '2026-01-01', dateTo: '2026-01-31' }) + const dateInputs = wrapper.findAll('input[type="date"]') + await dateInputs[0]!.setValue('2026-02-01') + expect(wrapper.emitted('update:dateFrom')!.slice(-1)[0]).toEqual(['2026-02-01']) + await dateInputs[1]!.setValue('2026-02-28') + expect(wrapper.emitted('update:dateTo')!.slice(-1)[0]).toEqual(['2026-02-28']) + + await wrapper.get('[aria-label="Clear date range"]').trigger('click') + expect(wrapper.emitted('update:dateFrom')!.slice(-1)[0]).toEqual(['']) + expect(wrapper.emitted('update:dateTo')!.slice(-1)[0]).toEqual(['']) + }) + + it('emits clearAll from the footer button', async () => { + const wrapper = mountPanel() + const clearAll = wrapper.findAll('button').find((button) => button.text() === 'Clear all') + await clearAll!.trigger('click') + expect(wrapper.emitted('clearAll')).toBeTruthy() + }) +}) diff --git a/client/src/features/annotations/components/__tests__/AnnotationListItem.spec.ts b/client/src/features/annotations/components/__tests__/AnnotationListItem.spec.ts new file mode 100644 index 000000000..c275b9694 --- /dev/null +++ b/client/src/features/annotations/components/__tests__/AnnotationListItem.spec.ts @@ -0,0 +1,119 @@ +import { mount } from '@vue/test-utils' +import { describe, expect, it, vi } from 'vitest' +import type { VueWrapper } from '@vue/test-utils' +import type { AnnotationItem } from '@bookorbit/types' +import AnnotationListItem from '../AnnotationListItem.vue' + +const stubs = { + RouterLink: { template: '' }, + Tooltip: { template: '
' }, + TooltipTrigger: { template: '
' }, + TooltipContent: { template: '
' }, + AnnotationBookThumb: { template: '
' }, + AnnotationSyncDetailPanel: { template: '
' }, + HighlightNoteEditor: { + template: '
', + }, +} + +function makeAnnotation(overrides: Partial = {}): AnnotationItem { + return { + id: 1, + bookId: 5, + cfi: 'epubcfi(/6/4!/4/2/1:0)', + jumpFileId: 9, + pageno: 3, + text: 'a short highlight', + color: '#FACC15', + style: 'highlight', + note: 'my note', + chapterTitle: 'Chapter 1', + origin: 'web', + positionStatus: 'exact', + chapterIndex: 0, + createdAt: '2026-01-01T00:00:00.000Z', + ...overrides, + } +} + +function mountItem(props: Record = {}) { + return mount(AnnotationListItem, { props: { annotation: makeAnnotation(), ...props }, global: { stubs } }) +} + +function iconButton(wrapper: VueWrapper, iconClass: string) { + return wrapper.findAll('button').find((button) => button.find(`.${iconClass}`).exists()) +} + +describe('AnnotationListItem', () => { + it('renders the quote and the note', () => { + const wrapper = mountItem() + expect(wrapper.text()).toContain('a short highlight') + expect(wrapper.text()).toContain('my note') + }) + + it('emits toggleSelect when the checkbox changes', async () => { + const wrapper = mountItem() + await wrapper.get('input[type="checkbox"]').setValue(true) + expect(wrapper.emitted('toggleSelect')!.slice(-1)[0]).toEqual([1]) + }) + + it('requires a confirm step before trashing in book mode', async () => { + const wrapper = mountItem({ mode: 'book' }) + await iconButton(wrapper, 'lucide-trash-2')!.trigger('click') + expect(wrapper.emitted('trash')).toBeFalsy() + + const confirm = wrapper.findAll('button').find((button) => button.text() === 'Trash') + await confirm!.trigger('click') + expect(wrapper.emitted('trash')!.slice(-1)[0]).toEqual([1]) + }) + + it('trashes immediately in hub mode', async () => { + const wrapper = mountItem({ mode: 'hub' }) + await iconButton(wrapper, 'lucide-trash-2')!.trigger('click') + expect(wrapper.emitted('trash')!.slice(-1)[0]).toEqual([1]) + }) + + it('exposes restore and purge actions when trashed', async () => { + const wrapper = mountItem({ trashed: true }) + await iconButton(wrapper, 'lucide-archive-restore')!.trigger('click') + expect(wrapper.emitted('restore')!.slice(-1)[0]).toEqual([1]) + await iconButton(wrapper, 'lucide-trash-2')!.trigger('click') + expect(wrapper.emitted('purge')!.slice(-1)[0]).toEqual([1]) + }) + + it('emits jump when a reader location is available', async () => { + const wrapper = mountItem({ mode: 'hub' }) + await iconButton(wrapper, 'lucide-book-open')!.trigger('click') + expect(wrapper.emitted('jump')).toBeTruthy() + }) + + it('emits updateColor from the style panel only when the color differs', async () => { + const wrapper = mountItem() + await iconButton(wrapper, 'lucide-palette')!.trigger('click') + await wrapper.get('button[title="Green"]').trigger('click') + expect(wrapper.emitted('updateColor')!.slice(-1)[0]).toEqual([1, '#4ADE80']) + }) + + it('emits updateNote when the note editor saves', async () => { + const wrapper = mountItem() + await iconButton(wrapper, 'lucide-file-pen')!.trigger('click') + await wrapper.get('.note-editor .save').trigger('click') + expect(wrapper.emitted('updateNote')!.slice(-1)[0]).toEqual([1, 'edited']) + }) + + it('copies the quote text to the clipboard', async () => { + const writeText = vi.fn<(text: string) => Promise>().mockResolvedValue(undefined) + Object.assign(navigator, { clipboard: { writeText } }) + const wrapper = mountItem() + await iconButton(wrapper, 'lucide-copy')!.trigger('click') + expect(writeText).toHaveBeenCalledWith('a short highlight') + }) + + it('toggles expansion for long quotes', async () => { + const wrapper = mountItem({ annotation: makeAnnotation({ text: 'x'.repeat(300) }) }) + const expand = wrapper.findAll('button').find((button) => button.text().includes('Expand')) + expect(expand).toBeTruthy() + await expand!.trigger('click') + expect(wrapper.findAll('button').some((button) => button.text().includes('Collapse'))).toBe(true) + }) +}) diff --git a/client/src/features/annotations/components/__tests__/AnnotationPagination.spec.ts b/client/src/features/annotations/components/__tests__/AnnotationPagination.spec.ts new file mode 100644 index 000000000..877cafb42 --- /dev/null +++ b/client/src/features/annotations/components/__tests__/AnnotationPagination.spec.ts @@ -0,0 +1,50 @@ +import { mount } from '@vue/test-utils' +import { describe, expect, it } from 'vitest' +import AnnotationPagination from '../AnnotationPagination.vue' + +const stubs = { Button: { template: '' } } + +function mountPagination(props: Record = {}) { + return mount(AnnotationPagination, { + props: { page: 2, totalPages: 5, rangeStart: 26, rangeEnd: 50, total: 120, ...props }, + global: { stubs }, + }) +} + +describe('AnnotationPagination', () => { + it('renders the range summary with an optional unit', () => { + expect(mountPagination().text()).toContain('Showing 26-50 of 120') + expect(mountPagination({ unit: 'highlights' }).text()).toContain('of 120 highlights') + }) + + it('hides the navigation when there is a single page', () => { + const wrapper = mountPagination({ totalPages: 1, page: 1 }) + expect(wrapper.find('[aria-label="Next page"]').exists()).toBe(false) + }) + + it('emits the next and previous pages', async () => { + const wrapper = mountPagination({ page: 2, totalPages: 5 }) + await wrapper.get('[aria-label="Next page"]').trigger('click') + await wrapper.get('[aria-label="Previous page"]').trigger('click') + expect(wrapper.emitted('update:page')).toEqual([[3], [1]]) + }) + + it('jumps to the first and last pages', async () => { + const wrapper = mountPagination({ page: 3, totalPages: 5 }) + await wrapper.get('[aria-label="First page"]').trigger('click') + await wrapper.get('[aria-label="Last page"]').trigger('click') + expect(wrapper.emitted('update:page')).toEqual([[1], [5]]) + }) + + it('disables the edges and never emits past the bounds', async () => { + const first = mountPagination({ page: 1, totalPages: 3 }) + expect(first.get('[aria-label="Previous page"]').attributes('disabled')).toBeDefined() + await first.get('[aria-label="Previous page"]').trigger('click') + expect(first.emitted('update:page')).toBeUndefined() + + const last = mountPagination({ page: 3, totalPages: 3 }) + expect(last.get('[aria-label="Next page"]').attributes('disabled')).toBeDefined() + await last.get('[aria-label="Next page"]').trigger('click') + expect(last.emitted('update:page')).toBeUndefined() + }) +}) diff --git a/client/src/features/annotations/components/__tests__/AnnotationSummaryBar.spec.ts b/client/src/features/annotations/components/__tests__/AnnotationSummaryBar.spec.ts new file mode 100644 index 000000000..b976c0c18 --- /dev/null +++ b/client/src/features/annotations/components/__tests__/AnnotationSummaryBar.spec.ts @@ -0,0 +1,40 @@ +import { mount } from '@vue/test-utils' +import { describe, expect, it } from 'vitest' +import AnnotationSummaryBar from '../AnnotationSummaryBar.vue' + +const origins = [ + { origin: 'web', label: 'Web', class: 'web-class', count: 3 }, + { origin: 'koreader', label: 'KOReader', class: 'ko-class', count: 5 }, +] + +describe('AnnotationSummaryBar', () => { + it('renders text summaries and origin counts', () => { + const wrapper = mount(AnnotationSummaryBar, { props: { texts: ['12 highlights', '4 notes'], origins } }) + expect(wrapper.text()).toContain('12 highlights') + expect(wrapper.text()).toContain('Web 3') + expect(wrapper.text()).toContain('KOReader 5') + }) + + it('is non-interactive when no activeOrigin is provided', () => { + const wrapper = mount(AnnotationSummaryBar, { props: { texts: [], origins } }) + expect(wrapper.findAll('button')).toHaveLength(0) + }) + + it('emits originClick and marks the active origin when interactive', async () => { + const wrapper = mount(AnnotationSummaryBar, { props: { texts: [], origins, activeOrigin: 'web' } }) + const buttons = wrapper.findAll('button') + expect(buttons).toHaveLength(2) + expect(buttons[0]!.attributes('aria-pressed')).toBe('true') + expect(buttons[1]!.attributes('aria-pressed')).toBe('false') + + await buttons[1]!.trigger('click') + expect(wrapper.emitted('originClick')!.slice(-1)[0]).toEqual(['koreader']) + }) + + it('treats a null activeOrigin as interactive with none pressed', () => { + const wrapper = mount(AnnotationSummaryBar, { props: { texts: [], origins, activeOrigin: null } }) + const buttons = wrapper.findAll('button') + expect(buttons).toHaveLength(2) + expect(buttons.every((button) => button.attributes('aria-pressed') === 'false')).toBe(true) + }) +}) diff --git a/client/src/features/annotations/components/__tests__/AnnotationToolbar.spec.ts b/client/src/features/annotations/components/__tests__/AnnotationToolbar.spec.ts new file mode 100644 index 000000000..0a90bacec --- /dev/null +++ b/client/src/features/annotations/components/__tests__/AnnotationToolbar.spec.ts @@ -0,0 +1,68 @@ +import { mount } from '@vue/test-utils' +import { describe, expect, it } from 'vitest' +import AnnotationToolbar from '../AnnotationToolbar.vue' + +const stubs = { + Popover: { template: '
' }, + PopoverTrigger: { template: '
' }, + PopoverContent: { template: '
' }, + Sheet: { template: '
' }, + SheetTrigger: { template: '
' }, + SheetContent: { template: '
' }, + SheetHeader: { template: '
' }, + SheetTitle: { template: '
' }, + Badge: { template: '' }, + Button: { template: '' }, +} + +function mountToolbar(props: Record = {}) { + return mount(AnnotationToolbar, { + props: { + sortOptions: [ + { value: 'newest', label: 'Newest first' }, + { value: 'oldest', label: 'Oldest first' }, + ], + filterCount: 0, + chips: [], + search: '', + sortKey: 'newest', + density: 'comfortable', + ...props, + }, + global: { stubs }, + }) +} + +describe('AnnotationToolbar', () => { + it('emits search updates', async () => { + const wrapper = mountToolbar() + await wrapper.get('input[type="search"]').setValue('dune') + expect(wrapper.emitted('update:search')!.slice(-1)[0]).toEqual(['dune']) + }) + + it('emits sort-key updates', async () => { + const wrapper = mountToolbar() + await wrapper.get('select').setValue('oldest') + expect(wrapper.emitted('update:sortKey')!.slice(-1)[0]).toEqual(['oldest']) + }) + + it('toggles density from comfortable to compact', async () => { + const wrapper = mountToolbar({ density: 'comfortable' }) + await wrapper.get('button[aria-label="Switch to compact view"]').trigger('click') + expect(wrapper.emitted('update:density')!.slice(-1)[0]).toEqual(['compact']) + }) + + it('shows the active filter count', () => { + expect(mountToolbar({ filterCount: 3 }).text()).toContain('3') + }) + + it('re-emits chip removal and clear-all from the chip row', async () => { + const wrapper = mountToolbar({ chips: [{ id: 'style', label: 'Style: Underline' }] }) + await wrapper.get('[aria-label="Remove filter Style: Underline"]').trigger('click') + expect(wrapper.emitted('removeChip')!.slice(-1)[0]).toEqual(['style']) + + const clearAll = wrapper.findAll('button').find((button) => button.text() === 'Clear all') + await clearAll!.trigger('click') + expect(wrapper.emitted('clearFilters')).toBeTruthy() + }) +}) diff --git a/client/src/features/annotations/composables/__tests__/useAnnotationMutations.spec.ts b/client/src/features/annotations/composables/__tests__/useAnnotationMutations.spec.ts new file mode 100644 index 000000000..065a01efc --- /dev/null +++ b/client/src/features/annotations/composables/__tests__/useAnnotationMutations.spec.ts @@ -0,0 +1,73 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { nextTick, ref } from 'vue' + +const apiMock = vi.hoisted(() => vi.fn<(input: RequestInfo | URL, init?: RequestInit) => Promise>()) + +vi.mock('@/lib/api', () => ({ + api: apiMock, +})) + +import { useAnnotationMutations } from '../useAnnotationMutations' + +function makeResponse(ok = true): Response { + return { ok, status: ok ? 200 : 500, json: async () => ({}) } as Response +} + +describe('useAnnotationMutations', () => { + beforeEach(() => { + apiMock.mockReset() + }) + + it('optimistically patches the item and PATCHes the resolved book endpoint', async () => { + apiMock.mockResolvedValue(makeResponse()) + const items = ref([{ id: 1, bookId: 7, note: 'old' }]) + const mutations = useAnnotationMutations(items, () => 7) + + await mutations.updateNote(1, 'new') + + expect(items.value[0]!.note).toBe('new') + const [url, req] = apiMock.mock.calls[0] as [string, RequestInit] + expect(url).toBe('/api/v1/books/7/annotations/1') + expect(req.method).toBe('PATCH') + expect(JSON.parse(String(req.body))).toEqual({ note: 'new' }) + expect(mutations.savingIds.value.has(1)).toBe(false) + }) + + it('reverts the optimistic change on failure', async () => { + apiMock.mockResolvedValue(makeResponse(false)) + const items = ref([{ id: 1, bookId: 7, color: '#000000' }]) + const mutations = useAnnotationMutations(items, () => 7) + + await mutations.updateColor(1, '#ffffff') + + expect(items.value[0]!.color).toBe('#000000') + }) + + it('does nothing when the book id cannot be resolved', async () => { + const items = ref([{ id: 1, bookId: 7, style: 'highlight' }]) + const mutations = useAnnotationMutations(items, () => null) + + await mutations.updateStyle(1, 'underline') + + expect(apiMock).not.toHaveBeenCalled() + expect(items.value[0]!.style).toBe('highlight') + }) + + it('prunes saving ids whose item leaves the list', async () => { + const items = ref([ + { id: 1, bookId: 7 }, + { id: 2, bookId: 7 }, + ]) + const mutations = useAnnotationMutations(items, () => 7) + + mutations.setSaving(1, true) + mutations.setSaving(2, true) + expect(mutations.savingIds.value.size).toBe(2) + + items.value = [{ id: 2, bookId: 7 }] + await nextTick() + + expect(mutations.savingIds.value.has(1)).toBe(false) + expect(mutations.savingIds.value.size).toBe(1) + }) +}) diff --git a/client/src/features/annotations/composables/__tests__/useAnnotationSelection.spec.ts b/client/src/features/annotations/composables/__tests__/useAnnotationSelection.spec.ts new file mode 100644 index 000000000..8f5f35804 --- /dev/null +++ b/client/src/features/annotations/composables/__tests__/useAnnotationSelection.spec.ts @@ -0,0 +1,49 @@ +import { describe, it, expect } from 'vitest' +import { nextTick, ref } from 'vue' +import { useAnnotationSelection } from '../useAnnotationSelection' + +describe('useAnnotationSelection', () => { + it('toggles, selects the page, clears, and reports state', () => { + const items = ref([{ id: 1 }, { id: 2 }, { id: 3 }]) + const selection = useAnnotationSelection(items) + + expect(selection.hasSelection.value).toBe(false) + + selection.toggleSelected(1) + expect(selection.selectedIds.value.has(1)).toBe(true) + expect(selection.hasSelection.value).toBe(true) + + selection.toggleSelected(1) + expect(selection.selectedIds.value.has(1)).toBe(false) + + selection.selectAllOnPage() + expect(selection.allVisibleSelected.value).toBe(true) + expect(selection.selectedItems.value).toHaveLength(3) + + selection.clearSelection() + expect(selection.hasSelection.value).toBe(false) + }) + + it('prunes selected ids that leave the list', async () => { + const items = ref([{ id: 1 }, { id: 2 }]) + const selection = useAnnotationSelection(items) + + selection.selectAllOnPage() + expect(selection.selectedIds.value.size).toBe(2) + + items.value = [{ id: 1 }] + await nextTick() + + expect(selection.selectedIds.value.has(2)).toBe(false) + expect(selection.selectedIds.value.size).toBe(1) + }) + + it('reports allVisibleSelected as false for an empty list', () => { + const items = ref<{ id: number }[]>([]) + const selection = useAnnotationSelection(items) + + expect(selection.allVisibleSelected.value).toBe(false) + selection.selectAllOnPage() + expect(selection.hasSelection.value).toBe(false) + }) +}) diff --git a/client/src/features/annotations/composables/__tests__/useAnnotationsHub.spec.ts b/client/src/features/annotations/composables/__tests__/useAnnotationsHub.spec.ts new file mode 100644 index 000000000..a8968cfdb --- /dev/null +++ b/client/src/features/annotations/composables/__tests__/useAnnotationsHub.spec.ts @@ -0,0 +1,516 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' + +const apiMock = vi.hoisted(() => vi.fn<(input: RequestInfo | URL, init?: RequestInit) => Promise>()) + +vi.mock('@/lib/api', () => ({ + api: apiMock, +})) + +function makeResponse(data: unknown, options: { ok?: boolean; status?: number } = {}): Response { + const { ok = true, status = ok ? 200 : 500 } = options + return { + ok, + status, + json: async () => data, + } as Response +} + +const emptyHub = { items: [], total: 0, page: 1, pageSize: 25, stats: { books: 0, withNotes: 0, originBreakdown: [] } } + +function paramsFromUrl(url: string): URLSearchParams { + return new URLSearchParams(url.split('?')[1] ?? '') +} + +describe('useAnnotationsHub', () => { + beforeEach(() => { + // Fake only the timer functions so the search-debounce setTimeout can never + // leak a real timer into a later test (which would call the mock mid-reset). + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'setInterval', 'clearInterval'] }) + vi.resetModules() + apiMock.mockReset() + // Safe default so any watcher-triggered reload resolves cleanly. + apiMock.mockResolvedValue(makeResponse(emptyHub)) + }) + + afterEach(() => { + if (vi.isFakeTimers()) { + vi.clearAllTimers() + vi.useRealTimers() + } + }) + + describe('query building', () => { + it('builds the query with the date range as local-day-boundary ISO and the notes-only flag', async () => { + const { useAnnotationsHub } = await import('../useAnnotationsHub') + const hub = useAnnotationsHub() + + hub.dateFrom.value = '2026-01-10' + hub.dateTo.value = '2026-01-12' + hub.notesOnly.value = true + + const params = paramsFromUrl(hub.exportUrl('json')) + expect(params.get('dateFrom')).toBe(new Date('2026-01-10T00:00:00.000').toISOString()) + expect(params.get('dateTo')).toBe(new Date('2026-01-12T23:59:59.999').toISOString()) + expect(params.get('hasNote')).toBe('true') + expect(params.get('format')).toBe('json') + }) + + it('omits the date range and notes-only params when they are unset', async () => { + const { useAnnotationsHub } = await import('../useAnnotationsHub') + const hub = useAnnotationsHub() + + const params = paramsFromUrl(hub.exportUrl('md')) + expect(params.has('dateFrom')).toBe(false) + expect(params.has('dateTo')).toBe(false) + expect(params.has('hasNote')).toBe(false) + expect(params.get('status')).toBe('active') + }) + + it('includes the search, book, color, style and origin filters when set', async () => { + const { useAnnotationsHub } = await import('../useAnnotationsHub') + const hub = useAnnotationsHub() + + hub.search.value = ' needle ' + hub.bookFilter.value = 5 + hub.colors.value = ['#FACC15', '#4ADE80'] + hub.styleFilter.value = 'underline' + hub.originFilter.value = 'koreader' + + const params = paramsFromUrl(hub.exportUrl('csv')) + expect(params.get('search')).toBe('needle') + expect(params.get('bookId')).toBe('5') + expect(params.get('colors')).toBe('#FACC15,#4ADE80') + expect(params.get('styles')).toBe('underline') + expect(params.get('origins')).toBe('koreader') + }) + + it('ignores an unparseable date value instead of sending an empty param', async () => { + const { useAnnotationsHub } = await import('../useAnnotationsHub') + const hub = useAnnotationsHub() + + hub.dateFrom.value = 'not-a-date' + + const params = paramsFromUrl(hub.exportUrl('csv')) + expect(params.has('dateFrom')).toBe(false) + }) + }) + + describe('toggles', () => { + it('toggleNotesOnly flips the flag and clearDates resets both dates', async () => { + const { useAnnotationsHub } = await import('../useAnnotationsHub') + const hub = useAnnotationsHub() + + expect(hub.notesOnly.value).toBe(false) + hub.toggleNotesOnly() + expect(hub.notesOnly.value).toBe(true) + + hub.dateFrom.value = '2026-01-01' + hub.dateTo.value = '2026-01-02' + hub.clearDates() + expect(hub.dateFrom.value).toBe('') + expect(hub.dateTo.value).toBe('') + }) + + it('manages selection state', async () => { + const { useAnnotationsHub } = await import('../useAnnotationsHub') + const hub = useAnnotationsHub() + hub.items.value = [{ id: 1 }, { id: 2 }, { id: 3 }] as never + + hub.toggleSelected(1) + hub.toggleSelected(2) + expect(hub.hasSelection.value).toBe(true) + expect(hub.selectedIds.value.has(1)).toBe(true) + + hub.toggleSelected(1) + expect(hub.selectedIds.value.has(1)).toBe(false) + + hub.selectAllOnPage() + expect(hub.selectedIds.value.size).toBe(3) + + hub.clearSelection() + expect(hub.selectedIds.value.size).toBe(0) + }) + + it('computes pagination ranges', async () => { + const { useAnnotationsHub } = await import('../useAnnotationsHub') + const hub = useAnnotationsHub() + + hub.total.value = 0 + expect(hub.rangeStart.value).toBe(0) + + hub.total.value = 60 + hub.pageSize.value = 25 + hub.page.value = 2 + expect(hub.totalPages.value).toBe(3) + expect(hub.rangeStart.value).toBe(26) + expect(hub.rangeEnd.value).toBe(50) + }) + }) + + describe('requests', () => { + it('load fetches the hub list and populates items, total and stats', async () => { + const body = { + items: [{ id: 1, bookId: 5, note: 'n' }], + total: 1, + page: 1, + pageSize: 25, + stats: { books: 1, withNotes: 1, originBreakdown: [{ origin: 'web', count: 1 }] }, + } + apiMock.mockResolvedValueOnce(makeResponse(body)) + + const { useAnnotationsHub } = await import('../useAnnotationsHub') + const hub = useAnnotationsHub() + + await hub.load() + + const url = String(apiMock.mock.calls[0][0]) + expect(url.startsWith('/api/v1/annotations?')).toBe(true) + expect(hub.items.value).toEqual(body.items) + expect(hub.total.value).toBe(1) + expect(hub.stats.value).toEqual(body.stats) + }) + + it('load surfaces an error message when the request fails', async () => { + apiMock.mockResolvedValueOnce(makeResponse({}, { ok: false, status: 500 })) + + const { useAnnotationsHub } = await import('../useAnnotationsHub') + const hub = useAnnotationsHub() + + await hub.load() + + expect(hub.error.value).toBe('Failed to load annotations') + }) + + it('searchBooks queries the books facet endpoint with the trimmed term', async () => { + apiMock.mockResolvedValueOnce(makeResponse([{ bookId: 5, bookTitle: 'B', author: 'A', count: 3 }])) + + const { useAnnotationsHub } = await import('../useAnnotationsHub') + const hub = useAnnotationsHub() + + const facets = await hub.searchBooks(' dune ') + + const url = String(apiMock.mock.calls[0][0]) + expect(url).toContain('/api/v1/annotations/books?') + expect(url).toContain('status=active') + expect(url).toContain('q=dune') + expect(facets).toEqual([{ bookId: 5, bookTitle: 'B', author: 'A', count: 3 }]) + }) + + it('resolveSelectedBook fills the label for the selected book id', async () => { + apiMock.mockResolvedValueOnce(makeResponse([{ bookId: 7, bookTitle: 'Dune', author: 'FH', count: 9 }])) + + const { useAnnotationsHub } = await import('../useAnnotationsHub') + const hub = useAnnotationsHub() + hub.hydrate({ bookFilter: 7 }) + + await hub.resolveSelectedBook() + + const url = String(apiMock.mock.calls[0][0]) + expect(url).toContain('selectedId=7') + expect(hub.selectedBookLabel.value).toBe('Dune') + }) + + it('resolveSelectedBook clears the label when no book is selected', async () => { + const { useAnnotationsHub } = await import('../useAnnotationsHub') + const hub = useAnnotationsHub() + + await hub.resolveSelectedBook() + + expect(apiMock).not.toHaveBeenCalled() + expect(hub.selectedBookLabel.value).toBeNull() + }) + + it('bulk returns 0 and skips the request when nothing is selected', async () => { + const { useAnnotationsHub } = await import('../useAnnotationsHub') + const hub = useAnnotationsHub() + + expect(await hub.bulk('trash')).toBe(0) + expect(apiMock).not.toHaveBeenCalled() + }) + + it('bulk posts the action and returns the affected count', async () => { + apiMock.mockResolvedValueOnce(makeResponse({ affected: 2 })) + + const { useAnnotationsHub } = await import('../useAnnotationsHub') + const hub = useAnnotationsHub() + hub.selectedIds.value = new Set([1, 2]) + + const affected = await hub.bulk('restyle', { color: '#FACC15' }) + + expect(affected).toBe(2) + const [url, req] = apiMock.mock.calls[0] as [string, RequestInit] + expect(url).toBe('/api/v1/annotations/bulk') + expect(req.method).toBe('POST') + expect(JSON.parse(String(req.body))).toEqual({ ids: [1, 2], action: 'restyle', color: '#FACC15' }) + expect(hub.selectedIds.value.size).toBe(0) + }) + + it('restore posts and resolves true on success', async () => { + apiMock.mockResolvedValueOnce(makeResponse({})) + + const { useAnnotationsHub } = await import('../useAnnotationsHub') + const hub = useAnnotationsHub() + + const ok = await hub.restore(7) + + expect(ok).toBe(true) + expect(apiMock).toHaveBeenCalledWith('/api/v1/annotations/7/restore', { method: 'POST' }) + }) + + it('purge deletes and resolves ok on success', async () => { + apiMock.mockResolvedValueOnce(makeResponse({}, { ok: true, status: 204 })) + + const { useAnnotationsHub } = await import('../useAnnotationsHub') + const hub = useAnnotationsHub() + + const result = await hub.purge(7) + + expect(result.ok).toBe(true) + expect(apiMock).toHaveBeenCalledWith('/api/v1/annotations/7', { method: 'DELETE' }) + }) + + it('purge surfaces the conflict message on 409', async () => { + apiMock.mockResolvedValueOnce(makeResponse({ message: 'Still queued' }, { ok: false, status: 409 })) + + const { useAnnotationsHub } = await import('../useAnnotationsHub') + const hub = useAnnotationsHub() + + const result = await hub.purge(7) + + expect(result).toEqual({ ok: false, message: 'Still queued' }) + }) + + it('purge returns a generic failure on other errors', async () => { + apiMock.mockResolvedValueOnce(makeResponse({}, { ok: false, status: 500 })) + + const { useAnnotationsHub } = await import('../useAnnotationsHub') + const hub = useAnnotationsHub() + + const result = await hub.purge(7) + + expect(result).toEqual({ ok: false, message: 'Failed to delete' }) + }) + }) + + describe('reactivity', () => { + it('reloads on filter changes and resets the book filter when the tab changes', async () => { + const { nextTick } = await import('vue') + const { useAnnotationsHub } = await import('../useAnnotationsHub') + const hub = useAnnotationsHub() + + hub.bookFilter.value = 5 + hub.status.value = 'trashed' + await nextTick() + expect(hub.bookFilter.value).toBe('all') + + apiMock.mockClear() + hub.page.value = 2 + await nextTick() + expect(apiMock).toHaveBeenCalled() + }) + }) + + describe('sort key', () => { + it('maps the merged sort key to sortBy/sortDir and back', async () => { + const { useAnnotationsHub } = await import('../useAnnotationsHub') + const hub = useAnnotationsHub() + + expect(hub.sortKey.value).toBe('newest') + + hub.sortKey.value = 'oldest' + expect(hub.sortBy.value).toBe('createdAt') + expect(hub.sortDir.value).toBe('asc') + + hub.sortKey.value = 'book-asc' + expect(hub.sortBy.value).toBe('book') + expect(hub.sortDir.value).toBe('asc') + + hub.sortBy.value = 'book' + hub.sortDir.value = 'desc' + expect(hub.sortKey.value).toBe('book-desc') + }) + }) + + describe('popover filters', () => { + it('builds chips, counts them, and clears individually or all at once', async () => { + const { useAnnotationsHub } = await import('../useAnnotationsHub') + const hub = useAnnotationsHub() + + expect(hub.popoverFilterCount.value).toBe(0) + expect(hub.activeFilterChips.value).toEqual([]) + + hub.colors.value = ['#FACC15'] + hub.styleFilter.value = 'underline' + hub.originFilter.value = 'koreader' + hub.dateFrom.value = '2026-01-10' + + expect(hub.popoverFilterCount.value).toBe(4) + expect(hub.activeFilterChips.value.map((chip) => chip.id)).toEqual(['color:#FACC15', 'style', 'origin', 'date']) + expect(hub.activeFilterChips.value[0].label).toBe('Color: Yellow') + expect(hub.activeFilterChips.value[3].label).toBe('Date: From 2026-01-10') + + hub.removeFilterChip('color:#FACC15') + expect(hub.colors.value).toEqual([]) + expect(hub.popoverFilterCount.value).toBe(3) + + hub.clearPopoverFilters() + expect(hub.popoverFilterCount.value).toBe(0) + expect(hub.styleFilter.value).toBe('all') + expect(hub.originFilter.value).toBe('all') + expect(hub.dateFrom.value).toBe('') + }) + + it('does not chip the book or notes-only filters', async () => { + const { useAnnotationsHub } = await import('../useAnnotationsHub') + const hub = useAnnotationsHub() + + hub.bookFilter.value = 5 + hub.notesOnly.value = true + + expect(hub.activeFilterChips.value).toEqual([]) + expect(hub.popoverFilterCount.value).toBe(0) + }) + }) + + describe('hydrate', () => { + it('restores state and keeps the hydrated page despite the filter watcher', async () => { + const { nextTick } = await import('vue') + const { useAnnotationsHub } = await import('../useAnnotationsHub') + const hub = useAnnotationsHub() + + hub.hydrate({ colors: ['#FACC15'], notesOnly: true, sortKey: 'oldest', page: 3 }) + + expect(hub.colors.value).toEqual(['#FACC15']) + expect(hub.notesOnly.value).toBe(true) + expect(hub.sortBy.value).toBe('createdAt') + expect(hub.sortDir.value).toBe('asc') + expect(hub.page.value).toBe(3) + + await nextTick() + expect(hub.page.value).toBe(3) + expect(hub.hydrating.value).toBe(false) + }) + }) + + describe('search debounce', () => { + it('defers the search-triggered reload until the debounce elapses', async () => { + vi.useFakeTimers() + try { + const { nextTick } = await import('vue') + const { useAnnotationsHub } = await import('../useAnnotationsHub') + const hub = useAnnotationsHub() + apiMock.mockClear() + + hub.search.value = 'dune' + await nextTick() + expect(apiMock).not.toHaveBeenCalled() + + vi.advanceTimersByTime(300) + expect(apiMock).toHaveBeenCalledTimes(1) + expect(String(apiMock.mock.calls[0][0])).toContain('search=dune') + } finally { + vi.useRealTimers() + } + }) + }) + + describe('filter reset', () => { + it('hasActiveFilters reflects active filters and resetAllFilters clears them', async () => { + const { useAnnotationsHub } = await import('../useAnnotationsHub') + const hub = useAnnotationsHub() + + expect(hub.hasActiveFilters.value).toBe(false) + + hub.colors.value = ['#FACC15'] + hub.bookFilter.value = 5 + hub.selectedBookLabel.value = 'Dune' + hub.notesOnly.value = true + hub.dateFrom.value = '2026-01-01' + expect(hub.hasActiveFilters.value).toBe(true) + + hub.resetAllFilters() + expect(hub.hasActiveFilters.value).toBe(false) + expect(hub.bookFilter.value).toBe('all') + expect(hub.selectedBookLabel.value).toBeNull() + expect(hub.notesOnly.value).toBe(false) + expect(hub.colors.value).toEqual([]) + expect(hub.dateFrom.value).toBe('') + }) + }) + + describe('updateAnnotation', () => { + it('optimistically patches the item and PATCHes the book-scoped endpoint', async () => { + apiMock.mockResolvedValueOnce(makeResponse({})) + + const { useAnnotationsHub } = await import('../useAnnotationsHub') + const hub = useAnnotationsHub() + hub.items.value = [{ id: 1, bookId: 5, note: 'old', color: '#FACC15' }] as never + + await hub.updateNote(1, 'new') + + const [url, req] = apiMock.mock.calls[0] as [string, RequestInit] + expect(url).toBe('/api/v1/books/5/annotations/1') + expect(req.method).toBe('PATCH') + expect(JSON.parse(String(req.body))).toEqual({ note: 'new' }) + expect(hub.items.value[0]!.note).toBe('new') + expect(hub.savingIds.value.has(1)).toBe(false) + }) + + it('reverts the optimistic change when the request fails', async () => { + apiMock.mockResolvedValueOnce(makeResponse({}, { ok: false, status: 500 })) + + const { useAnnotationsHub } = await import('../useAnnotationsHub') + const hub = useAnnotationsHub() + hub.items.value = [{ id: 1, bookId: 5, note: 'old' }] as never + + await hub.updateNote(1, 'new') + + expect(hub.items.value[0]!.note).toBe('old') + expect(hub.savingIds.value.has(1)).toBe(false) + }) + }) + + describe('load sequencing', () => { + it('reloads exactly once when a filter changes while on a later page', async () => { + const { nextTick } = await import('vue') + const { useAnnotationsHub } = await import('../useAnnotationsHub') + const hub = useAnnotationsHub() + + hub.page.value = 3 + await nextTick() + apiMock.mockClear() + + hub.colors.value = ['#FACC15'] + await nextTick() + + expect(hub.page.value).toBe(1) + expect(apiMock).toHaveBeenCalledTimes(1) + }) + + it('discards a stale load when a newer load has superseded it', async () => { + apiMock.mockReset() + let resolveStale!: (res: Response) => void + const stalePending = new Promise((resolve) => { + resolveStale = resolve + }) + const freshBody = { items: [{ id: 2 }], total: 2, page: 1, pageSize: 25, stats: emptyHub.stats } + apiMock.mockReturnValueOnce(stalePending).mockResolvedValueOnce(makeResponse(freshBody)) + + const { useAnnotationsHub } = await import('../useAnnotationsHub') + const hub = useAnnotationsHub() + + const stale = hub.load() + const fresh = hub.load() + await fresh + + expect(hub.total.value).toBe(2) + expect(hub.loading.value).toBe(false) + + resolveStale(makeResponse({ items: [{ id: 1 }], total: 1, page: 1, pageSize: 25, stats: emptyHub.stats })) + await stale + + expect(hub.total.value).toBe(2) + expect(hub.loading.value).toBe(false) + }) + }) +}) diff --git a/client/src/features/annotations/composables/__tests__/useAnnotationsUrlSync.spec.ts b/client/src/features/annotations/composables/__tests__/useAnnotationsUrlSync.spec.ts new file mode 100644 index 000000000..11315d393 --- /dev/null +++ b/client/src/features/annotations/composables/__tests__/useAnnotationsUrlSync.spec.ts @@ -0,0 +1,109 @@ +import { describe, it, expect } from 'vitest' +import type { LocationQuery } from 'vue-router' +import { annotationsQueryFromState, annotationsStateFromQuery } from '../useAnnotationsUrlSync' +import type { AnnotationsHubState } from '../useAnnotationsHub' + +const DEFAULT_STATE: AnnotationsHubState = { + status: 'active', + search: '', + bookFilter: 'all', + colors: [], + styleFilter: 'all', + originFilter: 'all', + notesOnly: false, + dateFrom: '', + dateTo: '', + sortKey: 'newest', + page: 1, +} + +describe('annotationsStateFromQuery', () => { + it('parses a full query into hub state', () => { + const state = annotationsStateFromQuery({ + status: 'trashed', + search: 'dune', + bookId: '42', + colors: '#FACC15,#4ADE80', + style: 'underline', + origin: 'koreader', + notes: '1', + from: '2026-01-10', + to: '2026-01-12', + sort: 'oldest', + page: '3', + }) + + expect(state).toEqual({ + status: 'trashed', + search: 'dune', + bookFilter: 42, + colors: ['#FACC15', '#4ADE80'], + styleFilter: 'underline', + originFilter: 'koreader', + notesOnly: true, + dateFrom: '2026-01-10', + dateTo: '2026-01-12', + sortKey: 'oldest', + page: 3, + }) + }) + + it('drops defaults, malformed numbers, and unknown sort keys', () => { + expect(annotationsStateFromQuery({ status: 'active', bookId: 'abc', sort: 'bogus', page: '1', notes: '0' })).toEqual({}) + expect(annotationsStateFromQuery({ page: '0', bookId: '-2' })).toEqual({}) + expect(annotationsStateFromQuery({})).toEqual({}) + }) + + it('takes the first value when a param repeats', () => { + expect(annotationsStateFromQuery({ search: ['needle', 'other'] }).search).toBe('needle') + }) +}) + +describe('annotationsQueryFromState', () => { + it('omits every default value', () => { + expect(annotationsQueryFromState(DEFAULT_STATE)).toEqual({}) + }) + + it('serializes only non-default fields and trims search', () => { + expect( + annotationsQueryFromState({ + ...DEFAULT_STATE, + status: 'trashed', + search: ' dune ', + bookFilter: 42, + colors: ['#FACC15'], + notesOnly: true, + sortKey: 'book-asc', + page: 2, + }), + ).toEqual({ + status: 'trashed', + search: 'dune', + bookId: '42', + colors: '#FACC15', + notes: '1', + sort: 'book-asc', + page: '2', + }) + }) + + it('round-trips through the query string', () => { + const state: AnnotationsHubState = { + ...DEFAULT_STATE, + originFilter: 'web', + styleFilter: 'invert', + dateFrom: '2026-01-01', + dateTo: '2026-02-01', + sortKey: 'book-desc', + page: 5, + } + expect(annotationsStateFromQuery(annotationsQueryFromState(state) as LocationQuery)).toEqual({ + originFilter: 'web', + styleFilter: 'invert', + dateFrom: '2026-01-01', + dateTo: '2026-02-01', + sortKey: 'book-desc', + page: 5, + }) + }) +}) diff --git a/client/src/features/annotations/composables/__tests__/useAnnotationsUrlSync.wiring.spec.ts b/client/src/features/annotations/composables/__tests__/useAnnotationsUrlSync.wiring.spec.ts new file mode 100644 index 000000000..d762151da --- /dev/null +++ b/client/src/features/annotations/composables/__tests__/useAnnotationsUrlSync.wiring.spec.ts @@ -0,0 +1,73 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { nextTick } from 'vue' + +const replaceMock = vi.hoisted(() => vi.fn<(to: unknown) => void>()) +const routeStub = vi.hoisted(() => ({ query: {} as Record })) +const apiMock = vi.hoisted(() => vi.fn<(input: RequestInfo | URL, init?: RequestInit) => Promise>()) + +vi.mock('vue-router', () => ({ + useRoute: () => routeStub, + useRouter: () => ({ replace: replaceMock }), +})) +vi.mock('@/lib/api', () => ({ api: apiMock })) + +import { useAnnotationsUrlSync } from '../useAnnotationsUrlSync' +import { useAnnotationsHub } from '../useAnnotationsHub' + +function hubResponse(): Response { + return { + ok: true, + status: 200, + json: async () => ({ items: [], total: 0, page: 1, pageSize: 25, stats: { books: 0, withNotes: 0, originBreakdown: [] } }), + } as Response +} + +describe('useAnnotationsUrlSync wiring', () => { + beforeEach(() => { + replaceMock.mockReset() + routeStub.query = {} + apiMock.mockReset() + apiMock.mockResolvedValue(hubResponse()) + }) + + it('hydrates the hub from the query and does not write while hydrating', async () => { + routeStub.query = { status: 'trashed', page: '2' } + const hub = useAnnotationsHub() + useAnnotationsUrlSync(hub) + + expect(replaceMock).not.toHaveBeenCalled() + await nextTick() + expect(hub.status.value).toBe('trashed') + expect(hub.page.value).toBe(2) + }) + + it('mirrors a later filter change into the URL via router.replace', async () => { + const hub = useAnnotationsHub() + useAnnotationsUrlSync(hub) + await nextTick() + + hub.status.value = 'trashed' + await nextTick() + + expect(replaceMock.mock.calls.slice(-1)[0]![0]).toEqual({ query: { status: 'trashed' } }) + }) + + it('debounces search writes', async () => { + vi.useFakeTimers() + try { + const hub = useAnnotationsHub() + useAnnotationsUrlSync(hub) + await nextTick() + replaceMock.mockClear() + + hub.search.value = 'dune' + await nextTick() + expect(replaceMock).not.toHaveBeenCalled() + + vi.advanceTimersByTime(300) + expect(replaceMock).toHaveBeenCalledWith({ query: { search: 'dune' } }) + } finally { + vi.useRealTimers() + } + }) +}) diff --git a/client/src/features/annotations/composables/__tests__/useDensity.spec.ts b/client/src/features/annotations/composables/__tests__/useDensity.spec.ts new file mode 100644 index 000000000..bce9a9917 --- /dev/null +++ b/client/src/features/annotations/composables/__tests__/useDensity.spec.ts @@ -0,0 +1,35 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { nextTick } from 'vue' +import { useDensity } from '../useDensity' + +const STORAGE_KEY = 'annotations:density' + +describe('useDensity', () => { + beforeEach(() => { + localStorage.clear() + }) + + it('defaults to comfortable when nothing is stored', () => { + const { density } = useDensity() + expect(density.value).toBe('comfortable') + }) + + it('restores a valid stored value', () => { + localStorage.setItem(STORAGE_KEY, 'compact') + const { density } = useDensity() + expect(density.value).toBe('compact') + }) + + it('falls back to comfortable for an unrecognized stored value', () => { + localStorage.setItem(STORAGE_KEY, 'cozy') + const { density } = useDensity() + expect(density.value).toBe('comfortable') + }) + + it('persists changes back to localStorage', async () => { + const { density } = useDensity() + density.value = 'compact' + await nextTick() + expect(localStorage.getItem(STORAGE_KEY)).toBe('compact') + }) +}) diff --git a/client/src/features/annotations/composables/useAnnotationMutations.ts b/client/src/features/annotations/composables/useAnnotationMutations.ts new file mode 100644 index 000000000..1b7703790 --- /dev/null +++ b/client/src/features/annotations/composables/useAnnotationMutations.ts @@ -0,0 +1,66 @@ +import { ref, watch, type Ref } from 'vue' +import { api } from '@/lib/api' + +export interface AnnotationPatch { + note?: string | null + color?: string + style?: string +} + +type Editable = { id: number; bookId: number; note?: string | null; color?: string; style?: string } + +/** + * Shared per-item note/color/style editing for an annotation list: optimistic local patch, + * book-scoped PATCH, revert on failure, and per-id saving state. `resolveBookId` lets the hub + * look the book up per item while a single-book view returns a constant. + */ +export function useAnnotationMutations(items: Ref, resolveBookId: (id: number) => number | null) { + const savingIds = ref>(new Set()) + + function setSaving(id: number, saving: boolean) { + const next = new Set(savingIds.value) + if (saving) next.add(id) + else next.delete(id) + savingIds.value = next + } + + async function patch(id: number, body: AnnotationPatch) { + const bookId = resolveBookId(id) + if (bookId == null) return + const previous = items.value + items.value = items.value.map((item) => (item.id === id ? { ...item, ...body } : item)) + setSaving(id, true) + try { + const res = await api(`/api/v1/books/${bookId}/annotations/${id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + if (!res.ok) items.value = previous + } catch { + items.value = previous + } finally { + setSaving(id, false) + } + } + + function updateNote(id: number, note: string | null) { + return patch(id, { note }) + } + + function updateColor(id: number, color: string) { + return patch(id, { color }) + } + + function updateStyle(id: number, style: string) { + return patch(id, { style }) + } + + watch(items, (nextItems) => { + const currentIds = new Set(nextItems.map((item) => item.id)) + const pruned = new Set([...savingIds.value].filter((id) => currentIds.has(id))) + if (pruned.size !== savingIds.value.size) savingIds.value = pruned + }) + + return { savingIds, setSaving, updateNote, updateColor, updateStyle } +} diff --git a/client/src/features/annotations/composables/useAnnotationSelection.ts b/client/src/features/annotations/composables/useAnnotationSelection.ts new file mode 100644 index 000000000..7b2a41240 --- /dev/null +++ b/client/src/features/annotations/composables/useAnnotationSelection.ts @@ -0,0 +1,36 @@ +import { computed, ref, watch, type Ref } from 'vue' + +/** Shared bulk-selection state for an annotation list. Prunes ids that leave the list. */ +export function useAnnotationSelection(items: Ref) { + const selectedIds = ref>(new Set()) + + const visibleIds = computed(() => items.value.map((item) => item.id)) + const hasSelection = computed(() => selectedIds.value.size > 0) + const allVisibleSelected = computed(() => visibleIds.value.length > 0 && visibleIds.value.every((id) => selectedIds.value.has(id))) + const selectedItems = computed(() => items.value.filter((item) => selectedIds.value.has(item.id))) + + function toggleSelected(id: number) { + const next = new Set(selectedIds.value) + if (next.has(id)) next.delete(id) + else next.add(id) + selectedIds.value = next + } + + function selectAllOnPage() { + const next = new Set(selectedIds.value) + for (const id of visibleIds.value) next.add(id) + selectedIds.value = next + } + + function clearSelection() { + if (selectedIds.value.size > 0) selectedIds.value = new Set() + } + + watch(items, (nextItems) => { + const currentIds = new Set(nextItems.map((item) => item.id)) + const pruned = new Set([...selectedIds.value].filter((id) => currentIds.has(id))) + if (pruned.size !== selectedIds.value.size) selectedIds.value = pruned + }) + + return { selectedIds, hasSelection, allVisibleSelected, selectedItems, toggleSelected, selectAllOnPage, clearSelection } +} diff --git a/client/src/features/annotations/composables/useAnnotationSyncDetail.ts b/client/src/features/annotations/composables/useAnnotationSyncDetail.ts new file mode 100644 index 000000000..cf42f3036 --- /dev/null +++ b/client/src/features/annotations/composables/useAnnotationSyncDetail.ts @@ -0,0 +1,44 @@ +import { ref } from 'vue' +import { api } from '@/lib/api' +import type { AnnotationPositionFormat, AnnotationSyncDetail } from '@bookorbit/types' + +export function useAnnotationSyncDetail() { + const detail = ref(null) + const loading = ref(false) + const retrying = ref(null) + const error = ref(null) + + async function load(annotationId: number): Promise { + loading.value = true + error.value = null + try { + const res = await api(`/api/v1/annotations/${annotationId}/sync-detail`) + if (!res.ok) throw new Error('Failed to load sync detail') + detail.value = (await res.json()) as AnnotationSyncDetail + } catch (e) { + error.value = e instanceof Error ? e.message : 'Failed to load sync detail' + } finally { + loading.value = false + } + } + + async function retry(annotationId: number, format: AnnotationPositionFormat): Promise { + retrying.value = format + error.value = null + try { + const res = await api(`/api/v1/annotations/${annotationId}/positions/retry`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ format }), + }) + if (!res.ok) throw new Error('Failed to retry position conversion') + detail.value = (await res.json()) as AnnotationSyncDetail + } catch (e) { + error.value = e instanceof Error ? e.message : 'Failed to retry position conversion' + } finally { + retrying.value = null + } + } + + return { detail, loading, retrying, error, load, retry } +} diff --git a/client/src/features/annotations/composables/useAnnotationsHub.ts b/client/src/features/annotations/composables/useAnnotationsHub.ts new file mode 100644 index 000000000..5f1a3c1d4 --- /dev/null +++ b/client/src/features/annotations/composables/useAnnotationsHub.ts @@ -0,0 +1,356 @@ +import { computed, nextTick, ref, watch } from 'vue' +import type { AnnotationHubBookFacet, AnnotationHubItem, AnnotationHubResponse, AnnotationHubStats } from '@bookorbit/types' +import { api } from '@/lib/api' +import { type SortKey } from '../lib/filter-options' +import { buildFilterChips } from '../lib/filter-chips' +import { useAnnotationSelection } from './useAnnotationSelection' +import { useAnnotationMutations } from './useAnnotationMutations' + +export type HubStatus = 'active' | 'trashed' + +/** The slice of hub state that can be restored from the URL on load. */ +export interface AnnotationsHubState { + status: HubStatus + search: string + bookFilter: number | 'all' + colors: string[] + styleFilter: string + originFilter: string + notesOnly: boolean + dateFrom: string + dateTo: string + sortKey: SortKey + page: number +} + +const SEARCH_RELOAD_DEBOUNCE_MS = 300 + +/** + * Turns a yyyy-mm-dd value from an `` into a UTC ISO instant at the + * start or end of that calendar day in the user's local timezone, so a "to" date includes + * the whole day. Returns '' for empty or unparseable input. + */ +function toDayBoundaryIso(date: string, edge: 'start' | 'end'): string { + if (!date) return '' + const parsed = new Date(`${date}T${edge === 'start' ? '00:00:00.000' : '23:59:59.999'}`) + return Number.isNaN(parsed.getTime()) ? '' : parsed.toISOString() +} + +export function useAnnotationsHub() { + const items = ref([]) + const total = ref(0) + const page = ref(1) + const pageSize = ref(25) + const loading = ref(false) + const error = ref(null) + + const status = ref('active') + const search = ref('') + const colors = ref([]) + const styleFilter = ref('all') + const originFilter = ref('all') + const bookFilter = ref('all') + const sortBy = ref<'createdAt' | 'book'>('createdAt') + const sortDir = ref<'asc' | 'desc'>('desc') + const dateFrom = ref('') + const dateTo = ref('') + const notesOnly = ref(false) + const hydrating = ref(false) + + const selectedBookLabel = ref(null) + const stats = ref(null) + + const selection = useAnnotationSelection(items) + const mutations = useAnnotationMutations(items, (id) => items.value.find((item) => item.id === id)?.bookId ?? null) + + const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value))) + const rangeStart = computed(() => (total.value === 0 ? 0 : (page.value - 1) * pageSize.value + 1)) + const rangeEnd = computed(() => Math.min(page.value * pageSize.value, total.value)) + + const sortKey = computed({ + get() { + if (sortBy.value === 'book') return sortDir.value === 'asc' ? 'book-asc' : 'book-desc' + return sortDir.value === 'desc' ? 'newest' : 'oldest' + }, + set(value) { + switch (value) { + case 'newest': + sortBy.value = 'createdAt' + sortDir.value = 'desc' + break + case 'oldest': + sortBy.value = 'createdAt' + sortDir.value = 'asc' + break + case 'book-asc': + sortBy.value = 'book' + sortDir.value = 'asc' + break + case 'book-desc': + sortBy.value = 'book' + sortDir.value = 'desc' + break + } + }, + }) + + const activeFilterChips = computed(() => + buildFilterChips({ + colors: colors.value, + styleFilter: styleFilter.value, + originFilter: originFilter.value, + dateFrom: dateFrom.value, + dateTo: dateTo.value, + }), + ) + + const popoverFilterCount = computed(() => activeFilterChips.value.length) + + const hasActiveFilters = computed( + () => + search.value.trim() !== '' || + bookFilter.value !== 'all' || + notesOnly.value || + colors.value.length > 0 || + styleFilter.value !== 'all' || + originFilter.value !== 'all' || + dateFrom.value !== '' || + dateTo.value !== '', + ) + + function buildQuery(extra: Record = {}): string { + const params = new URLSearchParams() + params.set('page', String(page.value)) + params.set('pageSize', String(pageSize.value)) + params.set('status', status.value) + params.set('sortBy', sortBy.value) + params.set('sortDir', sortDir.value) + if (search.value.trim()) params.set('search', search.value.trim()) + if (colors.value.length > 0) params.set('colors', colors.value.join(',')) + if (styleFilter.value !== 'all') params.set('styles', styleFilter.value) + if (originFilter.value !== 'all') params.set('origins', originFilter.value) + if (bookFilter.value !== 'all') params.set('bookId', String(bookFilter.value)) + const from = toDayBoundaryIso(dateFrom.value, 'start') + if (from) params.set('dateFrom', from) + const to = toDayBoundaryIso(dateTo.value, 'end') + if (to) params.set('dateTo', to) + if (notesOnly.value) params.set('hasNote', 'true') + for (const [key, value] of Object.entries(extra)) params.set(key, value) + return params.toString() + } + + let loadSeq = 0 + async function load() { + const seq = ++loadSeq + loading.value = true + error.value = null + try { + const res = await api(`/api/v1/annotations?${buildQuery()}`) + if (seq !== loadSeq) return + if (!res.ok) { + error.value = 'Failed to load annotations' + return + } + const body: AnnotationHubResponse = await res.json() + if (seq !== loadSeq) return + items.value = body.items + total.value = body.total + stats.value = body.stats + } finally { + if (seq === loadSeq) loading.value = false + } + } + + async function searchBooks(q: string): Promise { + const params = new URLSearchParams({ status: status.value }) + if (q.trim()) params.set('q', q.trim()) + const res = await api(`/api/v1/annotations/books?${params.toString()}`) + if (!res.ok) return [] + return (await res.json()) as AnnotationHubBookFacet[] + } + + async function resolveSelectedBook() { + if (bookFilter.value === 'all') { + selectedBookLabel.value = null + return + } + if (selectedBookLabel.value) return + const params = new URLSearchParams({ status: status.value, selectedId: String(bookFilter.value) }) + const res = await api(`/api/v1/annotations/books?${params.toString()}`) + if (!res.ok) return + const facets = (await res.json()) as AnnotationHubBookFacet[] + const match = facets.find((facet) => facet.bookId === bookFilter.value) + if (match) selectedBookLabel.value = match.bookTitle ?? 'Unknown book' + } + + function toggleNotesOnly() { + notesOnly.value = !notesOnly.value + } + + function clearDates() { + dateFrom.value = '' + dateTo.value = '' + } + + function clearPopoverFilters() { + colors.value = [] + styleFilter.value = 'all' + originFilter.value = 'all' + clearDates() + } + + function resetAllFilters() { + search.value = '' + bookFilter.value = 'all' + selectedBookLabel.value = null + notesOnly.value = false + clearPopoverFilters() + } + + function removeFilterChip(id: string) { + if (id.startsWith('color:')) { + const hex = id.slice('color:'.length) + colors.value = colors.value.filter((value) => value !== hex) + return + } + if (id === 'style') styleFilter.value = 'all' + else if (id === 'origin') originFilter.value = 'all' + else if (id === 'date') clearDates() + } + + function hydrate(state: Partial) { + hydrating.value = true + if (state.status !== undefined) status.value = state.status + if (state.search !== undefined) search.value = state.search + if (state.bookFilter !== undefined) bookFilter.value = state.bookFilter + if (state.colors !== undefined) colors.value = state.colors + if (state.styleFilter !== undefined) styleFilter.value = state.styleFilter + if (state.originFilter !== undefined) originFilter.value = state.originFilter + if (state.notesOnly !== undefined) notesOnly.value = state.notesOnly + if (state.dateFrom !== undefined) dateFrom.value = state.dateFrom + if (state.dateTo !== undefined) dateTo.value = state.dateTo + if (state.sortKey !== undefined) sortKey.value = state.sortKey + if (state.page !== undefined) page.value = state.page + void nextTick(() => { + hydrating.value = false + }) + } + + async function bulk(action: 'trash' | 'restore' | 'restyle', patch?: { color?: string; style?: string }): Promise { + const ids = [...selection.selectedIds.value] + if (ids.length === 0) return 0 + const res = await api('/api/v1/annotations/bulk', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ids, action, ...patch }), + }) + if (!res.ok) return 0 + const body = (await res.json()) as { affected: number } + selection.clearSelection() + await load() + return body.affected + } + + async function restore(id: number): Promise { + const res = await api(`/api/v1/annotations/${id}/restore`, { method: 'POST' }) + if (res.ok) await load() + return res.ok + } + + async function purge(id: number): Promise<{ ok: boolean; message?: string }> { + const res = await api(`/api/v1/annotations/${id}`, { method: 'DELETE' }) + if (res.ok) { + await load() + return { ok: true } + } + if (res.status === 409) { + const body = (await res.json().catch(() => null)) as { message?: string } | null + return { ok: false, message: body?.message ?? 'Still pending device sync' } + } + return { ok: false, message: 'Failed to delete' } + } + + function exportUrl(format: 'md' | 'csv' | 'json'): string { + return `/api/v1/annotations/export?${buildQuery({ format })}` + } + + function reloadFromFilterChange() { + selection.clearSelection() + if (page.value === 1) void load() + else page.value = 1 + } + + watch([status, colors, styleFilter, originFilter, bookFilter, sortBy, sortDir, dateFrom, dateTo, notesOnly], () => { + if (hydrating.value) return + reloadFromFilterChange() + }) + let searchDebounce: ReturnType | null = null + watch(search, () => { + if (hydrating.value) return + if (searchDebounce) clearTimeout(searchDebounce) + searchDebounce = setTimeout(reloadFromFilterChange, SEARCH_RELOAD_DEBOUNCE_MS) + }) + watch(status, () => { + if (hydrating.value) return + bookFilter.value = 'all' + selectedBookLabel.value = null + }) + watch(page, () => { + if (hydrating.value) return + selection.clearSelection() + void load() + }) + + return { + items, + total, + page, + pageSize, + totalPages, + rangeStart, + rangeEnd, + loading, + error, + status, + search, + colors, + styleFilter, + originFilter, + bookFilter, + sortBy, + sortDir, + sortKey, + dateFrom, + dateTo, + notesOnly, + hydrating, + activeFilterChips, + popoverFilterCount, + hasActiveFilters, + selectedBookLabel, + stats, + selectedIds: selection.selectedIds, + savingIds: mutations.savingIds, + hasSelection: selection.hasSelection, + allVisibleSelected: selection.allVisibleSelected, + load, + searchBooks, + resolveSelectedBook, + toggleSelected: selection.toggleSelected, + clearSelection: selection.clearSelection, + selectAllOnPage: selection.selectAllOnPage, + toggleNotesOnly, + clearDates, + clearPopoverFilters, + resetAllFilters, + removeFilterChip, + updateNote: mutations.updateNote, + updateColor: mutations.updateColor, + updateStyle: mutations.updateStyle, + hydrate, + bulk, + restore, + purge, + exportUrl, + } +} diff --git a/client/src/features/annotations/composables/useAnnotationsUrlSync.ts b/client/src/features/annotations/composables/useAnnotationsUrlSync.ts new file mode 100644 index 000000000..e844971aa --- /dev/null +++ b/client/src/features/annotations/composables/useAnnotationsUrlSync.ts @@ -0,0 +1,135 @@ +import { watch } from 'vue' +import { useRoute, useRouter, type LocationQuery, type LocationQueryRaw } from 'vue-router' +import { SORT_OPTIONS, type SortKey } from '../lib/filter-options' +import type { AnnotationsHubState, useAnnotationsHub } from './useAnnotationsHub' + +type Hub = ReturnType + +const VALID_SORT_KEYS = new Set(SORT_OPTIONS.map((option) => option.value)) +const SEARCH_WRITE_DELAY_MS = 300 + +function firstString(value: LocationQuery[string] | undefined): string | undefined { + if (Array.isArray(value)) { + const first = value[0] + return typeof first === 'string' ? first : undefined + } + return typeof value === 'string' ? value : undefined +} + +/** Restores the saved filter/sort/page slice from a URL query (ignoring unknown or malformed values). */ +export function annotationsStateFromQuery(query: LocationQuery): Partial { + const state: Partial = {} + + if (firstString(query.status) === 'trashed') state.status = 'trashed' + + const search = firstString(query.search) + if (search) state.search = search + + const bookId = firstString(query.bookId) + if (bookId) { + const parsed = Number(bookId) + if (Number.isInteger(parsed) && parsed > 0) state.bookFilter = parsed + } + + const colorsParam = firstString(query.colors) + if (colorsParam) { + const list = colorsParam.split(',').filter(Boolean) + if (list.length > 0) state.colors = list + } + + const style = firstString(query.style) + if (style) state.styleFilter = style + + const origin = firstString(query.origin) + if (origin) state.originFilter = origin + + if (firstString(query.notes) === '1') state.notesOnly = true + + const from = firstString(query.from) + if (from) state.dateFrom = from + + const to = firstString(query.to) + if (to) state.dateTo = to + + const sort = firstString(query.sort) + if (sort && VALID_SORT_KEYS.has(sort)) state.sortKey = sort as SortKey + + const page = firstString(query.page) + if (page) { + const parsed = Number(page) + if (Number.isInteger(parsed) && parsed > 1) state.page = parsed + } + + return state +} + +/** Serializes the current hub state into a minimal query (defaults are omitted). */ +export function annotationsQueryFromState(state: AnnotationsHubState): LocationQueryRaw { + const query: LocationQueryRaw = {} + if (state.status !== 'active') query.status = state.status + const search = state.search.trim() + if (search) query.search = search + if (state.bookFilter !== 'all') query.bookId = String(state.bookFilter) + if (state.colors.length > 0) query.colors = state.colors.join(',') + if (state.styleFilter !== 'all') query.style = state.styleFilter + if (state.originFilter !== 'all') query.origin = state.originFilter + if (state.notesOnly) query.notes = '1' + if (state.dateFrom) query.from = state.dateFrom + if (state.dateTo) query.to = state.dateTo + if (state.sortKey !== 'newest') query.sort = state.sortKey + if (state.page > 1) query.page = String(state.page) + return query +} + +function sameQuery(next: LocationQueryRaw, current: LocationQuery): boolean { + const nextKeys = Object.keys(next) + if (nextKeys.length !== Object.keys(current).length) return false + return nextKeys.every((key) => String(next[key]) === firstString(current[key])) +} + +/** + * Two-way binds the annotations filter/sort/page state to the URL query: hydrates the hub from + * the query on setup, then mirrors later changes back via `router.replace` so refresh, bookmark, + * and share restore the exact view without polluting browser history. Search writes are debounced. + */ +export function useAnnotationsUrlSync(hub: Hub) { + const route = useRoute() + const router = useRouter() + + hub.hydrate(annotationsStateFromQuery(route.query)) + void hub.resolveSelectedBook() + + function snapshot(): AnnotationsHubState { + return { + status: hub.status.value, + search: hub.search.value, + bookFilter: hub.bookFilter.value, + colors: hub.colors.value, + styleFilter: hub.styleFilter.value, + originFilter: hub.originFilter.value, + notesOnly: hub.notesOnly.value, + dateFrom: hub.dateFrom.value, + dateTo: hub.dateTo.value, + sortKey: hub.sortKey.value, + page: hub.page.value, + } + } + + function write() { + if (hub.hydrating.value) return + const query = annotationsQueryFromState(snapshot()) + if (sameQuery(query, route.query)) return + void router.replace({ query }) + } + + let searchTimer: ReturnType | null = null + watch(hub.search, () => { + if (searchTimer) clearTimeout(searchTimer) + searchTimer = setTimeout(write, SEARCH_WRITE_DELAY_MS) + }) + + watch( + [hub.status, hub.bookFilter, hub.colors, hub.styleFilter, hub.originFilter, hub.notesOnly, hub.dateFrom, hub.dateTo, hub.sortKey, hub.page], + write, + ) +} diff --git a/client/src/features/annotations/composables/useDensity.ts b/client/src/features/annotations/composables/useDensity.ts new file mode 100644 index 000000000..c96668ba2 --- /dev/null +++ b/client/src/features/annotations/composables/useDensity.ts @@ -0,0 +1,15 @@ +import { ref, watch } from 'vue' + +export type AnnotationDensity = 'compact' | 'comfortable' + +const STORAGE_KEY = 'annotations:density' + +/** Density preference shared across the annotations hub and the book highlights tab. */ +export function useDensity() { + const stored = localStorage.getItem(STORAGE_KEY) + const density = ref(stored === 'compact' || stored === 'comfortable' ? stored : 'comfortable') + + watch(density, (value) => localStorage.setItem(STORAGE_KEY, value)) + + return { density } +} diff --git a/client/src/features/annotations/lib/__tests__/filter-chips.spec.ts b/client/src/features/annotations/lib/__tests__/filter-chips.spec.ts new file mode 100644 index 000000000..16284a25f --- /dev/null +++ b/client/src/features/annotations/lib/__tests__/filter-chips.spec.ts @@ -0,0 +1,39 @@ +import { describe, it, expect } from 'vitest' +import { buildFilterChips, dateRangeLabel } from '../filter-chips' + +describe('dateRangeLabel', () => { + it('formats both, from-only, and to-only ranges', () => { + expect(dateRangeLabel('2026-01-01', '2026-01-31')).toBe('2026-01-01 to 2026-01-31') + expect(dateRangeLabel('2026-01-01', '')).toBe('From 2026-01-01') + expect(dateRangeLabel('', '2026-01-31')).toBe('Until 2026-01-31') + }) +}) + +describe('buildFilterChips', () => { + it('returns an empty list when no filters are active', () => { + expect(buildFilterChips({ colors: [], dateFrom: '', dateTo: '' })).toEqual([]) + expect(buildFilterChips({ colors: [], styleFilter: 'all', originFilter: 'all', dateFrom: '', dateTo: '' })).toEqual([]) + }) + + it('builds one chip per color, resolving both app and KOReader-exact labels', () => { + const chips = buildFilterChips({ colors: ['#FACC15', '#FF3300'], dateFrom: '', dateTo: '' }) + expect(chips).toEqual([ + { id: 'color:#FACC15', label: 'Color: Yellow' }, + { id: 'color:#FF3300', label: 'Color: KOReader Red' }, + ]) + }) + + it('builds style, origin, and date chips when set', () => { + const chips = buildFilterChips({ + colors: [], + styleFilter: 'underline', + originFilter: 'koreader', + dateFrom: '2026-01-01', + dateTo: '2026-02-01', + }) + expect(chips.map((chip) => chip.id)).toEqual(['style', 'origin', 'date']) + expect(chips[0].label).toBe('Style: Underline') + expect(chips[1].label).toBe('Source: KOReader') + expect(chips[2].label).toBe('Date: 2026-01-01 to 2026-02-01') + }) +}) diff --git a/client/src/features/annotations/lib/filter-chips.ts b/client/src/features/annotations/lib/filter-chips.ts new file mode 100644 index 000000000..1a3c3c8f1 --- /dev/null +++ b/client/src/features/annotations/lib/filter-chips.ts @@ -0,0 +1,28 @@ +import { colorLabel, originLabel, styleLabel } from './filter-options' + +export interface ActiveFilterChip { + id: string + label: string +} + +export function dateRangeLabel(from: string, to: string): string { + if (from && to) return `${from} to ${to}` + if (from) return `From ${from}` + return `Until ${to}` +} + +/** Builds the dismissible chip list for the filters that live inside the Filters popover. */ +export function buildFilterChips(opts: { + colors: string[] + styleFilter?: string + originFilter?: string + dateFrom: string + dateTo: string +}): ActiveFilterChip[] { + const chips: ActiveFilterChip[] = [] + for (const hex of opts.colors) chips.push({ id: `color:${hex}`, label: `Color: ${colorLabel(hex)}` }) + if (opts.styleFilter && opts.styleFilter !== 'all') chips.push({ id: 'style', label: `Style: ${styleLabel(opts.styleFilter)}` }) + if (opts.originFilter && opts.originFilter !== 'all') chips.push({ id: 'origin', label: `Source: ${originLabel(opts.originFilter)}` }) + if (opts.dateFrom || opts.dateTo) chips.push({ id: 'date', label: `Date: ${dateRangeLabel(opts.dateFrom, opts.dateTo)}` }) + return chips +} diff --git a/client/src/features/annotations/lib/filter-options.ts b/client/src/features/annotations/lib/filter-options.ts new file mode 100644 index 000000000..38298a337 --- /dev/null +++ b/client/src/features/annotations/lib/filter-options.ts @@ -0,0 +1,52 @@ +import { ANNOTATION_COLOR_FILTER_OPTIONS } from '@bookorbit/types' + +export interface FilterOption { + value: string + label: string +} + +export const COLOR_OPTIONS: FilterOption[] = [ + { value: 'all', label: 'All colors' }, + ...ANNOTATION_COLOR_FILTER_OPTIONS.map((color) => ({ value: color.hex, label: color.label })), +] + +export const STYLE_OPTIONS: FilterOption[] = [ + { value: 'all', label: 'All styles' }, + { value: 'highlight', label: 'Highlight' }, + { value: 'underline', label: 'Underline' }, + { value: 'strikethrough', label: 'Strikethrough' }, + { value: 'squiggly', label: 'Squiggly' }, + { value: 'invert', label: 'Invert' }, +] + +export const ORIGIN_OPTIONS: FilterOption[] = [ + { value: 'all', label: 'All sources' }, + { value: 'web', label: 'Web' }, + { value: 'koreader', label: 'KOReader' }, + { value: 'kobo', label: 'Kobo' }, +] + +export type SortKey = 'newest' | 'oldest' | 'book-asc' | 'book-desc' + +export const SORT_OPTIONS: { value: SortKey; label: string }[] = [ + { value: 'newest', label: 'Newest first' }, + { value: 'oldest', label: 'Oldest first' }, + { value: 'book-asc', label: 'Book A to Z' }, + { value: 'book-desc', label: 'Book Z to A' }, +] + +function labelFor(options: FilterOption[], value: string): string { + return options.find((option) => option.value === value)?.label ?? value +} + +export function colorLabel(value: string): string { + return labelFor(COLOR_OPTIONS, value) +} + +export function styleLabel(value: string): string { + return labelFor(STYLE_OPTIONS, value) +} + +export function originLabel(value: string): string { + return labelFor(ORIGIN_OPTIONS, value) +} diff --git a/client/src/features/annotations/lib/pill-styles.ts b/client/src/features/annotations/lib/pill-styles.ts new file mode 100644 index 000000000..e542f62e0 --- /dev/null +++ b/client/src/features/annotations/lib/pill-styles.ts @@ -0,0 +1,41 @@ +import type { AnnotationItem, AnnotationPositionStatus } from '@bookorbit/types' + +type AnnotationOrigin = AnnotationItem['origin'] + +export interface PillStyle { + label: string + class: string +} + +const PILL_BASE = 'inline-flex items-center gap-1 rounded border px-1.5 py-0.5 text-[10px] font-medium' + +export const SOURCE_PILLS: Record = { + web: { label: 'Web', class: 'border-[var(--pill-web)]/40 bg-[var(--pill-web)]/10 text-[var(--pill-web)]' }, + koreader: { label: 'KOReader', class: 'border-[var(--pill-koreader)]/40 bg-[var(--pill-koreader)]/10 text-[var(--pill-koreader)]' }, + kobo: { label: 'Kobo', class: 'border-[var(--pill-kobo)]/40 bg-[var(--pill-kobo)]/10 text-[var(--pill-kobo)]' }, +} + +const STATUS_QUIET = 'border-border bg-muted text-muted-foreground' + +const STATUS_PILLS: Record = { + exact: { label: 'Exact', class: STATUS_QUIET }, + repaired: { label: 'Repaired', class: 'border-[var(--pill-repaired)]/40 bg-[var(--pill-repaired)]/10 text-[var(--pill-repaired)]' }, + pending: { label: 'Pending', class: 'border-[var(--pill-pending)]/40 bg-[var(--pill-pending)]/10 text-[var(--pill-pending)]' }, + failed: { label: 'Failed', class: 'border-destructive/40 bg-destructive/10 text-destructive' }, +} + +const APPROXIMATE_PILL: PillStyle = { + label: 'Approximate', + class: 'border-[var(--pill-repaired)]/40 bg-[var(--pill-repaired)]/10 text-[var(--pill-repaired)]', +} + +export const PILL_CLASS = PILL_BASE + +export function sourcePill(origin: AnnotationOrigin): PillStyle { + return SOURCE_PILLS[origin] +} + +export function statusPill(status: AnnotationPositionStatus | null, isApproximate: boolean): PillStyle | null { + if (!status) return isApproximate ? APPROXIMATE_PILL : null + return STATUS_PILLS[status] +} diff --git a/client/src/features/annotations/views/AnnotationsHubView.vue b/client/src/features/annotations/views/AnnotationsHubView.vue new file mode 100644 index 000000000..224fe33c4 --- /dev/null +++ b/client/src/features/annotations/views/AnnotationsHubView.vue @@ -0,0 +1,345 @@ + + + diff --git a/client/src/features/book/components/detail/tabs/AddSessionDialog.vue b/client/src/features/book/components/detail/tabs/AddSessionDialog.vue new file mode 100644 index 000000000..6413ce1a6 --- /dev/null +++ b/client/src/features/book/components/detail/tabs/AddSessionDialog.vue @@ -0,0 +1,158 @@ + + + diff --git a/client/src/features/book/components/detail/tabs/HighlightCard.vue b/client/src/features/book/components/detail/tabs/HighlightCard.vue index 1d67dc4dd..b805840de 100644 --- a/client/src/features/book/components/detail/tabs/HighlightCard.vue +++ b/client/src/features/book/components/detail/tabs/HighlightCard.vue @@ -1,187 +1,44 @@ diff --git a/client/src/features/book/components/detail/tabs/HighlightChapterGroup.vue b/client/src/features/book/components/detail/tabs/HighlightChapterGroup.vue index fd22e01fb..e3cbbb467 100644 --- a/client/src/features/book/components/detail/tabs/HighlightChapterGroup.vue +++ b/client/src/features/book/components/detail/tabs/HighlightChapterGroup.vue @@ -1,18 +1,24 @@ diff --git a/client/src/features/book/components/detail/tabs/ReadingLogExportMenu.vue b/client/src/features/book/components/detail/tabs/ReadingLogExportMenu.vue new file mode 100644 index 000000000..5b6ec7fa1 --- /dev/null +++ b/client/src/features/book/components/detail/tabs/ReadingLogExportMenu.vue @@ -0,0 +1,120 @@ + + + diff --git a/client/src/features/book/components/detail/tabs/ReadingLogHeatmap.vue b/client/src/features/book/components/detail/tabs/ReadingLogHeatmap.vue new file mode 100644 index 000000000..ffed4c93e --- /dev/null +++ b/client/src/features/book/components/detail/tabs/ReadingLogHeatmap.vue @@ -0,0 +1,185 @@ + + + diff --git a/client/src/features/book/components/detail/tabs/ReadingLogHero.vue b/client/src/features/book/components/detail/tabs/ReadingLogHero.vue new file mode 100644 index 000000000..7573f3b7f --- /dev/null +++ b/client/src/features/book/components/detail/tabs/ReadingLogHero.vue @@ -0,0 +1,431 @@ + + + diff --git a/client/src/features/book/components/detail/tabs/ReadingLogJourneyChart.vue b/client/src/features/book/components/detail/tabs/ReadingLogJourneyChart.vue new file mode 100644 index 000000000..dd0de1926 --- /dev/null +++ b/client/src/features/book/components/detail/tabs/ReadingLogJourneyChart.vue @@ -0,0 +1,106 @@ + + + diff --git a/client/src/features/book/components/detail/tabs/ReadingLogSourceSplit.vue b/client/src/features/book/components/detail/tabs/ReadingLogSourceSplit.vue new file mode 100644 index 000000000..2bd4dd30e --- /dev/null +++ b/client/src/features/book/components/detail/tabs/ReadingLogSourceSplit.vue @@ -0,0 +1,68 @@ + + + diff --git a/client/src/features/book/components/detail/tabs/ReadingLogSparkline.vue b/client/src/features/book/components/detail/tabs/ReadingLogSparkline.vue deleted file mode 100644 index d44d9eed4..000000000 --- a/client/src/features/book/components/detail/tabs/ReadingLogSparkline.vue +++ /dev/null @@ -1,65 +0,0 @@ - - - diff --git a/client/src/features/book/components/detail/tabs/ReadingLogStatsStrip.vue b/client/src/features/book/components/detail/tabs/ReadingLogStatsStrip.vue deleted file mode 100644 index f03faf4f3..000000000 --- a/client/src/features/book/components/detail/tabs/ReadingLogStatsStrip.vue +++ /dev/null @@ -1,182 +0,0 @@ - - - diff --git a/client/src/features/book/components/detail/tabs/ReadingLogTab.vue b/client/src/features/book/components/detail/tabs/ReadingLogTab.vue index 22ed0afa0..722f8c59b 100644 --- a/client/src/features/book/components/detail/tabs/ReadingLogTab.vue +++ b/client/src/features/book/components/detail/tabs/ReadingLogTab.vue @@ -1,16 +1,39 @@ diff --git a/client/src/features/book/components/detail/tabs/__tests__/HighlightsExportMenu.spec.ts b/client/src/features/book/components/detail/tabs/__tests__/HighlightsExportMenu.spec.ts index 8d5c9629f..ada8a8d5b 100644 --- a/client/src/features/book/components/detail/tabs/__tests__/HighlightsExportMenu.spec.ts +++ b/client/src/features/book/components/detail/tabs/__tests__/HighlightsExportMenu.spec.ts @@ -8,11 +8,16 @@ function makeHighlight(overrides: Partial = {}): AnnotationItem id: 1, bookId: 5, cfi: 'epubcfi(/6/4)', + jumpFileId: 10, + pageno: null, text: 'highlighted text', color: '#FACC15', style: 'highlight', note: null, chapterTitle: 'Chapter 1', + origin: 'web', + positionStatus: 'exact', + chapterIndex: null, createdAt: '2026-01-01T00:00:00.000Z', ...overrides, } @@ -40,10 +45,16 @@ function setupDownloadMocks() { return { createObjectURL, revokeObjectURL, clickMock } } +const stubs = { + Popover: { name: 'Popover', props: ['open'], template: '
' }, + PopoverTrigger: { template: '
' }, + PopoverContent: { template: '
' }, +} + function mountMenu(items: AnnotationItem[], bookTitle = 'Test Book') { return mount(HighlightsExportMenu, { props: { items, bookTitle }, - global: { stubs: { teleport: true } }, + global: { stubs }, }) } @@ -63,9 +74,8 @@ describe('HighlightsExportMenu', () => { expect(btn.attributes('disabled')).toBeDefined() }) - it('opens menu on click', async () => { + it('shows export options', () => { const wrapper = mountMenu([makeHighlight()]) - await wrapper.find('button').trigger('click') expect(wrapper.text()).toContain('Markdown') expect(wrapper.text()).toContain('Plain Text') expect(wrapper.text()).toContain('JSON') @@ -76,7 +86,6 @@ describe('HighlightsExportMenu', () => { const wrapper = mountMenu([makeHighlight({ text: 'my highlight', note: 'my note', chapterTitle: 'Ch 1' })], 'Test Book') - await wrapper.find('button').trigger('click') const mdBtn = wrapper.findAll('button').find((b) => b.text().includes('Markdown'))! await mdBtn.trigger('click') @@ -90,7 +99,6 @@ describe('HighlightsExportMenu', () => { const wrapper = mountMenu([makeHighlight()]) - await wrapper.find('button').trigger('click') const jsonBtn = wrapper.findAll('button').find((b) => b.text().includes('JSON'))! await jsonBtn.trigger('click') @@ -103,7 +111,6 @@ describe('HighlightsExportMenu', () => { const wrapper = mountMenu([makeHighlight()]) - await wrapper.find('button').trigger('click') const txtBtn = wrapper.findAll('button').find((b) => b.text().includes('Plain Text'))! await txtBtn.trigger('click') @@ -115,12 +122,10 @@ describe('HighlightsExportMenu', () => { setupDownloadMocks() const wrapper = mountMenu([makeHighlight()]) - await wrapper.find('button').trigger('click') - expect(wrapper.text()).toContain('Markdown') const mdBtn = wrapper.findAll('button').find((b) => b.text().includes('Markdown'))! await mdBtn.trigger('click') - expect(wrapper.text()).not.toContain('Plain Text') + expect(wrapper.findComponent({ name: 'Popover' }).props('open')).toBe(false) }) }) diff --git a/client/src/features/book/components/detail/tabs/__tests__/HighlightsFilterBar.spec.ts b/client/src/features/book/components/detail/tabs/__tests__/HighlightsFilterBar.spec.ts deleted file mode 100644 index 945fac0c2..000000000 --- a/client/src/features/book/components/detail/tabs/__tests__/HighlightsFilterBar.spec.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { mount } from '@vue/test-utils' -import { describe, expect, it, vi } from 'vitest' -import HighlightsFilterBar from '../HighlightsFilterBar.vue' - -function makeProps(overrides = {}) { - return { - activeColors: [] as string[], - chapters: ['Chapter 1', 'Chapter 2'], - selectedChapter: undefined as string | undefined, - dateFrom: undefined as string | undefined, - dateTo: undefined as string | undefined, - ...overrides, - } -} - -describe('HighlightsFilterBar', () => { - it('renders search input', () => { - const wrapper = mount(HighlightsFilterBar, { props: makeProps() }) - const input = wrapper.find('input[type="text"]') - expect(input.exists()).toBe(true) - expect(input.attributes('placeholder')).toBe('Search highlights...') - }) - - it('renders color filter buttons', () => { - const wrapper = mount(HighlightsFilterBar, { props: makeProps() }) - const colorButtons = wrapper.findAll('button[title]') - expect(colorButtons.length).toBe(5) - }) - - it('emits toggleColor when a color button is clicked', async () => { - const wrapper = mount(HighlightsFilterBar, { props: makeProps() }) - const yellowBtn = wrapper.find('button[title="Yellow"]') - await yellowBtn.trigger('click') - expect(wrapper.emitted('toggleColor')).toBeTruthy() - expect(wrapper.emitted('toggleColor')![0]).toEqual(['#FACC15']) - }) - - it('highlights active color buttons', () => { - const wrapper = mount(HighlightsFilterBar, { - props: makeProps({ activeColors: ['#FACC15'] }), - }) - const yellowBtn = wrapper.find('button[title="Yellow"]') - expect(yellowBtn.classes()).toContain('border-foreground') - }) - - it('renders chapter dropdown when chapters exist', () => { - const wrapper = mount(HighlightsFilterBar, { props: makeProps() }) - const select = wrapper.find('select') - expect(select.exists()).toBe(true) - const options = select.findAll('option') - expect(options.length).toBe(3) - expect(options[0]?.text()).toBe('All chapters') - }) - - it('does not render chapter dropdown when no chapters', () => { - const wrapper = mount(HighlightsFilterBar, { - props: makeProps({ chapters: [] }), - }) - expect(wrapper.find('select').exists()).toBe(false) - }) - - it('emits chapterChange when chapter is selected', async () => { - const wrapper = mount(HighlightsFilterBar, { props: makeProps() }) - const select = wrapper.find('select') - await select.setValue('Chapter 1') - expect(wrapper.emitted('chapterChange')).toBeTruthy() - expect(wrapper.emitted('chapterChange')![0]).toEqual(['Chapter 1']) - }) - - it('renders date range inputs', () => { - const wrapper = mount(HighlightsFilterBar, { props: makeProps() }) - const dateInputs = wrapper.findAll('input[type="date"]') - expect(dateInputs.length).toBe(2) - }) - - it('emits dateRangeChange when date from changes', async () => { - const wrapper = mount(HighlightsFilterBar, { props: makeProps() }) - const dateInputs = wrapper.findAll('input[type="date"]') - await dateInputs[0]?.setValue('2026-01-01') - expect(wrapper.emitted('dateRangeChange')).toBeTruthy() - expect(wrapper.emitted('dateRangeChange')![0]).toEqual(['2026-01-01', undefined]) - }) - - it('emits search with debounce on input', async () => { - vi.useFakeTimers() - const wrapper = mount(HighlightsFilterBar, { props: makeProps() }) - const input = wrapper.find('input[type="text"]') - - await input.setValue('test') - await input.trigger('input') - expect(wrapper.emitted('search')).toBeFalsy() - - vi.advanceTimersByTime(300) - expect(wrapper.emitted('search')).toBeTruthy() - - vi.useRealTimers() - }) - - it('shows clear button when search has value and emits on click', async () => { - vi.useFakeTimers() - const wrapper = mount(HighlightsFilterBar, { props: makeProps() }) - const input = wrapper.find('input[type="text"]') - - await input.setValue('test') - const clearBtn = wrapper.findAll('button').find((b) => !b.attributes('title')) - expect(clearBtn).toBeTruthy() - - vi.useRealTimers() - }) -}) diff --git a/client/src/features/book/components/detail/tabs/__tests__/ReadingLogHeatmap.spec.ts b/client/src/features/book/components/detail/tabs/__tests__/ReadingLogHeatmap.spec.ts new file mode 100644 index 000000000..88b8d4fe8 --- /dev/null +++ b/client/src/features/book/components/detail/tabs/__tests__/ReadingLogHeatmap.spec.ts @@ -0,0 +1,41 @@ +import { mount } from '@vue/test-utils' +import { createPinia } from 'pinia' +import { describe, expect, it, vi } from 'vitest' +import type { BookReadingSessionStats } from '@bookorbit/types' + +vi.mock('vue-echarts', () => ({ + default: { name: 'VChart', props: ['option'], template: '
' }, +})) + +import ReadingLogHeatmap from '../ReadingLogHeatmap.vue' + +function makeStats(): BookReadingSessionStats { + return { + totalSessions: 1, + totalSeconds: 1800, + avgDurationSeconds: 1800, + firstSessionAt: '2026-06-08T10:00:00.000Z', + lastSessionAt: '2026-06-08T10:30:00.000Z', + dailySummary: [{ day: '2026-06-08', totalMinutes: 30 }], + paceProgressDelta: 0, + paceDurationSeconds: 0, + progressSummary: [], + bySource: [], + } +} + +describe('ReadingLogHeatmap', () => { + it('configures the activity axis with every weekday starting on Monday', () => { + const wrapper = mount(ReadingLogHeatmap, { + props: { stats: makeStats(), loading: false, quickFilter: 'all' }, + global: { plugins: [createPinia()] }, + }) + + const option = wrapper.findComponent({ name: 'VChart' }).props('option') as { + calendar?: { dayLabel?: { firstDay?: number; nameMap?: string[] } } + } + + expect(option.calendar?.dayLabel?.firstDay).toBe(1) + expect(option.calendar?.dayLabel?.nameMap).toEqual(['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']) + }) +}) diff --git a/client/src/features/book/components/detail/tabs/__tests__/ReadingLogHero.spec.ts b/client/src/features/book/components/detail/tabs/__tests__/ReadingLogHero.spec.ts new file mode 100644 index 000000000..55cc0f98c --- /dev/null +++ b/client/src/features/book/components/detail/tabs/__tests__/ReadingLogHero.spec.ts @@ -0,0 +1,492 @@ +import { flushPromises, mount } from '@vue/test-utils' +import type { Component } from 'vue' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { BookDetail, BookReadingSessionStats, UserBookStatus } from '@bookorbit/types' + +const mocks = vi.hoisted(() => ({ + api: vi.fn<(input: RequestInfo | URL, init?: RequestInit) => Promise>(), + setStatus: vi.fn<(bookId: number, status: string) => Promise>(), + updateStatus: vi.fn<(bookId: number, patch: Record) => Promise>(), +})) + +vi.mock('@/lib/api', () => ({ + api: mocks.api, +})) + +vi.mock('@/features/book/composables/useBookStatus', () => { + const MockIcon = { template: '' } + return { + STATUS_COLORS: { + unread: '', + want_to_read: '', + reading: '', + on_hold: '', + rereading: '', + read: '', + skimmed: '', + abandoned: '', + }, + STATUS_ICONS: { + unread: MockIcon, + want_to_read: MockIcon, + reading: MockIcon, + on_hold: MockIcon, + rereading: MockIcon, + read: MockIcon, + skimmed: MockIcon, + abandoned: MockIcon, + }, + STATUS_OPTIONS: [ + { value: 'reading', label: 'Reading' }, + { value: 'read', label: 'Read' }, + ], + useBookStatus: () => ({ + setStatus: mocks.setStatus, + updateStatus: mocks.updateStatus, + }), + } +}) + +vi.mock('@/components/ui/dropdown-menu', () => ({ + DropdownMenu: { template: '
' }, + DropdownMenuContent: { template: '
' }, + DropdownMenuItem: { template: '' }, + DropdownMenuTrigger: { template: '
' }, +})) + +vi.mock('@/features/achievements/components/AchievementProgressRing.vue', () => ({ + default: { template: '
' }, +})) + +import ReadingLogHero from '../ReadingLogHero.vue' + +function makeReadStatus(overrides: Partial = {}): UserBookStatus { + return { + status: 'reading', + source: 'manual', + startedAt: '2026-04-01', + finishedAt: '2026-04-20', + updatedAt: '2026-04-21T00:00:00.000Z', + ...overrides, + } +} + +function makeBook(overrides: Partial = {}): BookDetail { + return { + id: 10, + libraryId: 1, + libraryName: 'My Library', + status: 'ok', + folderPath: '/books', + addedAt: '2026-01-01T00:00:00.000Z', + updatedAt: null, + title: 'Test Book', + subtitle: null, + description: null, + isbn10: null, + isbn13: null, + publisher: null, + publishedYear: null, + language: null, + pageCount: null, + seriesName: null, + seriesIndex: null, + rating: null, + coverSource: null, + providerIds: {}, + authors: [], + genres: [], + tags: [], + files: [], + lastWrittenAt: null, + metadataScore: null, + hardcoverEditionId: null, + communityRatings: [], + readStatus: makeReadStatus(), + audioMetadata: null, + formatPriority: [], + comicMetadata: null, + customMetadata: [], + lockedFields: [], + collections: [], + ...overrides, + } +} + +function makeStats(overrides: Partial = {}): BookReadingSessionStats { + return { + totalSessions: 1, + totalSeconds: 1800, + avgDurationSeconds: 1800, + firstSessionAt: '2026-04-15T10:00:00.000Z', + lastSessionAt: '2026-04-16T10:00:00.000Z', + dailySummary: [], + paceProgressDelta: 0, + paceDurationSeconds: 0, + progressSummary: [], + bySource: [], + ...overrides, + } +} + +function mountHero(book = makeBook(), stats = makeStats()) { + return mount(ReadingLogHero as Component, { + props: { book, stats, loading: false }, + }) +} + +function makeResponse(data: unknown): Response { + return { + ok: true, + json: async () => data, + } as Response +} + +describe('ReadingLogHero', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-06-24T12:00:00.000Z')) + mocks.api.mockReset() + mocks.api.mockResolvedValue(makeResponse([])) + mocks.setStatus.mockReset() + mocks.updateStatus.mockReset() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('renders session dates separately from editable lifecycle dates', async () => { + const wrapper = mountHero() + await flushPromises() + + expect(wrapper.text()).toContain('First Read') + expect(wrapper.text()).toContain('Last Read') + expect(wrapper.text()).toContain('Date Started') + expect(wrapper.text()).toContain('Apr 1, 2026') + expect(wrapper.text()).toContain('Date Finished') + expect(wrapper.text()).toContain('Apr 20, 2026') + }) + + it('keeps lifecycle date editing wired to the read status API', async () => { + const updated = makeReadStatus({ startedAt: '2026-04-02' }) + mocks.updateStatus.mockResolvedValue(updated) + const wrapper = mountHero() + await flushPromises() + + const startButton = wrapper.findAll('button').find((button) => button.text() === 'Apr 1, 2026') + expect(startButton).toBeDefined() + await startButton!.trigger('click') + + const input = wrapper.find('input[type="date"]') + await input.setValue('2026-04-02') + await input.trigger('blur') + await flushPromises() + + expect(mocks.updateStatus).toHaveBeenCalledWith(10, { startedAt: '2026-04-02' }) + expect(wrapper.emitted('saved')?.[0]).toEqual([updated]) + }) + + it('validates lifecycle dates before saving', async () => { + const wrapper = mountHero() + await flushPromises() + + const finishButton = wrapper.findAll('button').find((button) => button.text() === 'Apr 20, 2026') + expect(finishButton).toBeDefined() + await finishButton!.trigger('click') + + const input = wrapper.find('input[type="date"]') + await input.setValue('2026-07-01') + await input.trigger('blur') + await flushPromises() + + expect(wrapper.text()).toContain('Finish date cannot be in the future.') + expect(mocks.updateStatus).not.toHaveBeenCalled() + }) + + it('rejects invalid lifecycle date order and failed date saves', async () => { + const invalidWrapper = mountHero() + await flushPromises() + + const invalidFinishButton = invalidWrapper.findAll('button').find((button) => button.text() === 'Apr 20, 2026') + await invalidFinishButton!.trigger('click') + await invalidWrapper.find('input[type="date"]').setValue('2026-03-31') + await invalidWrapper.find('input[type="date"]').trigger('blur') + await flushPromises() + + expect(invalidWrapper.text()).toContain('Finish date must be on or after the start date.') + + mocks.updateStatus.mockRejectedValue(new Error('save failed')) + const failedWrapper = mountHero() + await flushPromises() + + const startButton = failedWrapper.findAll('button').find((button) => button.text() === 'Apr 1, 2026') + await startButton!.trigger('click') + await failedWrapper.find('input[type="date"]').setValue('2026-04-02') + await failedWrapper.find('input[type="date"]').trigger('blur') + await flushPromises() + + expect(failedWrapper.text()).toContain('Failed to save reading dates.') + }) + + it('closes lifecycle date editing when unchanged or canceled', async () => { + 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"]').trigger('blur') + await flushPromises() + + expect(mocks.updateStatus).not.toHaveBeenCalled() + expect(wrapper.find('input[type="date"]').exists()).toBe(false) + + await startButton!.trigger('click') + await wrapper.find('input[type="date"]').setValue('2026-04-03') + await wrapper.find('input[type="date"]').trigger('keydown', { key: 'Escape' }) + await flushPromises() + + expect(mocks.updateStatus).not.toHaveBeenCalled() + expect(wrapper.text()).toContain('Apr 1, 2026') + + const finishButton = wrapper.findAll('button').find((button) => button.text() === 'Apr 20, 2026') + await finishButton!.trigger('click') + await wrapper.find('input[type="date"]').setValue('2026-04-21') + await wrapper.find('input[type="date"]').trigger('keydown', { key: 'Escape' }) + await flushPromises() + + expect(mocks.updateStatus).not.toHaveBeenCalled() + expect(wrapper.text()).toContain('Apr 20, 2026') + }) + + it('updates status through the status menu', async () => { + const updated = makeReadStatus({ status: 'read' }) + mocks.setStatus.mockResolvedValue(updated) + const wrapper = mountHero() + await flushPromises() + + const readButton = wrapper.findAll('button').find((button) => button.text() === 'Read') + expect(readButton).toBeDefined() + await readButton!.trigger('click') + await flushPromises() + + expect(mocks.setStatus).toHaveBeenCalledWith(10, 'read') + expect(wrapper.emitted('saved')?.[0]).toEqual([updated]) + }) + + it('reverts local status when the status update fails', async () => { + mocks.setStatus.mockRejectedValue(new Error('network failed')) + const wrapper = mountHero() + await flushPromises() + + const readButton = wrapper.findAll('button').find((button) => button.text() === 'Read') + await readButton!.trigger('click') + await flushPromises() + + expect(mocks.setStatus).toHaveBeenCalledWith(10, 'read') + expect(wrapper.emitted('saved')).toBeUndefined() + expect(wrapper.text()).toContain('Reading') + }) + + it('loads progress from ebook and audiobook progress endpoints', async () => { + mocks.api.mockImplementation(async (input) => { + const url = String(input) + if (url.includes('audio-progress')) return makeResponse({ percentage: 99.6 }) + return makeResponse([{ fileId: 1, percentage: 0.5 }]) + }) + const wrapper = mountHero( + makeBook({ + files: [ + { + id: 1, + format: 'mp3', + role: 'primary', + sizeBytes: null, + absolutePath: '/books/audio.mp3', + createdAt: '2026-01-01T00:00:00.000Z', + filename: 'audio.mp3', + durationSeconds: null, + }, + ], + }), + ) + await flushPromises() + + expect(wrapper.text()).toContain('>99%') + expect(mocks.api).toHaveBeenCalledWith('/api/v1/books/10/audio-progress') + }) + + it('ignores non-ok and malformed progress responses', async () => { + mocks.api.mockImplementation(async (input) => { + const url = String(input) + if (url.includes('audio-progress')) return makeResponse({ percentage: Number.NaN }) + return { ok: false, json: async () => [] } as Response + }) + const wrapper = mountHero( + makeBook({ + files: [ + { + id: 1, + format: 'm4b', + role: 'primary', + sizeBytes: null, + absolutePath: '/books/audio.m4b', + createdAt: '2026-01-01T00:00:00.000Z', + filename: 'audio.m4b', + durationSeconds: null, + }, + ], + }), + ) + await flushPromises() + + expect(wrapper.text()).toContain('0%') + }) + + it('renders eta and emits add-session actions', async () => { + mocks.api.mockResolvedValue(makeResponse([{ fileId: 1, percentage: 50 }])) + const wrapper = mountHero(makeBook(), makeStats({ paceProgressDelta: 50, paceDurationSeconds: 3600 })) + await flushPromises() + + expect(wrapper.text()).toContain('~1h to finish') + const addButton = wrapper.findAll('button').find((button) => button.text() === 'Add session') + await addButton!.trigger('click') + + expect(wrapper.emitted('addSession')).toHaveLength(1) + }) + + it('renders mixed hour and minute eta labels', async () => { + mocks.api.mockResolvedValue(makeResponse([{ fileId: 1, percentage: 10 }])) + const wrapper = mountHero(makeBook(), makeStats({ paceProgressDelta: 20, paceDurationSeconds: 3600 })) + await flushPromises() + + expect(wrapper.text()).toContain('~4h 30m to finish') + }) + + it('renders minute-only, whole-hour, and capped eta labels', async () => { + mocks.api.mockResolvedValue(makeResponse([{ fileId: 1, percentage: 99 }])) + const minuteWrapper = mountHero(makeBook(), makeStats({ paceProgressDelta: 50, paceDurationSeconds: 3600 })) + await flushPromises() + expect(minuteWrapper.text()).toContain('~5m to finish') + + mocks.api.mockResolvedValue(makeResponse([{ fileId: 1, percentage: 50 }])) + const hourWrapper = mountHero(makeBook(), makeStats({ paceProgressDelta: 25, paceDurationSeconds: 3600 })) + await flushPromises() + expect(hourWrapper.text()).toContain('~2h to finish') + + mocks.api.mockResolvedValue(makeResponse([{ fileId: 1, percentage: 1 }])) + const cappedWrapper = mountHero(makeBook(), makeStats({ paceProgressDelta: 0.1, paceDurationSeconds: 3600 })) + await flushPromises() + expect(cappedWrapper.text()).toContain('99h+ to finish') + }) + + it('hides eta for terminal statuses and invalid pace inputs', async () => { + mocks.api.mockResolvedValue(makeResponse([{ fileId: 1, percentage: 50 }])) + const readWrapper = mountHero( + makeBook({ readStatus: makeReadStatus({ status: 'read' }) }), + makeStats({ paceProgressDelta: 50, paceDurationSeconds: 3600 }), + ) + await flushPromises() + expect(readWrapper.text()).not.toContain('to finish') + + const noPaceWrapper = mountHero(makeBook(), makeStats({ paceProgressDelta: 0, paceDurationSeconds: 3600 })) + await flushPromises() + expect(noPaceWrapper.text()).not.toContain('to finish') + }) + + it('falls back to zero progress when progress loading fails', async () => { + mocks.api.mockRejectedValue(new Error('progress failed')) + const wrapper = mountHero() + await flushPromises() + + expect(wrapper.text()).toContain('0%') + }) + + it('falls back to zero progress when progress parsing fails', async () => { + mocks.api.mockResolvedValue({ + ok: true, + json: async () => { + throw new Error('bad json') + }, + } as unknown as Response) + const wrapper = mountHero() + await flushPromises() + + expect(wrapper.text()).toContain('0%') + }) + + it('renders momentum titles for upward, downward, and unchanged trends', async () => { + const newActivity = mountHero( + makeBook(), + makeStats({ + dailySummary: [{ day: '2026-06-24', totalMinutes: 20 }], + }), + ) + await flushPromises() + expect(newActivity.find('[title="New activity this week"]').exists()).toBe(true) + + const upward = mountHero( + makeBook(), + makeStats({ + dailySummary: [ + { day: '2026-06-24', totalMinutes: 20 }, + { day: '2026-06-17', totalMinutes: 10 }, + ], + }), + ) + await flushPromises() + expect(upward.find('[title="+100% vs previous 7 days"]').exists()).toBe(true) + + const downward = mountHero( + makeBook(), + makeStats({ + dailySummary: [ + { day: '2026-06-24', totalMinutes: 10 }, + { day: '2026-06-17', totalMinutes: 20 }, + ], + }), + ) + await flushPromises() + expect(downward.find('[title="-50% vs previous 7 days"]').exists()).toBe(true) + + const unchanged = mountHero( + makeBook(), + makeStats({ + dailySummary: [ + { day: '2026-06-24', totalMinutes: 10 }, + { day: '2026-06-17', totalMinutes: 10 }, + ], + }), + ) + await flushPromises() + expect(unchanged.find('[title="Unchanged vs previous 7 days"]').exists()).toBe(true) + }) + + it('renders empty and loading states without session dates', async () => { + const emptyWrapper = mountHero(makeBook({ readStatus: null }), makeStats({ firstSessionAt: null, lastSessionAt: null })) + await flushPromises() + + expect(emptyWrapper.text()).toContain('No sessions') + expect(emptyWrapper.text()).toContain('Not set') + + const loadingWrapper = mount(ReadingLogHero as Component, { + props: { book: makeBook(), stats: null, loading: true }, + }) + await flushPromises() + + expect(loadingWrapper.text()).toContain('Add session') + }) + + it('normalizes invalid lifecycle dates to empty display values', async () => { + const wrapper = mountHero( + makeBook({ + readStatus: makeReadStatus({ startedAt: 'not-a-date', finishedAt: 'also-not-a-date' }), + }), + makeStats({ lastSessionAt: '2024-04-15T10:00:00.000Z' }), + ) + await flushPromises() + + expect(wrapper.text()).toContain('Not set') + expect(wrapper.text()).toContain('2 years ago') + }) +}) diff --git a/client/src/features/book/components/detail/tabs/__tests__/ReadingLogSourceSplit.spec.ts b/client/src/features/book/components/detail/tabs/__tests__/ReadingLogSourceSplit.spec.ts new file mode 100644 index 000000000..8bbb98f4b --- /dev/null +++ b/client/src/features/book/components/detail/tabs/__tests__/ReadingLogSourceSplit.spec.ts @@ -0,0 +1,94 @@ +import { mount } from '@vue/test-utils' +import { describe, expect, it } from 'vitest' +import type { BookReadingSourceSlice } from '@bookorbit/types' + +import ReadingLogSourceSplit from '../ReadingLogSourceSplit.vue' + +function makeStats(bySource: BookReadingSourceSlice[]) { + return { + totalSessions: 0, + totalSeconds: 0, + avgDurationSeconds: 0, + firstSessionAt: null, + lastSessionAt: null, + dailySummary: [], + paceProgressDelta: 0, + paceDurationSeconds: 0, + progressSummary: [], + bySource, + } +} + +describe('ReadingLogSourceSplit', () => { + it('renders nothing when stats is null', () => { + const wrapper = mount(ReadingLogSourceSplit, { props: { stats: null } }) + expect(wrapper.find('div').exists()).toBe(false) + }) + + it('renders nothing when there are no source slices', () => { + const wrapper = mount(ReadingLogSourceSplit, { props: { stats: makeStats([]) } }) + expect(wrapper.find('div').exists()).toBe(false) + }) + + it('renders nothing when the book was read from a single source', () => { + const wrapper = mount(ReadingLogSourceSplit, { + props: { stats: makeStats([{ bucket: 'koreader', totalSeconds: 100, totalSessions: 2 }]) }, + }) + expect(wrapper.find('div').exists()).toBe(false) + }) + + it('renders a labelled split with percentages and durations across sources', () => { + const wrapper = mount(ReadingLogSourceSplit, { + props: { + stats: makeStats([ + { bucket: 'bookorbit', totalSeconds: 3600, totalSessions: 4 }, + { bucket: 'koreader', totalSeconds: 120, totalSessions: 1 }, + { bucket: 'kobo', totalSeconds: 30, totalSessions: 1 }, + ]), + }, + }) + + expect(wrapper.find('div').exists()).toBe(true) + const text = wrapper.text() + expect(text).toContain('Where you read this book') + expect(text).toContain('BookOrbit') + expect(text).toContain('KOReader') + expect(text).toContain('Kobo') + // total = 3750s -> 96% / 3% / 1% + expect(text).toContain('96%') + expect(text).toContain('3%') + expect(text).toContain('1%') + // formatDuration: hours, minutes and seconds branches + expect(text).toContain('1h 0m') + expect(text).toContain('2m') + expect(text).toContain('30s') + }) + + it('stays hidden when buckets exist but carry no reading time', () => { + const wrapper = mount(ReadingLogSourceSplit, { + props: { + stats: makeStats([ + { bucket: 'bookorbit', totalSeconds: 0, totalSessions: 1 }, + { bucket: 'kobo', totalSeconds: 0, totalSessions: 1 }, + ]), + }, + }) + expect(wrapper.find('div').exists()).toBe(false) + }) + + it('sizes each bar segment proportionally to its reading time', () => { + const wrapper = mount(ReadingLogSourceSplit, { + props: { + stats: makeStats([ + { bucket: 'bookorbit', totalSeconds: 300, totalSessions: 2 }, + { bucket: 'kobo', totalSeconds: 100, totalSessions: 1 }, + ]), + }, + }) + + const segments = wrapper.findAll('.h-full') + expect(segments).toHaveLength(2) + expect(segments[0]!.attributes('style')).toContain('width: 75%') + expect(segments[1]!.attributes('style')).toContain('width: 25%') + }) +}) diff --git a/client/src/features/book/components/detail/tabs/__tests__/ReadingLogStatsStrip.spec.ts b/client/src/features/book/components/detail/tabs/__tests__/ReadingLogStatsStrip.spec.ts deleted file mode 100644 index c6c491cb3..000000000 --- a/client/src/features/book/components/detail/tabs/__tests__/ReadingLogStatsStrip.spec.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { mount } from '@vue/test-utils' -import { describe, expect, it } from 'vitest' -import ReadingLogStatsStrip from '../ReadingLogStatsStrip.vue' - -function makeStats(overrides = {}) { - return { - totalSessions: 5, - totalSeconds: 3780, - avgDurationSeconds: 756, - firstSessionAt: '2026-01-15T10:00:00.000Z', - lastSessionAt: '2026-04-15T10:00:00.000Z', - dailySummary: [], - ...overrides, - } -} - -function makeProps(overrides = {}) { - return { - stats: makeStats(), - loading: false, - quickFilter: 'all' as const, - ...overrides, - } -} - -describe('ReadingLogStatsStrip', () => { - it('shows skeleton cards on initial load (stats=null and loading=true)', () => { - const wrapper = mount(ReadingLogStatsStrip, { - props: makeProps({ stats: null, loading: true }), - }) - expect(wrapper.findAll('.animate-shimmer').length).toBeGreaterThan(0) - }) - - it('does not show skeleton when loading=true but stats already loaded (uses opacity instead)', () => { - const wrapper = mount(ReadingLogStatsStrip, { - props: makeProps({ loading: true }), - }) - expect(wrapper.find('.animate-shimmer').exists()).toBe(false) - expect(wrapper.find('.opacity-50').exists()).toBe(true) - }) - - it('renders 8 stat cards when stats loaded and not loading', () => { - const wrapper = mount(ReadingLogStatsStrip, { - props: makeProps(), - }) - expect(wrapper.findAll('.rounded-lg.border').length).toBe(8) - }) - - it('renders 8 stat cards when stats loaded even while re-fetching', () => { - const wrapper = mount(ReadingLogStatsStrip, { - props: makeProps({ loading: true }), - }) - expect(wrapper.findAll('.rounded-lg.border').length).toBe(8) - }) - - it('formats 3780 seconds as "1h 3m"', () => { - const wrapper = mount(ReadingLogStatsStrip, { - props: makeProps({ stats: makeStats({ totalSeconds: 3780 }) }), - }) - expect(wrapper.text()).toContain('1h 3m') - }) - - it('shows "-" for null firstSessionAt', () => { - const wrapper = mount(ReadingLogStatsStrip, { - props: makeProps({ stats: makeStats({ firstSessionAt: null }) }), - }) - expect(wrapper.text()).toContain('-') - }) - - it('shows 0 for 0 total sessions', () => { - const wrapper = mount(ReadingLogStatsStrip, { - props: makeProps({ stats: makeStats({ totalSessions: 0 }) }), - }) - expect(wrapper.text()).toContain('0') - }) - - it('formats 0 seconds as "0s"', () => { - const wrapper = mount(ReadingLogStatsStrip, { - props: makeProps({ stats: makeStats({ totalSeconds: 0, avgDurationSeconds: 0 }) }), - }) - expect(wrapper.text()).toContain('0s') - }) - - it('shows 0s total when stats is null and not loading', () => { - const wrapper = mount(ReadingLogStatsStrip, { - props: makeProps({ stats: null }), - }) - expect(wrapper.text()).toContain('0s') - }) - - it('does not apply opacity class when not loading', () => { - const wrapper = mount(ReadingLogStatsStrip, { - props: makeProps(), - }) - expect(wrapper.find('.opacity-50').exists()).toBe(false) - }) - - it('renders new derived stats cards', () => { - const wrapper = mount(ReadingLogStatsStrip, { - props: makeProps(), - }) - expect(wrapper.text()).toContain('Consistency') - expect(wrapper.text()).toContain('Momentum') - expect(wrapper.text()).toContain('Active Days') - }) -}) 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 f547e468a..cba81702d 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 @@ -67,7 +67,17 @@ function makeListResponse(items = []) { total: items.length, page: 1, pageSize: 25, - stats: { totalSessions: items.length, totalSeconds: 0, avgDurationSeconds: 0, firstSessionAt: null, lastSessionAt: null, dailySummary: [] }, + stats: { + totalSessions: items.length, + totalSeconds: 0, + avgDurationSeconds: 0, + firstSessionAt: null, + lastSessionAt: null, + dailySummary: [], + paceProgressDelta: 0, + paceDurationSeconds: 0, + progressSummary: [], + }, }), } as Response } @@ -88,14 +98,13 @@ describe('ReadingLogTab', () => { expect(wrapper.text()).toContain('This year') }) - it('sparkline is rendered but chart area is hidden when dailySummary is empty', async () => { + it('renders the hero and chart empty states when there is no data', async () => { const wrapper = mount(ReadingLogTab, { props: { book: makeBook() }, global: { plugins: [createPinia()] } }) await flushPromises() - const sparkline = wrapper.findComponent({ name: 'ReadingLogSparkline' }) - expect(sparkline.exists()).toBe(true) - const chartArea = sparkline.find('[style*="height: 120px"]') - expect(chartArea.exists()).toBe(false) + expect(wrapper.text()).toContain('Add session') + expect(wrapper.text()).toContain('No progress data in this window.') + expect(wrapper.text()).toContain('No reading activity in this window.') }) it('calls setFilters with dateFrom when "Last 30 days" is clicked', async () => { diff --git a/client/src/features/book/components/detail/tabs/__tests__/ReadingLogTable.spec.ts b/client/src/features/book/components/detail/tabs/__tests__/ReadingLogTable.spec.ts index 6a9d430e3..4fe09f2ae 100644 --- a/client/src/features/book/components/detail/tabs/__tests__/ReadingLogTable.spec.ts +++ b/client/src/features/book/components/detail/tabs/__tests__/ReadingLogTable.spec.ts @@ -11,6 +11,7 @@ function makeSession(overrides = {}) { progressDelta: 5.5, endProgress: 42.0, format: 'epub', + source: null, ...overrides, } } @@ -20,11 +21,11 @@ function mountTable(props = {}) { props: { sessions: [makeSession()], total: 1, - page: 1, - pageSize: 25, sortBy: 'startedAt', sortDir: 'desc' as const, loading: false, + loadingMore: false, + hasMore: false, hasMultipleFormats: false, ...props, }, @@ -42,16 +43,6 @@ describe('ReadingLogTable', () => { expect(wrapper.text()).toContain('No reading sessions recorded yet') }) - it('does not show edit inputs (edit mode removed)', () => { - const wrapper = mountTable() - expect(wrapper.find('input[type="datetime-local"]').exists()).toBe(false) - }) - - it('does not show edit pencil button (edit mode removed)', () => { - const wrapper = mountTable() - expect(wrapper.find('button[title="Edit"]').exists()).toBe(false) - }) - it('first delete click enters confirm state', async () => { const wrapper = mountTable() const deleteBtn = wrapper.find('button[title="Delete"]') @@ -116,6 +107,78 @@ describe('ReadingLogTable', () => { expect(wrapper.find('tbody').text()).toContain('-') }) + it('shows pace per hour derived from delta and duration', () => { + const wrapper = mountTable({ sessions: [makeSession({ progressDelta: 5, durationSeconds: 1800 })] }) + expect(wrapper.text()).toContain('10.0%/hr') + }) + + it('shows "-" pace when progressDelta is null', () => { + const wrapper = mountTable({ sessions: [makeSession({ progressDelta: null })] }) + const paceCells = wrapper.findAll('td.hidden') + expect(paceCells.some((cell) => cell.text() === '-')).toBe(true) + }) + + it('shows "-" pace when progressDelta is negative', () => { + const wrapper = mountTable({ sessions: [makeSession({ progressDelta: -70.2, durationSeconds: 26 })] }) + const paceCells = wrapper.findAll('td.hidden') + expect(paceCells.some((cell) => cell.text() === '-')).toBe(true) + expect(wrapper.text()).not.toContain('-9715.3%/hr') + }) + + it('groups sessions under day headers with subtotals when sorted by date', () => { + // Local-time constructors keep the grouping assertions timezone-agnostic. + const day1Morning = new Date(2026, 3, 15, 10, 0).toISOString() + const day1Evening = new Date(2026, 3, 15, 20, 0).toISOString() + const day2Morning = new Date(2026, 3, 14, 9, 0).toISOString() + const wrapper = mountTable({ + sessions: [ + makeSession({ id: 1, startedAt: day1Morning, durationSeconds: 1800, progressDelta: 3 }), + makeSession({ id: 2, startedAt: day1Evening, durationSeconds: 1800, progressDelta: 2 }), + makeSession({ id: 3, startedAt: day2Morning, durationSeconds: 600, progressDelta: null }), + ], + total: 3, + }) + + const headerRows = wrapper.findAll('tbody tr.bg-muted\\/40') + expect(headerRows.length).toBe(2) + expect(headerRows[0]!.text()).toContain('1h 0m') + expect(headerRows[0]!.text()).toContain('+5.0%') + expect(headerRows[1]!.text()).toContain('10m') + expect(headerRows[1]!.text()).not.toContain('%') + }) + + it('renders flat rows without day headers when sorted by another column', () => { + const wrapper = mountTable({ + sessions: [ + makeSession({ id: 1, startedAt: new Date(2026, 3, 15, 10, 0).toISOString() }), + makeSession({ id: 2, startedAt: new Date(2026, 3, 14, 9, 0).toISOString() }), + ], + total: 2, + sortBy: 'durationSeconds', + }) + + expect(wrapper.findAll('tbody tr.bg-muted\\/40').length).toBe(0) + expect(wrapper.find('tbody').findAll('tr').length).toBe(2) + }) + + it('shows source pills when sessions carry a source', () => { + const wrapper = mountTable({ + sessions: [makeSession({ id: 1, source: 'koreader' }), makeSession({ id: 2, source: 'manual' })], + total: 2, + }) + + expect(wrapper.text()).toContain('KOReader') + expect(wrapper.text()).toContain('Manual') + const headers = wrapper.findAll('th') + expect(headers.some((h) => h.text() === 'Source')).toBe(true) + }) + + it('hides the source column when no session has a source', () => { + const wrapper = mountTable({ sessions: [makeSession({ source: null })] }) + const headers = wrapper.findAll('th') + expect(headers.some((h) => h.text() === 'Source')).toBe(false) + }) + it('does not show format column when hasMultipleFormats is false', () => { const wrapper = mountTable({ hasMultipleFormats: false }) const headers = wrapper.findAll('th') @@ -157,36 +220,29 @@ describe('ReadingLogTable', () => { expect(wrapper.emitted('sortChange')?.[0]).toEqual(['startedAt', 'asc']) }) - it('prev button emits pageChange with page-1', async () => { - const wrapper = mountTable({ total: 30, page: 2, pageSize: 25 }) - const prevBtn = wrapper.findAll('button').find((b) => b.text() === 'Prev') - expect(prevBtn).toBeDefined() - await prevBtn!.trigger('click') - expect(wrapper.emitted('pageChange')?.[0]).toEqual([1]) + it('shows a Load more button when hasMore is true and emits loadMore', async () => { + const wrapper = mountTable({ total: 30, hasMore: true }) + const loadMoreBtn = wrapper.findAll('button').find((b) => b.text().includes('Load more')) + expect(loadMoreBtn).toBeDefined() + await loadMoreBtn!.trigger('click') + expect(wrapper.emitted('loadMore')).toBeTruthy() }) - it('next button emits pageChange with page+1', async () => { - const wrapper = mountTable({ total: 30, page: 1, pageSize: 25 }) - const nextBtn = wrapper.findAll('button').find((b) => b.text() === 'Next') - await nextBtn!.trigger('click') - expect(wrapper.emitted('pageChange')?.[0]).toEqual([2]) + it('hides the Load more button when everything is loaded', () => { + const wrapper = mountTable({ total: 1, hasMore: false }) + const loadMoreBtn = wrapper.findAll('button').find((b) => b.text().includes('Load more')) + expect(loadMoreBtn).toBeUndefined() }) - it('prev button is disabled on first page', () => { - const wrapper = mountTable({ page: 1 }) - const prevBtn = wrapper.findAll('button').find((b) => b.text() === 'Prev') - expect(prevBtn?.attributes('disabled')).toBeDefined() + it('disables the Load more button while loading more', () => { + const wrapper = mountTable({ total: 30, hasMore: true, loadingMore: true }) + const loadMoreBtn = wrapper.findAll('button').find((b) => b.text().includes('Load more')) + expect(loadMoreBtn?.attributes('disabled')).toBeDefined() }) - it('next button is disabled on last page', () => { - const wrapper = mountTable({ total: 1, page: 1, pageSize: 25 }) - const nextBtn = wrapper.findAll('button').find((b) => b.text() === 'Next') - expect(nextBtn?.attributes('disabled')).toBeDefined() - }) - - it('shows pagination range correctly', () => { - const wrapper = mountTable({ total: 50, page: 2, pageSize: 25 }) - expect(wrapper.text()).toContain('26-50 of 50') + it('shows the accumulated count', () => { + const wrapper = mountTable({ sessions: [makeSession({ id: 1 }), makeSession({ id: 2 })], total: 50 }) + expect(wrapper.text()).toContain('Showing 2 of 50 sessions') }) it('clears confirm state when sessions prop changes', async () => { @@ -209,16 +265,6 @@ describe('ReadingLogTable', () => { expect(wrapper.find('button[title="Click again to confirm delete"]').exists()).toBe(false) }) - it('clears confirm state when navigating to next page', async () => { - const wrapper = mountTable({ total: 30, page: 1, pageSize: 25 }) - const deleteBtn = wrapper.find('button[title="Delete"]') - await deleteBtn.trigger('click') - expect(wrapper.find('button[title="Click again to confirm delete"]').exists()).toBe(true) - const nextBtn = wrapper.findAll('button').find((b) => b.text() === 'Next') - await nextBtn!.trigger('click') - expect(wrapper.find('button[title="Click again to confirm delete"]').exists()).toBe(false) - }) - it('clears confirm state when cancel delete is clicked', async () => { const wrapper = mountTable() const deleteBtn = wrapper.find('button[title="Delete"]') diff --git a/client/src/features/book/composables/__tests__/useBookReadingLog.spec.ts b/client/src/features/book/composables/__tests__/useBookReadingLog.spec.ts index 0d60430dc..36e50dddc 100644 --- a/client/src/features/book/composables/__tests__/useBookReadingLog.spec.ts +++ b/client/src/features/book/composables/__tests__/useBookReadingLog.spec.ts @@ -19,13 +19,39 @@ function makeResponse(data: unknown, status = 200): Response { } as Response } -function makeListResponse(items = [], total?: number) { +const emptyStats = { + totalSessions: 0, + totalSeconds: 0, + avgDurationSeconds: 0, + firstSessionAt: null, + lastSessionAt: null, + dailySummary: [], + paceProgressDelta: 0, + paceDurationSeconds: 0, + progressSummary: [], +} + +function makeSession(id: number, overrides?: Record) { + return { + id, + startedAt: '2026-04-15T10:00:00.000Z', + endedAt: '2026-04-15T10:30:00.000Z', + durationSeconds: 1800, + progressDelta: null, + endProgress: null, + format: null, + source: 'web', + ...overrides, + } +} + +function makeListResponse(items: unknown[] = [], total?: number) { return makeResponse({ items, total: total ?? items.length, page: 1, pageSize: 25, - stats: { totalSessions: 0, totalSeconds: 0, avgDurationSeconds: 0, firstSessionAt: null, lastSessionAt: null, dailySummary: [] }, + stats: emptyStats, }) } @@ -44,29 +70,14 @@ describe('useBookReadingLog', () => { }) it('updates sessions and stats after fetch', async () => { - const session = { - id: 1, - startedAt: '2026-04-15T10:00:00.000Z', - endedAt: '2026-04-15T10:30:00.000Z', - durationSeconds: 1800, - progressDelta: null, - endProgress: null, - format: 'epub', - } + const session = makeSession(1, { format: 'epub' }) mocks.api.mockResolvedValue( makeResponse({ items: [session], total: 1, page: 1, pageSize: 25, - stats: { - totalSessions: 1, - totalSeconds: 1800, - avgDurationSeconds: 1800, - firstSessionAt: session.startedAt, - lastSessionAt: session.startedAt, - dailySummary: [], - }, + stats: { ...emptyStats, totalSessions: 1, totalSeconds: 1800, avgDurationSeconds: 1800 }, }), ) @@ -107,60 +118,30 @@ describe('useBookReadingLog', () => { expect(error.value).toContain('network error') }) - it('deleteSession removes item optimistically then refetches', async () => { - const session = { - id: 1, - startedAt: '2026-04-15T10:00:00.000Z', - endedAt: '2026-04-15T10:30:00.000Z', - durationSeconds: 1800, - progressDelta: null, - endProgress: null, - format: null, - } + it('deleteSession removes item optimistically then refreshes stats only', async () => { mocks.api - .mockResolvedValueOnce( - makeResponse({ - items: [session], - total: 1, - page: 1, - pageSize: 25, - stats: { totalSessions: 1, totalSeconds: 1800, avgDurationSeconds: 1800, firstSessionAt: null, lastSessionAt: null, dailySummary: [] }, - }), - ) + .mockResolvedValueOnce(makeListResponse([makeSession(1), makeSession(2)], 2)) .mockResolvedValueOnce({ ok: true, status: 204, json: async () => null } as Response) - .mockResolvedValueOnce(makeListResponse()) + .mockResolvedValueOnce(makeListResponse([makeSession(2)], 1)) const bookId = ref(10) - const { sessions, deleteSession } = useBookReadingLog(bookId) + const { sessions, total, deleteSession } = useBookReadingLog(bookId) await nextTick() await nextTick() await deleteSession(1) await nextTick() - expect(sessions.value).toHaveLength(0) + expect(sessions.value.map((s) => s.id)).toEqual([2]) + expect(total.value).toBe(1) + const statsUrl = mocks.api.mock.calls[2]?.[0] as string + expect(statsUrl).toContain('page=1') + expect(statsUrl).toContain('pageSize=1') }) it('deleteSession rolls back sessions and total on error', async () => { - const session = { - id: 1, - startedAt: '2026-04-15T10:00:00.000Z', - endedAt: '2026-04-15T10:30:00.000Z', - durationSeconds: 1800, - progressDelta: null, - endProgress: null, - format: null, - } mocks.api - .mockResolvedValueOnce( - makeResponse({ - items: [session], - total: 5, - page: 1, - pageSize: 25, - stats: { totalSessions: 5, totalSeconds: 0, avgDurationSeconds: 0, firstSessionAt: null, lastSessionAt: null, dailySummary: [] }, - }), - ) + .mockResolvedValueOnce(makeListResponse([makeSession(1)], 5)) .mockResolvedValueOnce({ ok: false, status: 404, json: async () => null } as Response) const bookId = ref(10) @@ -217,24 +198,103 @@ describe('useBookReadingLog', () => { expect(url).toContain('sortDir=asc') }) - it('setPage changes page and fetches', async () => { - mocks.api.mockResolvedValue(makeListResponse()) + it('loadMore appends the next page without duplicates', async () => { + const firstPage = Array.from({ length: 25 }, (_, i) => makeSession(i + 1)) + mocks.api.mockResolvedValueOnce(makeListResponse(firstPage, 30)) const bookId = ref(10) - const { setPage, page } = useBookReadingLog(bookId) + const { sessions, hasMore, loadMore } = useBookReadingLog(bookId) + await nextTick() + await nextTick() + + expect(sessions.value).toHaveLength(25) + expect(hasMore.value).toBe(true) + + mocks.api.mockClear() + const secondPage = [makeSession(25), ...Array.from({ length: 5 }, (_, i) => makeSession(i + 26))] + mocks.api.mockResolvedValueOnce(makeListResponse(secondPage, 30)) + + await loadMore() + + const url = mocks.api.mock.calls[0]?.[0] as string + expect(url).toContain('page=2') + expect(sessions.value).toHaveLength(30) + expect(hasMore.value).toBe(false) + }) + + it('loadMore is a no-op when everything is loaded', async () => { + mocks.api.mockResolvedValueOnce(makeListResponse([makeSession(1)], 1)) + + const bookId = ref(10) + const { loadMore } = useBookReadingLog(bookId) await nextTick() await nextTick() mocks.api.mockClear() - mocks.api.mockResolvedValue(makeListResponse()) + await loadMore() - setPage(3) + expect(mocks.api).not.toHaveBeenCalled() + }) + + it('addSession posts the payload and refetches from page 1', async () => { + mocks.api.mockResolvedValueOnce(makeListResponse()) + + const bookId = ref(10) + const { addSession } = useBookReadingLog(bookId) await nextTick() await nextTick() - expect(page.value).toBe(3) - const url = mocks.api.mock.calls[0]?.[0] as string - expect(url).toContain('page=3') + mocks.api.mockClear() + mocks.api + .mockResolvedValueOnce(makeResponse({ id: 9, source: 'manual' }, 201)) + .mockResolvedValueOnce(makeListResponse([makeSession(9, { source: 'manual' })], 1)) + + await addSession({ startedAt: '2026-04-15T10:00:00.000Z', durationMinutes: 30, endProgress: 50 }) + + const [postUrl, postInit] = mocks.api.mock.calls[0] as [string, RequestInit] + expect(postUrl).toBe('/api/v1/books/10/sessions') + expect(postInit.method).toBe('POST') + expect(JSON.parse(postInit.body as string)).toMatchObject({ durationMinutes: 30, endProgress: 50 }) + const refetchUrl = mocks.api.mock.calls[1]?.[0] as string + expect(refetchUrl).toContain('page=1') + }) + + it('addSession throws with the server message on failure', async () => { + mocks.api.mockResolvedValueOnce(makeListResponse()) + + const bookId = ref(10) + const { addSession } = useBookReadingLog(bookId) + await nextTick() + await nextTick() + + mocks.api.mockClear() + mocks.api.mockResolvedValueOnce(makeResponse({ message: 'startedAt cannot be in the future' }, 400)) + + await expect(addSession({ startedAt: '2099-01-01T00:00:00.000Z', durationMinutes: 30 })).rejects.toThrow('startedAt cannot be in the future') + }) + + it('exportAll pages through the full list at pageSize 100', async () => { + mocks.api.mockResolvedValueOnce(makeListResponse()) + + const bookId = ref(10) + const { exportAll } = useBookReadingLog(bookId) + await nextTick() + await nextTick() + + mocks.api.mockClear() + const pageOne = Array.from({ length: 100 }, (_, i) => makeSession(i + 1)) + const pageTwo = Array.from({ length: 20 }, (_, i) => makeSession(i + 101)) + mocks.api.mockResolvedValueOnce(makeListResponse(pageOne, 120)).mockResolvedValueOnce(makeListResponse(pageTwo, 120)) + + const items = await exportAll() + + expect(items).toHaveLength(120) + expect(mocks.api).toHaveBeenCalledTimes(2) + const firstUrl = mocks.api.mock.calls[0]?.[0] as string + expect(firstUrl).toContain('pageSize=100') + expect(firstUrl).toContain('page=1') + const secondUrl = mocks.api.mock.calls[1]?.[0] as string + expect(secondUrl).toContain('page=2') }) it('loading is true while fetching and false after', async () => { diff --git a/client/src/features/book/composables/useBookHighlights.test.ts b/client/src/features/book/composables/useBookHighlights.test.ts index bb04fbaef..7708261ad 100644 --- a/client/src/features/book/composables/useBookHighlights.test.ts +++ b/client/src/features/book/composables/useBookHighlights.test.ts @@ -52,8 +52,7 @@ describe('useBookHighlights', () => { }) it('fetches highlights on mount', async () => { - const response = makeListResponse() - mockFetch.mockResolvedValue(mockOkResponse(response)) + mockFetch.mockResolvedValue(mockOkResponse(makeListResponse())) const bookId = ref(5) const { items, total, stats, loading } = useBookHighlights(bookId) @@ -77,7 +76,7 @@ describe('useBookHighlights', () => { expect(error.value).toBe('HTTP 500') }) - it('includes pagination params in URL', async () => { + it('includes pagination and sort params in the URL', async () => { mockFetch.mockResolvedValue(mockOkResponse(makeListResponse())) const bookId = ref(5) useBookHighlights(bookId) @@ -92,87 +91,62 @@ describe('useBookHighlights', () => { expect(url).toContain('sortDir=asc') }) - it('toggleColor adds and removes colors', async () => { + it('reloads with the selected colors when the colors filter changes', async () => { mockFetch.mockResolvedValue(mockOkResponse(makeListResponse())) const bookId = ref(5) - const { colors, toggleColor } = useBookHighlights(bookId) + const { colors } = useBookHighlights(bookId) await nextTick() await vi.waitFor(() => expect(mockFetch).toHaveBeenCalled()) mockFetch.mockClear() - toggleColor('#FACC15') - expect(colors.value).toContain('#FACC15') + colors.value = ['#FACC15'] await vi.waitFor(() => expect(mockFetch).toHaveBeenCalled()) - const url = mockFetch.mock.calls[0]?.[0] as string - expect(url).toContain('colors=%23FACC15') - - mockFetch.mockClear() - mockFetch.mockResolvedValue(mockOkResponse(makeListResponse())) - toggleColor('#FACC15') - expect(colors.value).not.toContain('#FACC15') + expect(mockFetch.mock.calls[0]?.[0]).toContain('colors=%23FACC15') }) - it('setSort changes sort params and resets page', async () => { + it('maps the merged sort key to sortBy/sortDir', async () => { mockFetch.mockResolvedValue(mockOkResponse(makeListResponse())) const bookId = ref(5) - const { page, sortBy, sortDir, setSort } = useBookHighlights(bookId) + const { sortBy, sortDir, sortKey } = useBookHighlights(bookId) await nextTick() await vi.waitFor(() => expect(mockFetch).toHaveBeenCalled()) - mockFetch.mockClear() - mockFetch.mockResolvedValue(mockOkResponse(makeListResponse())) - - setSort('createdAt', 'desc') + expect(sortKey.value).toBe('position') + sortKey.value = 'newest' expect(sortBy.value).toBe('createdAt') expect(sortDir.value).toBe('desc') - expect(page.value).toBe(1) + sortKey.value = 'oldest' + expect(sortDir.value).toBe('asc') }) - it('setPage changes the page', async () => { + it('reloads when the page changes', async () => { mockFetch.mockResolvedValue(mockOkResponse(makeListResponse())) const bookId = ref(5) - const { page, setPage } = useBookHighlights(bookId) + const { page } = useBookHighlights(bookId) await nextTick() await vi.waitFor(() => expect(mockFetch).toHaveBeenCalled()) mockFetch.mockClear() - mockFetch.mockResolvedValue(mockOkResponse(makeListResponse())) - setPage(3) + page.value = 3 + await vi.waitFor(() => expect(mockFetch).toHaveBeenCalled()) expect(page.value).toBe(3) + expect(mockFetch.mock.calls[0]?.[0]).toContain('page=3') }) - it('setSearch updates search and resets page', async () => { + it('updateNote optimistically patches the item and PATCHes the endpoint', async () => { mockFetch.mockResolvedValue(mockOkResponse(makeListResponse())) const bookId = ref(5) - const { search, setSearch } = useBookHighlights(bookId) - - await nextTick() - await vi.waitFor(() => expect(mockFetch).toHaveBeenCalled()) - mockFetch.mockClear() - mockFetch.mockResolvedValue(mockOkResponse(makeListResponse())) - - setSearch('freedom') - - expect(search.value).toBe('freedom') - }) - - it('updateNote patches annotation and updates items', async () => { - const initial = makeListResponse() - mockFetch.mockResolvedValue(mockOkResponse(initial)) - const bookId = ref(5) const { items, updateNote } = useBookHighlights(bookId) await nextTick() await vi.waitFor(() => expect(items.value).toHaveLength(1)) mockFetch.mockClear() - - const updatedItem = { ...initial.items[0], note: 'my note' } - mockFetch.mockResolvedValue(mockOkResponse(updatedItem)) + mockFetch.mockResolvedValue(mockOkResponse({})) await updateNote(1, 'my note') @@ -180,18 +154,15 @@ describe('useBookHighlights', () => { expect(items.value[0]?.note).toBe('my note') }) - it('updateColor patches annotation color', async () => { - const initial = makeListResponse() - mockFetch.mockResolvedValue(mockOkResponse(initial)) + it('updateColor optimistically patches the item color', async () => { + mockFetch.mockResolvedValue(mockOkResponse(makeListResponse())) const bookId = ref(5) const { items, updateColor } = useBookHighlights(bookId) await nextTick() await vi.waitFor(() => expect(items.value).toHaveLength(1)) mockFetch.mockClear() - - const updatedItem = { ...initial.items[0], color: '#4ADE80' } - mockFetch.mockResolvedValue(mockOkResponse(updatedItem)) + mockFetch.mockResolvedValue(mockOkResponse({})) await updateColor(1, '#4ADE80') @@ -199,8 +170,7 @@ describe('useBookHighlights', () => { }) it('deleteHighlight removes item optimistically and refetches', async () => { - const initial = makeListResponse() - mockFetch.mockResolvedValue(mockOkResponse(initial)) + mockFetch.mockResolvedValue(mockOkResponse(makeListResponse())) const bookId = ref(5) const { items, deleteHighlight } = useBookHighlights(bookId) @@ -216,8 +186,7 @@ describe('useBookHighlights', () => { }) it('deleteHighlight reverts on failure', async () => { - const initial = makeListResponse() - mockFetch.mockResolvedValue(mockOkResponse(initial)) + mockFetch.mockResolvedValue(mockOkResponse(makeListResponse())) const bookId = ref(5) const { items, total, error, deleteHighlight } = useBookHighlights(bookId) @@ -234,46 +203,219 @@ describe('useBookHighlights', () => { expect(error.value).toBeTruthy() }) - it('resets state when bookId changes', async () => { - const initial = makeListResponse() - mockFetch.mockResolvedValue(mockOkResponse(initial)) + it('builds color/date chips and resets all filters', async () => { + mockFetch.mockResolvedValue(mockOkResponse(makeListResponse())) const bookId = ref(5) - const { colors, search, chapter, page, toggleColor, setSearch, setChapter, setPage } = useBookHighlights(bookId) + const { colors, dateFrom, chapter, search, activeFilterChips, popoverFilterCount, hasActiveFilters, resetAllFilters } = useBookHighlights(bookId) await nextTick() await vi.waitFor(() => expect(mockFetch).toHaveBeenCalled()) - toggleColor('#FACC15') - setSearch('test') - setChapter('Chapter 1') - setPage(3) + colors.value = ['#FACC15'] + dateFrom.value = '2026-01-10' + chapter.value = 'Chapter 1' + search.value = 'freedom' + expect(hasActiveFilters.value).toBe(true) + expect(popoverFilterCount.value).toBe(2) + expect(activeFilterChips.value.map((chip) => chip.id)).toEqual(['color:#FACC15', 'date']) + + resetAllFilters() + expect(colors.value).toEqual([]) + expect(dateFrom.value).toBe('') + expect(chapter.value).toBe('') + expect(search.value).toBe('') + expect(hasActiveFilters.value).toBe(false) + }) + + it('bulkTrash returns 0 without calling the API for an empty selection', async () => { + mockFetch.mockResolvedValue(mockOkResponse(makeListResponse())) + const bookId = ref(5) + const { bulkTrash } = useBookHighlights(bookId) + + await nextTick() + await vi.waitFor(() => expect(mockFetch).toHaveBeenCalled()) + mockFetch.mockClear() + + expect(await bulkTrash([])).toBe(0) + expect(mockFetch).not.toHaveBeenCalled() + }) + + it('bulkTrash posts the trash action, optimistically drops the item, and returns the affected count', async () => { + mockFetch.mockResolvedValue(mockOkResponse(makeListResponse())) + const bookId = ref(5) + const { items, bulkTrash } = useBookHighlights(bookId) + + await nextTick() + await vi.waitFor(() => expect(items.value).toHaveLength(1)) + mockFetch.mockClear() + mockFetch + .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ affected: 1 }) }) + .mockResolvedValueOnce(mockOkResponse(makeListResponse({ items: [], total: 0 }))) + + const affected = await bulkTrash([1]) + + expect(affected).toBe(1) + const init = mockFetch.mock.calls[0]?.[1] as RequestInit + expect(mockFetch.mock.calls[0]?.[0]).toBe('/api/v1/annotations/bulk') + expect(JSON.parse(String(init.body))).toEqual({ ids: [1], action: 'trash' }) + }) + + it('bulkTrash reverts items and total when the request fails', async () => { + mockFetch.mockResolvedValue(mockOkResponse(makeListResponse())) + const bookId = ref(5) + const { items, total, error, bulkTrash } = useBookHighlights(bookId) + + await nextTick() + await vi.waitFor(() => expect(items.value).toHaveLength(1)) + mockFetch.mockClear() + mockFetch.mockResolvedValue(mockErrorResponse()) + + const affected = await bulkTrash([1]) + + expect(affected).toBe(0) + expect(items.value).toHaveLength(1) + expect(total.value).toBe(1) + expect(error.value).toBeTruthy() + }) + + it('bulkRestyle posts the restyle patch and returns the affected count', async () => { + mockFetch.mockResolvedValue(mockOkResponse(makeListResponse())) + const bookId = ref(5) + const { bulkRestyle } = useBookHighlights(bookId) + + await nextTick() + await vi.waitFor(() => expect(mockFetch).toHaveBeenCalled()) + mockFetch.mockClear() + mockFetch + .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ affected: 2 }) }) + .mockResolvedValueOnce(mockOkResponse(makeListResponse())) + + const affected = await bulkRestyle([1, 2], { color: '#4ADE80' }) + + expect(affected).toBe(2) + const init = mockFetch.mock.calls[0]?.[1] as RequestInit + expect(JSON.parse(String(init.body))).toEqual({ ids: [1, 2], action: 'restyle', color: '#4ADE80' }) + }) + + it('bulkRestyle returns 0 for an empty selection and sets an error on failure', async () => { + mockFetch.mockResolvedValue(mockOkResponse(makeListResponse())) + const bookId = ref(5) + const { bulkRestyle, error } = useBookHighlights(bookId) + + await nextTick() + await vi.waitFor(() => expect(mockFetch).toHaveBeenCalled()) + + expect(await bulkRestyle([], { color: '#fff' })).toBe(0) + + mockFetch.mockClear() + mockFetch.mockResolvedValue(mockErrorResponse()) + expect(await bulkRestyle([1], { style: 'underline' })).toBe(0) + expect(error.value).toBeTruthy() + }) + + it('removeFilterChip clears a single color or the whole date range', async () => { + mockFetch.mockResolvedValue(mockOkResponse(makeListResponse())) + const bookId = ref(5) + const { colors, dateFrom, dateTo, removeFilterChip } = useBookHighlights(bookId) + + await nextTick() + await vi.waitFor(() => expect(mockFetch).toHaveBeenCalled()) + + colors.value = ['#FACC15', '#4ADE80'] + removeFilterChip('color:#FACC15') + expect(colors.value).toEqual(['#4ADE80']) + + dateFrom.value = '2026-01-01' + dateTo.value = '2026-01-31' + removeFilterChip('date') + expect(dateFrom.value).toBe('') + expect(dateTo.value).toBe('') + }) + + it('maps the position sort key back to sortBy/sortDir', async () => { + mockFetch.mockResolvedValue(mockOkResponse(makeListResponse())) + const bookId = ref(5) + const { sortKey, sortBy, sortDir } = useBookHighlights(bookId) + + await nextTick() + await vi.waitFor(() => expect(mockFetch).toHaveBeenCalled()) + + sortKey.value = 'newest' + expect(sortBy.value).toBe('createdAt') + + sortKey.value = 'position' + expect(sortBy.value).toBe('position') + expect(sortDir.value).toBe('asc') + }) + + it('refetches exactly once when a filter changes while on a later page', async () => { + mockFetch.mockResolvedValue(mockOkResponse(makeListResponse())) + const bookId = ref(5) + const { page, colors } = useBookHighlights(bookId) + + await nextTick() + await vi.waitFor(() => expect(mockFetch).toHaveBeenCalled()) + + page.value = 3 + await nextTick() + mockFetch.mockClear() + + colors.value = ['#FACC15'] + await nextTick() + + expect(page.value).toBe(1) + expect(mockFetch).toHaveBeenCalledTimes(1) + }) + + it('discards a stale fetch when a newer fetch supersedes it', async () => { + mockFetch.mockResolvedValue(mockOkResponse(makeListResponse())) + const bookId = ref(5) + const { total, loading, fetchHighlights } = useBookHighlights(bookId) + + await nextTick() + await vi.waitFor(() => expect(loading.value).toBe(false)) + + let resolveStale!: (res: unknown) => void + const stalePending = new Promise((resolve) => { + resolveStale = resolve + }) + mockFetch.mockReset() + mockFetch.mockReturnValueOnce(stalePending).mockResolvedValueOnce(mockOkResponse(makeListResponse({ total: 9 }))) + + const stale = fetchHighlights() + const fresh = fetchHighlights() + await fresh + + expect(total.value).toBe(9) + + resolveStale(mockOkResponse(makeListResponse({ total: 1 }))) + await stale + + expect(total.value).toBe(9) + expect(loading.value).toBe(false) + }) + + it('resets state when bookId changes', async () => { + mockFetch.mockResolvedValue(mockOkResponse(makeListResponse())) + const bookId = ref(5) + const { colors, search, chapter, page } = useBookHighlights(bookId) + + await nextTick() + await vi.waitFor(() => expect(mockFetch).toHaveBeenCalled()) + + colors.value = ['#FACC15'] + chapter.value = 'Chapter 1' + page.value = 3 mockFetch.mockClear() mockFetch.mockResolvedValue(mockOkResponse(makeListResponse())) bookId.value = 10 - await nextTick() expect(colors.value).toEqual([]) expect(search.value).toBe('') - expect(chapter.value).toBeUndefined() + expect(chapter.value).toBe('') expect(page.value).toBe(1) }) - - it('setDateRange updates date filters', async () => { - mockFetch.mockResolvedValue(mockOkResponse(makeListResponse())) - const bookId = ref(5) - const { dateFrom, dateTo, setDateRange } = useBookHighlights(bookId) - - await nextTick() - await vi.waitFor(() => expect(mockFetch).toHaveBeenCalled()) - mockFetch.mockClear() - mockFetch.mockResolvedValue(mockOkResponse(makeListResponse())) - - setDateRange('2026-01-01', '2026-06-01') - - expect(dateFrom.value).toBe('2026-01-01') - expect(dateTo.value).toBe('2026-06-01') - }) }) diff --git a/client/src/features/book/composables/useBookHighlights.ts b/client/src/features/book/composables/useBookHighlights.ts index e76efe751..6fa336784 100644 --- a/client/src/features/book/composables/useBookHighlights.ts +++ b/client/src/features/book/composables/useBookHighlights.ts @@ -1,25 +1,66 @@ -import { computed, ref, watch, type Ref } from 'vue' +import { computed, nextTick, ref, watch, type Ref } from 'vue' import type { AnnotationItem, AnnotationListResponse, AnnotationStats } from '@bookorbit/types' import { api } from '@/lib/api' +import { buildFilterChips } from '@/features/annotations/lib/filter-chips' +import { useAnnotationSelection } from '@/features/annotations/composables/useAnnotationSelection' +import { useAnnotationMutations } from '@/features/annotations/composables/useAnnotationMutations' + +export type BookSortKey = 'position' | 'newest' | 'oldest' + +const SEARCH_RELOAD_DEBOUNCE_MS = 300 export function useBookHighlights(bookIdRef: Ref) { const items = ref([]) const total = ref(0) + const pageSize = ref(25) + const page = ref(1) const stats = ref(null) const loading = ref(false) const error = ref(null) - const page = ref(1) - const pageSize = ref(25) + const sortBy = ref<'position' | 'createdAt'>('position') const sortDir = ref<'asc' | 'desc'>('asc') const colors = ref([]) const search = ref('') - const chapter = ref(undefined) - const dateFrom = ref(undefined) - const dateTo = ref(undefined) + const chapter = ref('') + const dateFrom = ref('') + const dateTo = ref('') + const hydratingBook = ref(false) + const selection = useAnnotationSelection(items) + const mutations = useAnnotationMutations(items, () => bookIdRef.value) + + const chapters = computed(() => stats.value?.chapters ?? []) + const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value))) + const rangeStart = computed(() => (total.value === 0 ? 0 : Math.min((page.value - 1) * pageSize.value + 1, total.value))) + const rangeEnd = computed(() => Math.min(page.value * pageSize.value, total.value)) + + const sortKey = computed({ + get() { + if (sortBy.value === 'position') return 'position' + return sortDir.value === 'desc' ? 'newest' : 'oldest' + }, + set(value) { + if (value === 'position') { + sortBy.value = 'position' + sortDir.value = 'asc' + } else { + sortBy.value = 'createdAt' + sortDir.value = value === 'newest' ? 'desc' : 'asc' + } + }, + }) + + const activeFilterChips = computed(() => buildFilterChips({ colors: colors.value, dateFrom: dateFrom.value, dateTo: dateTo.value })) + const popoverFilterCount = computed(() => activeFilterChips.value.length) + const hasActiveFilters = computed( + () => search.value.trim() !== '' || colors.value.length > 0 || Boolean(chapter.value) || dateFrom.value !== '' || dateTo.value !== '', + ) + + let fetchSeq = 0 async function fetchHighlights() { const bookId = bookIdRef.value + const seq = ++fetchSeq loading.value = true error.value = null try { @@ -36,48 +77,49 @@ export function useBookHighlights(bookIdRef: Ref) { if (dateTo.value) params.set('dateTo', dateTo.value) const res = await api(`/api/v1/books/${bookId}/annotations?${params.toString()}`) + if (seq !== fetchSeq) return if (!res.ok) throw new Error(`HTTP ${res.status}`) const data: AnnotationListResponse = await res.json() + if (seq !== fetchSeq) return items.value = data.items total.value = data.total stats.value = data.stats } catch (e) { - error.value = e instanceof Error ? e.message : 'Failed to load highlights' + if (seq === fetchSeq) error.value = e instanceof Error ? e.message : 'Failed to load highlights' } finally { - loading.value = false + if (seq === fetchSeq) loading.value = false } } - async function updateNote(annotationId: number, note: string | null) { - try { - const bookId = bookIdRef.value - const res = await api(`/api/v1/books/${bookId}/annotations/${annotationId}`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ note }), - }) - if (!res.ok) throw new Error(`HTTP ${res.status}`) - const updated: AnnotationItem = await res.json() - items.value = items.value.map((a) => (a.id === annotationId ? updated : a)) - } catch (e) { - error.value = e instanceof Error ? e.message : 'Failed to update note' - } + function reloadFromFilterChange() { + selection.clearSelection() + if (page.value === 1) void fetchHighlights() + else page.value = 1 } - async function updateColor(annotationId: number, color: string) { - try { - const bookId = bookIdRef.value - const res = await api(`/api/v1/books/${bookId}/annotations/${annotationId}`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ color }), - }) - if (!res.ok) throw new Error(`HTTP ${res.status}`) - const updated: AnnotationItem = await res.json() - items.value = items.value.map((a) => (a.id === annotationId ? updated : a)) - } catch (e) { - error.value = e instanceof Error ? e.message : 'Failed to update color' + function clearDates() { + dateFrom.value = '' + dateTo.value = '' + } + + function clearPopoverFilters() { + colors.value = [] + clearDates() + } + + function resetAllFilters() { + search.value = '' + chapter.value = '' + clearPopoverFilters() + } + + function removeFilterChip(id: string) { + if (id.startsWith('color:')) { + const hex = id.slice('color:'.length) + colors.value = colors.value.filter((value) => value !== hex) + return } + if (id === 'date') clearDates() } async function deleteHighlight(annotationId: number) { @@ -97,88 +139,125 @@ export function useBookHighlights(bookIdRef: Ref) { } } - function setPage(p: number) { - page.value = p - void fetchHighlights() - } - - function setSort(by: typeof sortBy.value, dir: typeof sortDir.value) { - sortBy.value = by - sortDir.value = dir - page.value = 1 - void fetchHighlights() - } - - function toggleColor(color: string) { - if (colors.value.includes(color)) { - colors.value = colors.value.filter((c) => c !== color) - } else { - colors.value = [...colors.value, color] + async function bulkTrash(annotationIds: number[]): Promise { + if (annotationIds.length === 0) return 0 + const prev = items.value + const prevTotal = total.value + items.value = items.value.filter((a) => !annotationIds.includes(a.id)) + total.value = Math.max(0, total.value - annotationIds.length) + try { + const res = await api('/api/v1/annotations/bulk', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ids: annotationIds, action: 'trash' }), + }) + if (!res.ok) throw new Error(`HTTP ${res.status}`) + const body = (await res.json()) as { affected: number } + await fetchHighlights() + return body.affected + } catch (e) { + items.value = prev + total.value = prevTotal + error.value = e instanceof Error ? e.message : 'Failed to move highlights to trash' + return 0 } - page.value = 1 - void fetchHighlights() } - function setSearch(q: string) { - search.value = q - page.value = 1 - void fetchHighlights() + async function bulkRestyle(annotationIds: number[], patch: { color?: string; style?: string }): Promise { + if (annotationIds.length === 0) return 0 + try { + const res = await api('/api/v1/annotations/bulk', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ids: annotationIds, action: 'restyle', ...patch }), + }) + if (!res.ok) throw new Error(`HTTP ${res.status}`) + const body = (await res.json()) as { affected: number } + await fetchHighlights() + return body.affected + } catch (e) { + error.value = e instanceof Error ? e.message : 'Failed to update selected highlights' + return 0 + } } - function setChapter(ch: string | undefined) { - chapter.value = ch - page.value = 1 + watch([colors, chapter, dateFrom, dateTo, sortBy, sortDir], () => { + if (hydratingBook.value) return + reloadFromFilterChange() + }) + let searchDebounce: ReturnType | null = null + watch(search, () => { + if (hydratingBook.value) return + if (searchDebounce) clearTimeout(searchDebounce) + searchDebounce = setTimeout(reloadFromFilterChange, SEARCH_RELOAD_DEBOUNCE_MS) + }) + watch(page, () => { + if (hydratingBook.value) return + selection.clearSelection() void fetchHighlights() - } - - function setDateRange(from: string | undefined, to: string | undefined) { - dateFrom.value = from - dateTo.value = to - page.value = 1 - void fetchHighlights() - } + }) watch( bookIdRef, () => { + hydratingBook.value = true page.value = 1 colors.value = [] search.value = '' - chapter.value = undefined - dateFrom.value = undefined - dateTo.value = undefined + chapter.value = '' + dateFrom.value = '' + dateTo.value = '' + sortBy.value = 'position' + sortDir.value = 'asc' void fetchHighlights() + void nextTick(() => { + hydratingBook.value = false + }) }, { immediate: true }, ) - const chapters = computed(() => stats.value?.chapters ?? []) - return { items, total, + page, + pageSize, + totalPages, + rangeStart, + rangeEnd, stats, loading, error, - page, - pageSize, sortBy, sortDir, + sortKey, colors, search, chapter, chapters, dateFrom, dateTo, + activeFilterChips, + popoverFilterCount, + hasActiveFilters, + selectedIds: selection.selectedIds, + savingIds: mutations.savingIds, + hasSelection: selection.hasSelection, + allVisibleSelected: selection.allVisibleSelected, + selectedItems: selection.selectedItems, + toggleSelected: selection.toggleSelected, + selectAllOnPage: selection.selectAllOnPage, + clearSelection: selection.clearSelection, + updateNote: mutations.updateNote, + updateColor: mutations.updateColor, + updateStyle: mutations.updateStyle, fetchHighlights, - updateNote, - updateColor, deleteHighlight, - setPage, - setSort, - toggleColor, - setSearch, - setChapter, - setDateRange, + bulkTrash, + bulkRestyle, + clearPopoverFilters, + resetAllFilters, + removeFilterChip, + clearDates, } } diff --git a/client/src/features/book/composables/useBookReadingLog.ts b/client/src/features/book/composables/useBookReadingLog.ts index ed4737d42..87e50e7e4 100644 --- a/client/src/features/book/composables/useBookReadingLog.ts +++ b/client/src/features/book/composables/useBookReadingLog.ts @@ -1,12 +1,23 @@ -import { ref, watch, type Ref } from 'vue' +import { computed, ref, watch, type Ref } from 'vue' import type { BookReadingSession, BookReadingSessionListResponse, BookReadingSessionStats } from '@bookorbit/types' import { api } from '@/lib/api' +export interface AddReadingSessionPayload { + startedAt: string + durationMinutes: number + endProgress?: number + format?: string +} + +const EXPORT_PAGE_SIZE = 100 +const EXPORT_MAX_PAGES = 50 + export function useBookReadingLog(bookIdRef: Ref) { const sessions = ref([]) const total = ref(0) const stats = ref(null) const loading = ref(false) + const loadingMore = ref(false) const error = ref(null) const page = ref(1) const pageSize = ref(25) @@ -16,30 +27,65 @@ export function useBookReadingLog(bookIdRef: Ref) { const dateTo = ref(undefined) const format = ref(undefined) - async function fetchSessions() { + const hasMore = computed(() => sessions.value.length < total.value) + + function buildParams(overrides?: { page?: number; pageSize?: number }) { + const params = new URLSearchParams({ + page: String(overrides?.page ?? page.value), + pageSize: String(overrides?.pageSize ?? pageSize.value), + sortBy: sortBy.value, + sortDir: sortDir.value, + }) + if (dateFrom.value) params.set('dateFrom', dateFrom.value) + if (dateTo.value) params.set('dateTo', dateTo.value) + if (format.value) params.set('format', format.value) + return params + } + + async function fetchSessions(opts?: { append?: boolean }) { const bookId = bookIdRef.value - loading.value = true + const append = opts?.append ?? false + if (append) loadingMore.value = true + else loading.value = true error.value = null try { - const params = new URLSearchParams({ - page: String(page.value), - pageSize: String(pageSize.value), - sortBy: sortBy.value, - sortDir: sortDir.value, - }) - if (dateFrom.value) params.set('dateFrom', dateFrom.value) - if (dateTo.value) params.set('dateTo', dateTo.value) - if (format.value) params.set('format', format.value) - const res = await api(`/api/v1/books/${bookId}/sessions?${params.toString()}`) + const res = await api(`/api/v1/books/${bookId}/sessions?${buildParams().toString()}`) if (!res.ok) throw new Error(`HTTP ${res.status}`) const data: BookReadingSessionListResponse = await res.json() - sessions.value = data.items + if (append) { + const seen = new Set(sessions.value.map((s) => s.id)) + sessions.value = [...sessions.value, ...data.items.filter((item) => !seen.has(item.id))] + } else { + sessions.value = data.items + } total.value = data.total stats.value = data.stats } catch (e) { error.value = e instanceof Error ? e.message : 'Failed to load reading sessions' } finally { loading.value = false + loadingMore.value = false + } + } + + async function loadMore() { + if (loading.value || loadingMore.value || !hasMore.value) return + // Derive the next page from the accumulated length so a prior delete + // (which shifts server page boundaries) cannot skip rows. + page.value = Math.floor(sessions.value.length / pageSize.value) + 1 + await fetchSessions({ append: true }) + } + + async function refreshStats() { + const bookId = bookIdRef.value + try { + const res = await api(`/api/v1/books/${bookId}/sessions?${buildParams({ page: 1, pageSize: 1 }).toString()}`) + if (!res.ok) throw new Error(`HTTP ${res.status}`) + const data: BookReadingSessionListResponse = await res.json() + total.value = data.total + stats.value = data.stats + } catch { + // Stats refresh is best-effort; the optimistic list update already happened. } } @@ -52,7 +98,7 @@ export function useBookReadingLog(bookIdRef: Ref) { try { const res = await api(`/api/v1/books/${bookId}/sessions/${sessionId}`, { method: 'DELETE' }) if (!res.ok) throw new Error(`HTTP ${res.status}`) - await fetchSessions() + await refreshStats() } catch (e) { sessions.value = prev total.value = prevTotal @@ -60,9 +106,43 @@ export function useBookReadingLog(bookIdRef: Ref) { } } - function setPage(p: number) { - page.value = p - void fetchSessions() + async function addSession(payload: AddReadingSessionPayload) { + const bookId = bookIdRef.value + const res = await api(`/api/v1/books/${bookId}/sessions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }) + if (!res.ok) { + let message = `HTTP ${res.status}` + try { + const body = (await res.json()) as { message?: string | string[] } + if (body.message) message = Array.isArray(body.message) ? body.message.join(', ') : body.message + } catch { + // Keep the HTTP status message. + } + throw new Error(message) + } + page.value = 1 + await fetchSessions() + } + + async function exportAll(): Promise { + const bookId = bookIdRef.value + const items: BookReadingSession[] = [] + let expectedTotal = Number.POSITIVE_INFINITY + for (let exportPage = 1; exportPage <= EXPORT_MAX_PAGES && items.length < expectedTotal; exportPage += 1) { + const params = buildParams({ page: exportPage, pageSize: EXPORT_PAGE_SIZE }) + params.set('sortBy', 'startedAt') + params.set('sortDir', 'desc') + const res = await api(`/api/v1/books/${bookId}/sessions?${params.toString()}`) + if (!res.ok) throw new Error(`HTTP ${res.status}`) + const data: BookReadingSessionListResponse = await res.json() + items.push(...data.items) + expectedTotal = data.total + if (data.items.length === 0) break + } + return items } function setSort(by: typeof sortBy.value, dir: typeof sortDir.value) { @@ -97,6 +177,7 @@ export function useBookReadingLog(bookIdRef: Ref) { total, stats, loading, + loadingMore, error, page, pageSize, @@ -105,8 +186,11 @@ export function useBookReadingLog(bookIdRef: Ref) { dateFrom, dateTo, format, + hasMore, deleteSession, - setPage, + addSession, + exportAll, + loadMore, setSort, setFilters, } diff --git a/client/src/features/dashboard/components/widgets/ReadingRhythmWidget.vue b/client/src/features/dashboard/components/widgets/ReadingRhythmWidget.vue index 3d2f74f38..33ace6523 100644 --- a/client/src/features/dashboard/components/widgets/ReadingRhythmWidget.vue +++ b/client/src/features/dashboard/components/widgets/ReadingRhythmWidget.vue @@ -24,6 +24,12 @@ function getDayLabel(dateStr: string): string { const d = new Date(dateStr + 'T12:00:00Z') return ['S', 'M', 'T', 'W', 'T', 'F', 'S'][d.getUTCDay()]! } + +const consistencyLabel = computed(() => { + if (!data.value) return '' + const dayWord = data.value.activeDays === 1 ? 'day' : 'days' + return `${data.value.activeDays} active ${dayWord} · ${data.value.consistencyPercent}% consistency` +})