diff --git a/client/src/composables/__tests__/useSidebarNav.spec.ts b/client/src/composables/__tests__/useSidebarNav.spec.ts index 8b8b61e1e..e0e496409 100644 --- a/client/src/composables/__tests__/useSidebarNav.spec.ts +++ b/client/src/composables/__tests__/useSidebarNav.spec.ts @@ -73,7 +73,7 @@ describe('sidebar nav registry', () => { }) it('shows only ungated destinations to a user with no permissions', () => { - expect(allowedIds(makeContext())).toEqual(['dashboard', 'statistics', 'achievements', 'authors', 'series', 'annotations']) + expect(allowedIds(makeContext())).toEqual(['dashboard', 'statistics', 'reading-history', 'achievements', 'authors', 'series', 'annotations']) }) it('places Dashboard, Book Dock, Requests, Tools and the podcast Queue in the primary zone, above the entity sections', () => { @@ -82,10 +82,10 @@ describe('sidebar nav registry', () => { expect(primary).toEqual(['dashboard', 'book-dock', 'book-requests', 'tools', 'podcast-queue']) }) - it('groups Statistics, Achievements and Annotations in the Reading zone', () => { + it('groups Statistics, History, Achievements and Annotations in the Reading zone', () => { const reading = SIDEBAR_NAV_REGISTRY.filter((candidate) => candidate.zone === 'reading').map((candidate) => candidate.id) - expect(reading).toEqual(['statistics', 'achievements', 'annotations']) + expect(reading).toEqual(['statistics', 'reading-history', 'achievements', 'annotations']) }) it('hides Achievements when the user has turned achievements off', () => { @@ -266,6 +266,7 @@ describe('sidebar nav registry', () => { expect(booksModeIds).toEqual([ 'dashboard', 'statistics', + 'reading-history', 'achievements', 'book-dock', 'book-requests', diff --git a/client/src/composables/useSidebarNav.ts b/client/src/composables/useSidebarNav.ts index d5aa1b7f4..15dd202e2 100644 --- a/client/src/composables/useSidebarNav.ts +++ b/client/src/composables/useSidebarNav.ts @@ -1,7 +1,7 @@ import { computed, type Component } from 'vue' import { useI18n } from 'vue-i18n' import { useRoute, type RouteLocationNormalizedLoaded, type RouteLocationRaw } from 'vue-router' -import { BarChart3, BookPlus, Highlighter, LayoutDashboard, Library, ListMusic, PackageOpen, Trophy, Users, Wrench } from '@lucide/vue' +import { BarChart3, BookPlus, Highlighter, History, LayoutDashboard, Library, ListMusic, PackageOpen, Trophy, Users, Wrench } from '@lucide/vue' import { Permission, type BrowseCounts, type LibraryType, type SidebarSectionId } from '@bookorbit/types' import { useAuth } from '@/features/auth/composables/useAuth' import { usePermissions } from '@/features/auth/composables/usePermissions' @@ -112,6 +112,15 @@ export const SIDEBAR_NAV_REGISTRY: readonly SidebarNavEntry[] = [ isActive: (route) => route.name === 'statistics', tourId: 'statistics-btn', }, + { + id: 'reading-history', + labelKey: 'components.appHeader.readingHistory', + icon: History, + zone: 'reading', + modes: ['books'], + to: { name: 'reading-history' }, + isActive: (route) => route.name === 'reading-history', + }, { id: 'achievements', labelKey: 'components.appHeader.achievements', diff --git a/client/src/features/book/components/DeleteBookDialog.vue b/client/src/features/book/components/DeleteBookDialog.vue index e89307719..de210a4d8 100644 --- a/client/src/features/book/components/DeleteBookDialog.vue +++ b/client/src/features/book/components/DeleteBookDialog.vue @@ -1,5 +1,5 @@ + + diff --git a/client/src/features/reading-history/components/HistoryCover.vue b/client/src/features/reading-history/components/HistoryCover.vue new file mode 100644 index 000000000..932e25600 --- /dev/null +++ b/client/src/features/reading-history/components/HistoryCover.vue @@ -0,0 +1,48 @@ + + + diff --git a/client/src/features/reading-history/components/HistoryCoverTile.vue b/client/src/features/reading-history/components/HistoryCoverTile.vue new file mode 100644 index 000000000..4855b1739 --- /dev/null +++ b/client/src/features/reading-history/components/HistoryCoverTile.vue @@ -0,0 +1,83 @@ + + + diff --git a/client/src/features/reading-history/components/HistoryCoverWall.vue b/client/src/features/reading-history/components/HistoryCoverWall.vue new file mode 100644 index 000000000..11813470b --- /dev/null +++ b/client/src/features/reading-history/components/HistoryCoverWall.vue @@ -0,0 +1,157 @@ + + + diff --git a/client/src/features/reading-history/components/HistoryList.vue b/client/src/features/reading-history/components/HistoryList.vue new file mode 100644 index 000000000..412866751 --- /dev/null +++ b/client/src/features/reading-history/components/HistoryList.vue @@ -0,0 +1,187 @@ + + + diff --git a/client/src/features/reading-history/components/HistoryMonthHeader.vue b/client/src/features/reading-history/components/HistoryMonthHeader.vue new file mode 100644 index 000000000..b3997810f --- /dev/null +++ b/client/src/features/reading-history/components/HistoryMonthHeader.vue @@ -0,0 +1,31 @@ + + + diff --git a/client/src/features/reading-history/components/HistoryPeriodPicker.vue b/client/src/features/reading-history/components/HistoryPeriodPicker.vue new file mode 100644 index 000000000..ae57f6880 --- /dev/null +++ b/client/src/features/reading-history/components/HistoryPeriodPicker.vue @@ -0,0 +1,78 @@ + + + diff --git a/client/src/features/reading-history/components/HistoryRail.vue b/client/src/features/reading-history/components/HistoryRail.vue new file mode 100644 index 000000000..e16d15068 --- /dev/null +++ b/client/src/features/reading-history/components/HistoryRail.vue @@ -0,0 +1,380 @@ + + + diff --git a/client/src/features/reading-history/components/HistoryReadNotes.vue b/client/src/features/reading-history/components/HistoryReadNotes.vue new file mode 100644 index 000000000..006ec245a --- /dev/null +++ b/client/src/features/reading-history/components/HistoryReadNotes.vue @@ -0,0 +1,113 @@ + + + diff --git a/client/src/features/reading-history/components/HistoryReadPanel.vue b/client/src/features/reading-history/components/HistoryReadPanel.vue new file mode 100644 index 000000000..591b64a46 --- /dev/null +++ b/client/src/features/reading-history/components/HistoryReadPanel.vue @@ -0,0 +1,135 @@ + + + diff --git a/client/src/features/reading-history/components/HistoryReadRow.vue b/client/src/features/reading-history/components/HistoryReadRow.vue new file mode 100644 index 000000000..3d4b0968b --- /dev/null +++ b/client/src/features/reading-history/components/HistoryReadRow.vue @@ -0,0 +1,194 @@ + + + diff --git a/client/src/features/reading-history/components/HistoryStars.vue b/client/src/features/reading-history/components/HistoryStars.vue new file mode 100644 index 000000000..7dc82a111 --- /dev/null +++ b/client/src/features/reading-history/components/HistoryStars.vue @@ -0,0 +1,70 @@ + + + diff --git a/client/src/features/reading-history/components/HistoryToolbar.vue b/client/src/features/reading-history/components/HistoryToolbar.vue new file mode 100644 index 000000000..1bcba3990 --- /dev/null +++ b/client/src/features/reading-history/components/HistoryToolbar.vue @@ -0,0 +1,155 @@ + + + diff --git a/client/src/features/reading-history/components/ShareImageDialog.vue b/client/src/features/reading-history/components/ShareImageDialog.vue new file mode 100644 index 000000000..72793e096 --- /dev/null +++ b/client/src/features/reading-history/components/ShareImageDialog.vue @@ -0,0 +1,481 @@ + + + diff --git a/client/src/features/reading-history/composables/useReadMarks.ts b/client/src/features/reading-history/composables/useReadMarks.ts new file mode 100644 index 000000000..6f9c2fe22 --- /dev/null +++ b/client/src/features/reading-history/composables/useReadMarks.ts @@ -0,0 +1,27 @@ +import { reactive } from 'vue' +import type { ReadingHistoryMarks } from '@bookorbit/types' +import { fetchReadMarks } from '../api/reading-history.api' + +type Entry = { status: 'loading' } | { status: 'ready'; marks: ReadingHistoryMarks } | { status: 'error' } + +/** The highlights and journal entries of reads the reader opens, fetched once per read. */ +export function useReadMarks() { + const entries = reactive(new Map()) + + async function load(attemptId: number) { + const existing = entries.get(attemptId) + if (existing && existing.status !== 'error') return + entries.set(attemptId, { status: 'loading' }) + try { + entries.set(attemptId, { status: 'ready', marks: await fetchReadMarks(attemptId) }) + } catch { + entries.set(attemptId, { status: 'error' }) + } + } + + function get(attemptId: number): Entry | undefined { + return entries.get(attemptId) + } + + return { load, get } +} diff --git a/client/src/features/reading-history/composables/useReadingHistoryFilters.ts b/client/src/features/reading-history/composables/useReadingHistoryFilters.ts new file mode 100644 index 000000000..791b71895 --- /dev/null +++ b/client/src/features/reading-history/composables/useReadingHistoryFilters.ts @@ -0,0 +1,148 @@ +import { reactive, watch } from 'vue' +import { useRoute, useRouter, type LocationQuery, type LocationQueryRaw } from 'vue-router' +import { READING_ATTEMPT_OUTCOMES, READING_HISTORY_SORTS, type ReadingAttemptOutcome, type ReadingHistorySort } from '@bookorbit/types' + +export type ReadingHistoryView = 'list' | 'covers' + +export interface ReadingHistoryFilters { + /** null = All time. */ + year: number | null + quarter: number | null + outcomes: ReadingAttemptOutcome[] + search: string + sort: ReadingHistorySort + bookId: number | null + view: ReadingHistoryView +} + +const VIEW_STORAGE_KEY = 'reading-history-view' +const SEARCH_WRITE_DELAY_MS = 300 + +function first(value: LocationQuery[string] | undefined): string | undefined { + const raw = Array.isArray(value) ? value[0] : value + return typeof raw === 'string' ? raw : undefined +} + +function positiveInt(value: string | undefined): number | null { + if (!value) return null + const parsed = Number(value) + return Number.isInteger(parsed) && parsed > 0 ? parsed : null +} + +function storedView(): ReadingHistoryView { + try { + return localStorage.getItem(VIEW_STORAGE_KEY) === 'covers' ? 'covers' : 'list' + } catch { + return 'list' + } +} + +/** Reads the filters from a URL query, ignoring anything unknown or malformed. */ +export function filtersFromQuery(query: LocationQuery, currentYear: number): ReadingHistoryFilters { + const yearParam = first(query.year) + const year = yearParam === 'all' ? null : (positiveInt(yearParam) ?? currentYear) + const quarter = year === null ? null : positiveInt(first(query.q)) + const outcomes = (first(query.outcomes) ?? '') + .split(',') + .filter((value): value is ReadingAttemptOutcome => (READING_ATTEMPT_OUTCOMES as readonly string[]).includes(value)) + const sort = first(query.sort) + const view = first(query.view) + return { + year, + quarter: quarter !== null && quarter <= 4 ? quarter : null, + outcomes: outcomes.length > 0 ? [...new Set(outcomes)] : ['completed'], + search: first(query.search) ?? '', + sort: sort && (READING_HISTORY_SORTS as readonly string[]).includes(sort) ? (sort as ReadingHistorySort) : 'newest', + bookId: positiveInt(first(query.book)), + view: view === 'covers' || view === 'list' ? view : storedView(), + } +} + +/** Writes the filters back as a minimal query: defaults are left out. */ +export function queryFromFilters(filters: ReadingHistoryFilters, currentYear: number): LocationQueryRaw { + const query: LocationQueryRaw = {} + if (filters.year === null) query.year = 'all' + else if (filters.year !== currentYear) query.year = String(filters.year) + if (filters.year !== null && filters.quarter !== null) query.q = String(filters.quarter) + if (filters.outcomes.length !== 1 || filters.outcomes[0] !== 'completed') query.outcomes = filters.outcomes.join(',') + if (filters.search.trim()) query.search = filters.search.trim() + if (filters.sort !== 'newest') query.sort = filters.sort + if (filters.bookId !== null) query.book = String(filters.bookId) + if (filters.view === 'covers') query.view = 'covers' + return query +} + +function sameQuery(next: LocationQueryRaw, current: LocationQuery): boolean { + const keys = Object.keys(next) + if (keys.length !== Object.keys(current).length) return false + return keys.every((key) => first(current[key]) === next[key]) +} + +/** + * The page's filters, kept in the URL so a view can be bookmarked or linked to (the dashboard's + * goal and the completion charts link here), and the List or Covers choice remembered per browser. + */ +export function useReadingHistoryFilters(currentYear = new Date().getFullYear()) { + const route = useRoute() + const router = useRouter() + const filters = reactive(filtersFromQuery(route.query, currentYear)) + + let searchTimer: ReturnType | null = null + + function write() { + const next = queryFromFilters(filters, currentYear) + if (sameQuery(next, route.query)) return + void router.replace({ query: next }) + } + + watch( + () => [filters.year, filters.quarter, filters.outcomes.join(','), filters.sort, filters.bookId, filters.view], + () => { + try { + localStorage.setItem(VIEW_STORAGE_KEY, filters.view) + } catch { + /* storage unavailable: the choice lasts for this visit */ + } + write() + }, + ) + watch( + () => filters.search, + () => { + if (searchTimer) clearTimeout(searchTimer) + searchTimer = setTimeout(write, SEARCH_WRITE_DELAY_MS) + }, + ) + watch( + () => route.query, + (query) => { + if (route.name !== 'reading-history') return + Object.assign(filters, filtersFromQuery(query, currentYear)) + }, + ) + + function setYear(year: number | null) { + filters.year = year + filters.quarter = null + } + + function toggleQuarter(quarter: number) { + if (filters.year === null) return + filters.quarter = filters.quarter === quarter ? null : quarter + } + + /** Finished stays on unless another outcome is on, so the list never filters to nothing. */ + function toggleOutcome(outcome: ReadingAttemptOutcome) { + const has = filters.outcomes.includes(outcome) + if (has && filters.outcomes.length === 1) return + filters.outcomes = has + ? filters.outcomes.filter((value) => value !== outcome) + : READING_ATTEMPT_OUTCOMES.filter((value) => value === outcome || filters.outcomes.includes(value)) + } + + function clearBook() { + filters.bookId = null + } + + return { filters, currentYear, setYear, toggleQuarter, toggleOutcome, clearBook } +} diff --git a/client/src/features/reading-history/composables/useReadingHistoryList.ts b/client/src/features/reading-history/composables/useReadingHistoryList.ts new file mode 100644 index 000000000..0a6ec976f --- /dev/null +++ b/client/src/features/reading-history/composables/useReadingHistoryList.ts @@ -0,0 +1,140 @@ +import { computed, onScopeDispose, ref, shallowRef, toValue, triggerRef, watch, type MaybeRefOrGetter } from 'vue' +import type { ReadingHistoryItem, ReadingHistoryMonth } from '@bookorbit/types' +import { fetchReadingHistoryPage, type ReadingHistoryListParams } from '../api/reading-history.api' + +export const READING_HISTORY_PAGE_SIZE = 100 + +/** One row of the list: a month's header, or the read at a position in the whole list. */ +export type ReadingHistoryRow = + { kind: 'month'; key: string; month: ReadingHistoryMonth } | { kind: 'read'; key: string; index: number; month: string } + +/** What sits at the top of the screen, so switching between List and Covers can put it back there. */ +export type ReadingHistoryAnchor = { kind: 'month'; month: string } | { kind: 'read'; index: number } + +/** + * The list behind History, loaded a page at a time as rows come into view. + * + * The first page carries every month's size, so the whole list is laid out (month headers plus a + * placeholder per read) before most of it has loaded, and the scrollbar is right from the start. + * Rows ask for their page with `ensureRange`; a page is fetched once. Changing the filters keeps + * the old rows on screen until the new first page lands, and cancels requests still in flight, so + * a slow old answer can never land in a new list. + */ +export function useReadingHistoryList(params: MaybeRefOrGetter) { + const months = ref([]) + const total = ref(0) + const loaded = ref(false) + const loading = ref(false) + const error = ref(null) + const pages = shallowRef(new Map()) + const pending = new Set() + let generation = 0 + let controller: AbortController | null = null + let current: ReadingHistoryListParams | null = null + /** The pages on screen belong to the previous filters: shown until the new first page lands, never reused. */ + let stale = false + + const rows = computed(() => { + const out: ReadingHistoryRow[] = [] + let index = 0 + for (const month of months.value) { + out.push({ kind: 'month', key: `m-${month.month}`, month }) + for (let i = 0; i < month.reads; i++, index++) out.push({ kind: 'read', key: `r-${index}`, index, month: month.month }) + } + return out + }) + + function itemAt(index: number): ReadingHistoryItem | null { + return pages.value.get(Math.floor(index / READING_HISTORY_PAGE_SIZE))?.[index % READING_HISTORY_PAGE_SIZE] ?? null + } + + async function loadPage(page: number, gen: number) { + if (!current || pending.has(page) || (!stale && pages.value.has(page))) return + pending.add(page) + try { + const result = await fetchReadingHistoryPage(current, page * READING_HISTORY_PAGE_SIZE, READING_HISTORY_PAGE_SIZE, controller?.signal) + if (gen !== generation) return + if (stale) { + pages.value = new Map() + stale = false + } + pages.value.set(page, result.items) + triggerRef(pages) + if (result.months) months.value = result.months + total.value = result.total + } catch (err) { + if (gen !== generation || (err instanceof DOMException && err.name === 'AbortError')) return + error.value = err instanceof Error ? err.message : String(err) + } finally { + if (gen === generation) pending.delete(page) + } + } + + /** Loads every page the reads from `start` to `end` (list positions, inclusive) fall on. */ + function ensureRange(start: number, end: number) { + if (loading.value || total.value === 0) return + const last = Math.min(end, total.value - 1) + for (let page = Math.floor(Math.max(0, start) / READING_HISTORY_PAGE_SIZE); page <= Math.floor(last / READING_HISTORY_PAGE_SIZE); page++) { + void loadPage(page, generation) + } + } + + async function reset(next: ReadingHistoryListParams) { + generation++ + controller?.abort() + controller = new AbortController() + current = { ...next, outcomes: [...next.outcomes] } + pending.clear() + // The old rows stay on screen until the new first page replaces them, so a filter change + // dims the list instead of flashing it empty. + stale = true + error.value = null + loading.value = true + const gen = generation + await loadPage(0, gen) + if (gen !== generation) return + if (stale) { + // The first page failed: drop what belonged to the old filters rather than pass it off as the new. + pages.value = new Map() + months.value = [] + total.value = 0 + stale = false + } + loaded.value = true + loading.value = false + } + + function findItem(id: number): ReadingHistoryItem | null { + for (const items of pages.value.values()) { + const found = items.find((item) => item.id === id) + if (found) return found + } + return null + } + + /** Writes a change to one read into whatever page holds it, without refetching. */ + function patchItem(id: number, patch: Partial) { + for (const items of pages.value.values()) { + const index = items.findIndex((item) => item.id === id) + if (index === -1) continue + items[index] = { ...items[index]!, ...patch } + triggerRef(pages) + return + } + } + + function refresh() { + if (current) void reset(current) + } + + // Keyed by value: syncing the URL rebuilds the filters with equal values (a view switch does), + // and that must not refetch. + watch( + () => JSON.stringify(toValue(params)), + () => void reset(toValue(params)), + { immediate: true }, + ) + onScopeDispose(() => controller?.abort()) + + return { months, total, rows, loaded, loading, error, itemAt, findItem, ensureRange, patchItem, refresh } +} diff --git a/client/src/features/reading-history/composables/useReadingHistorySummary.ts b/client/src/features/reading-history/composables/useReadingHistorySummary.ts new file mode 100644 index 000000000..11d1bfee3 --- /dev/null +++ b/client/src/features/reading-history/composables/useReadingHistorySummary.ts @@ -0,0 +1,42 @@ +import { onScopeDispose, ref, toValue, watch, type MaybeRefOrGetter } from 'vue' +import type { ReadingHistorySummary } from '@bookorbit/types' +import { fetchReadingHistorySummary, type ReadingHistoryPeriodParams } from '../api/reading-history.api' + +/** The period's numbers for the rail. Keeps the last answer on screen while the next one loads. */ +export function useReadingHistorySummary(period: MaybeRefOrGetter) { + const summary = ref(null) + const loading = ref(false) + const error = ref(null) + let controller: AbortController | null = null + let current: ReadingHistoryPeriodParams | null = null + + async function load(next: ReadingHistoryPeriodParams) { + controller?.abort() + const own = new AbortController() + controller = own + current = next + loading.value = true + error.value = null + try { + summary.value = await fetchReadingHistorySummary(next, own.signal) + } catch (err) { + if (err instanceof DOMException && err.name === 'AbortError') return + error.value = err instanceof Error ? err.message : String(err) + } finally { + if (controller === own) loading.value = false + } + } + + function refresh() { + if (current) void load(current) + } + + watch( + () => JSON.stringify(toValue(period)), + () => void load(toValue(period)), + { immediate: true }, + ) + onScopeDispose(() => controller?.abort()) + + return { summary, loading, error, refresh } +} diff --git a/client/src/features/reading-history/composables/useShellVirtualizer.ts b/client/src/features/reading-history/composables/useShellVirtualizer.ts new file mode 100644 index 000000000..3b4950a48 --- /dev/null +++ b/client/src/features/reading-history/composables/useShellVirtualizer.ts @@ -0,0 +1,87 @@ +import { computed, onBeforeUnmount, onMounted, ref, shallowRef, type ComponentPublicInstance, type Ref } from 'vue' +import { elementScroll, useVirtualizer, type Virtualizer } from '@tanstack/vue-virtual' + +/** + * A virtualizer over rows that scroll with the page, not inside a box of their own: the scroll + * element is the app shell's scroller, and `scrollMargin` is where the list starts inside it. + * The start moves whenever anything above the list changes height (the rail's numbers arriving, + * the toolbar wrapping, a phone rotating), so it is re-measured on every resize. + */ +export function useShellVirtualizer(options: { + listEl: Ref + count: Ref + estimateSize: (index: number) => number + getItemKey: (index: number) => string + overscan?: number + /** Space a jump leaves above its row, for anything pinned over the top of the list. */ + scrollPaddingStart?: number +}) { + // The shell outlives every page, so it is there before this list renders. Resolving it now + // matters: the virtualizer caches its first scroll offset, and attaching later would snap the + // page back to the top. + const scrollEl = shallowRef(typeof document === 'undefined' ? null : document.querySelector('.app-shell-scroll')) + const scrollMargin = ref(0) + let observer: ResizeObserver | null = null + + function measureMargin() { + const list = options.listEl.value + const scroller = scrollEl.value + if (!list || !scroller) return + const next = Math.round(list.getBoundingClientRect().top - scroller.getBoundingClientRect().top + scroller.scrollTop) + if (next !== scrollMargin.value) scrollMargin.value = next + } + + onMounted(() => { + scrollEl.value = options.listEl.value?.closest('.app-shell-scroll') ?? scrollEl.value + measureMargin() + observer = new ResizeObserver(measureMargin) + const page = scrollEl.value?.firstElementChild + if (page) observer.observe(page) + if (options.listEl.value) observer.observe(options.listEl.value) + }) + + onBeforeUnmount(() => observer?.disconnect()) + + const virtualizer = useVirtualizer( + computed(() => ({ + count: options.count.value, + getScrollElement: () => scrollEl.value, + estimateSize: options.estimateSize, + getItemKey: options.getItemKey, + overscan: options.overscan ?? 6, + scrollPaddingStart: options.scrollPaddingStart ?? 0, + scrollMargin: scrollMargin.value, + // On first attaching to the shell the virtualizer scrolls to this offset; the shell's own + // position keeps it from snapping the page back to the top. + initialOffset: () => scrollEl.value?.scrollTop ?? 0, + // The shell scrolls smoothly by default. The virtualizer's own corrections (re-targeting a + // jump as rows measure, holding rows still as those above them resize) ask for the default + // behaviour, so they would animate, and mid-animation it mistakes them for the reader + // scrolling and abandons the jump. Only an explicitly smooth scroll animates. + scrollToFn: (offset: number, scroll: { adjustments?: number; behavior?: ScrollBehavior }, instance: Virtualizer) => + elementScroll(offset, { ...scroll, behavior: scroll.behavior === 'smooth' ? 'smooth' : 'instant' }, instance), + })), + ) + + const items = computed(() => virtualizer.value.getVirtualItems()) + const totalSize = computed(() => virtualizer.value.getTotalSize()) + + /** The list-relative scroll position: how far the top of the viewport is into the list. */ + const listScrollTop = computed(() => (virtualizer.value.scrollOffset ?? 0) - scrollMargin.value) + + /** A template ref callback: rows measure themselves as they render. */ + function measureElement(el: Element | ComponentPublicInstance | null) { + const node = el instanceof Element ? el : (el?.$el as Element | undefined) + if (node) virtualizer.value.measureElement(node) + } + + /** + * Jumps straight to a row. Instant, because the shell scrolls smoothly by default and a long + * list would crawl for seconds; the virtualizer re-targets once the rows around it are measured. + */ + function scrollToIndex(index: number) { + virtualizer.value.scrollToIndex(index, { align: 'start', behavior: 'instant' }) + } + + return { virtualizer, items, totalSize, scrollMargin, listScrollTop, measureElement, scrollToIndex, measureMargin } +} diff --git a/client/src/features/reading-history/lib/chart-month-link.ts b/client/src/features/reading-history/lib/chart-month-link.ts new file mode 100644 index 000000000..d8768946e --- /dev/null +++ b/client/src/features/reading-history/lib/chart-month-link.ts @@ -0,0 +1,24 @@ +import type { RouteLocationRaw } from 'vue-router' + +interface MonthChart { + containPixel: (finder: string, value: number[]) => boolean + convertFromPixel: (finder: { gridIndex: number }, value: number[]) => unknown +} + +/** + * Where a click on a monthly chart goes: History, opened on the month under the pointer. Charts + * plot one category per month; the grid converts the pointer to [category index, value], so the + * index picks the month even between drawn symbols. + */ +export function historyRouteForChartClick( + chart: MonthChart | null | undefined, + event: { offsetX: number; offsetY: number }, + points: readonly { year: number; month: number }[], +): RouteLocationRaw | null { + if (!chart || !chart.containPixel('grid', [event.offsetX, event.offsetY])) return null + const raw = chart.convertFromPixel({ gridIndex: 0 }, [event.offsetX, event.offsetY]) + const index = Math.round(Array.isArray(raw) ? Number(raw[0]) : Number(raw)) + const point = points[index] + if (!point) return null + return { name: 'reading-history', query: { year: String(point.year), month: `${point.year}-${String(point.month).padStart(2, '0')}` } } +} diff --git a/client/src/features/reading-history/lib/history-export.ts b/client/src/features/reading-history/lib/history-export.ts new file mode 100644 index 000000000..3b04c8379 --- /dev/null +++ b/client/src/features/reading-history/lib/history-export.ts @@ -0,0 +1,125 @@ +import type { ReadingHistoryItem } from '@bookorbit/types' +import { READING_HISTORY_UNDATED_MONTH } from '@bookorbit/types' +import { formatList } from '@/i18n/formatters' +import { formatMonthKey, readSpanDays } from './history-format' +import { sourceBadges } from './read-facts' + +type Translate = (key: string, values?: Record) => string + +export type ExportFormat = 'md' | 'csv' | 'json' + +export const EXPORT_MIME: Record = { + md: 'text/markdown', + csv: 'text/csv', + json: 'application/json', +} + +/** One line per read, grouped under month headings, in the order the list shows them. */ +export function toMarkdown(items: ReadingHistoryItem[], heading: string, t: Translate): string { + const lines = [`# ${heading}`] + let month: string | null = null + for (const item of items) { + const key = item.endedOn?.slice(0, 7) ?? READING_HISTORY_UNDATED_MONTH + if (key !== month) { + month = key + lines.push('', `## ${formatMonthKey(key, t)}`, '') + } + const parts = [item.endedOn ?? t('readingHistory.row.noDate'), `**${escapeMarkdown(item.title ?? t('readingHistory.row.untitled'))}**`] + if (item.authors.length) parts.push(escapeMarkdown(formatList(item.authors))) + if (item.outcome === 'abandoned') parts.push(t('readingHistory.row.abandoned')) + if (item.outcome === 'skimmed') parts.push(t('readingHistory.row.skimmed')) + if (item.outcome === 'completed' && (item.readNumber ?? 1) > 1) parts.push(t('readingHistory.row.readNumber', { n: item.readNumber })) + if (item.format) parts.push(item.format.toUpperCase()) + const span = readSpanDays(item) + if (span !== null) parts.push(span === 1 ? t('readingHistory.row.sameDay') : t('readingHistory.rail.days', { count: span })) + if (item.rating) parts.push('★'.repeat(item.rating)) + if (item.removedAt) parts.push(t('readingHistory.row.removed')) + lines.push(`- ${parts.join(' · ')}`) + } + return `${lines.join('\n')}\n` +} + +const CSV_COLUMNS = [ + 'finished_on', + 'started_on', + 'title', + 'authors', + 'read_number', + 'outcome', + 'format', + 'pages', + 'minutes_read', + 'sessions', + 'rating', + 'where', + 'highlights', + 'journal_entries', + 'book_id', + 'removed_from_library', +] as const + +function csvCell(value: string | number | null | undefined): string { + if (value === null || value === undefined) return '' + const text = String(value) + return /[",\n\r]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text +} + +/** Machine-readable columns, so the header stays the same in every language. */ +export function toCsv(items: ReadingHistoryItem[]): string { + const rows = items.map((item) => + [ + item.endedOn, + item.startedOn, + item.title, + item.authors.join('; '), + item.readNumber, + item.outcome, + item.format, + item.pageCount, + item.seconds ? Math.round(item.seconds / 60) : 0, + item.sessions, + item.rating, + sourceBadges(item.sources) + .map((source) => source.label) + .join('; '), + item.highlightCount, + item.journalCount, + item.bookId, + item.removedAt ? 'yes' : '', + ] + .map(csvCell) + .join(','), + ) + return `${[CSV_COLUMNS.join(','), ...rows].join('\n')}\n` +} + +export function toJson(items: ReadingHistoryItem[]): string { + return `${JSON.stringify(items, null, 2)}\n` +} + +function escapeMarkdown(value: string): string { + return value.replace(/([\\*_`[\]])/g, '\\$1') +} + +/** A file name a desktop or phone will accept: the period, lower-cased, with no separators. */ +export function exportFileName(periodLabel: string, format: ExportFormat): string { + const slug = periodLabel + .normalize('NFKD') + .replace(/[^\p{Letter}\p{Number}]+/gu, '-') + .replace(/^-+|-+$/g, '') + .toLowerCase() + return `reading-history${slug ? `-${slug}` : ''}.${format}` +} + +/** Saves a blob through a temporary link: the one route that also works from an installed iOS web app. */ +export function saveBlob(blob: Blob, fileName: string) { + const url = URL.createObjectURL(blob) + const link = document.createElement('a') + link.href = url + link.download = fileName + link.rel = 'noopener' + document.body.append(link) + link.click() + link.remove() + setTimeout(() => URL.revokeObjectURL(url), 30_000) +} diff --git a/client/src/features/reading-history/lib/history-format.ts b/client/src/features/reading-history/lib/history-format.ts new file mode 100644 index 000000000..1392f6541 --- /dev/null +++ b/client/src/features/reading-history/lib/history-format.ts @@ -0,0 +1,69 @@ +import type { ReadingHistoryItem } from '@bookorbit/types' +import { READING_HISTORY_UNDATED_MONTH } from '@bookorbit/types' +import { formatDate } from '@/i18n/formatters' + +type Translate = (key: string, values?: Record) => string + +/** A `YYYY-MM-DD` day as a local date; days carry no zone, so they are never shifted. */ +export function dateFromKey(key: string): Date { + const [year, month, day] = key.split('-').map(Number) + return new Date(year!, (month ?? 1) - 1, day ?? 1) +} + +export function formatDay(key: string): string { + return formatDate(dateFromKey(key), { month: 'short', day: 'numeric' }) +} + +export function formatDayLong(key: string): string { + return formatDate(dateFromKey(key), { year: 'numeric', month: 'short', day: 'numeric' }) +} + +/** An instant's local calendar day, short: what a highlight or journal entry is dated by. */ +export function formatInstantDay(iso: string): string { + return formatDate(new Date(iso), { month: 'short', day: 'numeric' }) +} + +export function formatWeekday(key: string): string { + return formatDate(dateFromKey(key), { weekday: 'short' }) +} + +export function formatDayWithWeekday(key: string): string { + return formatDate(dateFromKey(key), { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' }) +} + +/** `2026-10` → "October 2026"; the undated bucket gets its own label. */ +export function formatMonthKey(key: string, t: Translate): string { + if (key === READING_HISTORY_UNDATED_MONTH) return t('readingHistory.month.undated') + return formatDate(dateFromKey(`${key}-01`), { month: 'long', year: 'numeric' }) +} + +export function formatMonthName(month: number, style: 'long' | 'short' | 'narrow' = 'short'): string { + return formatDate(new Date(2000, month - 1, 1), { month: style }) +} + +/** Days from the day a read began to the day it ended, both counted; null without both. */ +export function readSpanDays(item: Pick): number | null { + if (!item.startedOn || !item.endedOn) return null + return Math.round((dateFromKey(item.endedOn).getTime() - dateFromKey(item.startedOn).getTime()) / 86_400_000) + 1 +} + +export function formatReadingTime(seconds: number, t: Translate): string | null { + if (seconds <= 0) return null + const minutes = Math.round(seconds / 60) + if (minutes < 1) return t('readingHistory.duration.underMinute') + if (minutes < 60) return t('readingHistory.duration.minutes', { minutes }) + return t('readingHistory.duration.hoursMinutes', { hours: Math.floor(minutes / 60), minutes: String(minutes % 60).padStart(2, '0') }) +} + +export function formatHours(seconds: number): number { + return Math.round(seconds / 3600) +} + +/** The read's first fact line: when it began and how long it took, or why that is unknown. */ +export function readSpanLabel(item: Pick, t: Translate): string { + const span = readSpanDays(item) + if (span === 1) return t('readingHistory.row.sameDay') + if (span !== null && item.startedOn) return t('readingHistory.row.startedSpan', { date: formatDay(item.startedOn), count: span }) + if (item.endedOn) return t('readingHistory.row.finishOnly') + return t('readingHistory.row.noDates') +} diff --git a/client/src/features/reading-history/lib/read-facts.ts b/client/src/features/reading-history/lib/read-facts.ts new file mode 100644 index 000000000..78bf0a3a5 --- /dev/null +++ b/client/src/features/reading-history/lib/read-facts.ts @@ -0,0 +1,32 @@ +import { + READING_SESSION_SOURCE_BUCKET_LABELS, + toReadingSessionSourceBucket, + type ReadingHistoryItem, + type ReadingSessionSourceBucket, +} from '@bookorbit/types' +import { SOURCE_BUCKET_COLOR_TOKENS } from '@/features/statistics/lib/source-bucket-colors' + +export interface SourceBadge { + bucket: ReadingSessionSourceBucket + label: string + color: string +} + +/** Where a read happened, one entry per app or device, in the order the charts list them. */ +export function sourceBadges(sources: ReadingHistoryItem['sources']): SourceBadge[] { + const buckets = new Set(sources.map((source) => toReadingSessionSourceBucket(source))) + return [...buckets].map((bucket) => ({ + bucket, + label: READING_SESSION_SOURCE_BUCKET_LABELS[bucket], + color: `var(${SOURCE_BUCKET_COLOR_TOKENS[bucket]})`, + })) +} + +export function sourceBucketColor(bucket: ReadingSessionSourceBucket): string { + return `var(${SOURCE_BUCKET_COLOR_TOKENS[bucket]})` +} + +/** A reread is any completed read after a book's first. */ +export function isReread(item: Pick): boolean { + return item.outcome === 'completed' && (item.readNumber ?? 1) > 1 +} diff --git a/client/src/features/reading-history/lib/share-poster.ts b/client/src/features/reading-history/lib/share-poster.ts new file mode 100644 index 000000000..322356374 --- /dev/null +++ b/client/src/features/reading-history/lib/share-poster.ts @@ -0,0 +1,355 @@ +export type PosterShape = 'square' | 'story' | 'wide' +export type PosterLook = 'dark' | 'light' + +export const POSTER_SIZES: Record = { + square: { width: 1080, height: 1080 }, + story: { width: 1080, height: 1920 }, + wide: { width: 1600, height: 900 }, +} + +export interface PosterTile { + image: HTMLImageElement | null + title: string + /** Completed reads of this book in the period; drawn as ×N when above one. */ + count: number + rating: number | null +} + +export interface PosterContent { + eyebrow: string + big: string + stats: { value: string; label: string }[] + tiles: PosterTile[] + facts: { label: string; value: string; meta: string }[] + moreLabel: (count: number) => string + /** The app's own lockup, split so the second word takes the accent, as in the sidebar. */ + brand: [string, string] +} + +export interface PosterStyle { + look: PosterLook + /** The reader's accent and the ink drawn on it, as canvas colours. */ + accent: string + accentInk: string + sans: string + serif: string + showTitles: boolean + showRereads: boolean + showRatings: boolean +} + +/** Lucide's Orbit, the glyph in the sidebar's brand tile, on its 24-unit grid. */ +function orbitGlyph(): Path2D[] { + return [ + new Path2D('M20.341 6.484A10 10 0 0 1 10.266 21.85'), + new Path2D('M3.659 17.516A10 10 0 0 1 13.74 2.152'), + circlePath(12, 12, 3), + circlePath(19, 5, 2), + circlePath(5, 19, 2), + ] +} + +function circlePath(x: number, y: number, r: number): Path2D { + const path = new Path2D() + path.arc(x, y, r, 0, Math.PI * 2) + return path +} + +/** The accent at a given opacity. Accepts the hex and rgb()/rgba() forms `toLegacyColor` returns. */ +export function withAlpha(color: string, alpha: number): string { + const hex = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(color.trim())?.[1] + if (hex) { + const full = hex.length === 3 ? [...hex].map((digit) => digit + digit).join('') : hex + const [r, g, b] = [0, 2, 4].map((at) => parseInt(full.slice(at, at + 2), 16)) + return `rgba(${r}, ${g}, ${b}, ${alpha})` + } + const rgb = /rgba?\(([^)]+)\)/ + .exec(color)?.[1] + ?.split(/[\s,/]+/) + .filter(Boolean) + if (rgb && rgb.length >= 3) return `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, ${alpha})` + return color +} + +function palette(style: PosterStyle) { + return style.look === 'dark' + ? { + base: '#101417', + ink: '#f2f5f6', + muted: 'rgba(242, 245, 246, 0.72)', + rule: 'rgba(242, 245, 246, 0.18)', + blank: 'rgba(255, 255, 255, 0.08)', + glow: withAlpha(style.accent, 0.34), + } + : { + base: '#f8f8f5', + ink: '#13181b', + muted: 'rgba(19, 24, 27, 0.68)', + rule: 'rgba(19, 24, 27, 0.16)', + blank: 'rgba(19, 24, 27, 0.07)', + glow: withAlpha(style.accent, 0.16), + } +} + +function font(weight: number, size: number, family: string): string { + return `${weight} ${size}px ${family}` +} + +function fitText(ctx: CanvasRenderingContext2D, text: string, maxWidth: number): string { + if (ctx.measureText(text).width <= maxWidth) return text + let low = 0 + let high = text.length + while (low < high) { + const mid = Math.ceil((low + high) / 2) + if (ctx.measureText(`${text.slice(0, mid).trimEnd()}…`).width <= maxWidth) low = mid + else high = mid - 1 + } + return `${text.slice(0, low).trimEnd()}…` +} + +function roundRect(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number) { + ctx.beginPath() + ctx.roundRect(x, y, w, h, r) +} + +/** Largest 2:3 tiles that show every cover, else a floor below which the rest fold into "+N". */ +export function fitTiles(count: number, width: number, height: number, gap: number): { columns: number; tile: number; shown: number } { + let best = { columns: 1, tile: 0 } + for (let rows = 1; rows <= Math.max(1, count); rows++) { + const columns = Math.ceil(count / rows) + const tile = Math.min((width - gap * (columns - 1)) / columns, (height - gap * (rows - 1)) / rows / 1.5) + if (tile > best.tile) best = { columns, tile } + } + if (best.tile >= 40 || count === 0) return { ...best, tile: Math.floor(best.tile), shown: count } + const tile = 40 + const columns = Math.floor((width + gap) / (tile + gap)) + const rows = Math.floor((height + gap) / (tile * 1.5 + gap)) + return { columns, tile, shown: Math.max(0, columns * rows - 1) } +} + +function drawBrand( + ctx: CanvasRenderingContext2D, + style: PosterStyle, + ink: string, + brand: [string, string], + right: number, + centerY: number, + scale: number, +) { + const tile = 46 * scale + ctx.font = font(600, 32 * scale, style.serif) + const first = brand[0] + const second = ` ${brand[1]}` + const textWidth = ctx.measureText(first).width + ctx.measureText(second).width + const gap = 14 * scale + const left = right - textWidth - gap - tile + roundRect(ctx, left, centerY - tile / 2, tile, tile, 11 * scale) + ctx.fillStyle = style.accent + ctx.fill() + ctx.save() + const glyph = tile * 0.6 + ctx.translate(left + (tile - glyph) / 2, centerY - glyph / 2) + ctx.scale(glyph / 24, glyph / 24) + ctx.strokeStyle = style.accentInk + ctx.lineWidth = 2 + ctx.lineCap = 'round' + ctx.lineJoin = 'round' + for (const path of orbitGlyph()) ctx.stroke(path) + ctx.restore() + ctx.textBaseline = 'middle' + ctx.fillStyle = ink + ctx.fillText(first, left + tile + gap, centerY + 2 * scale) + ctx.fillStyle = style.accent + ctx.fillText(second, left + tile + gap + ctx.measureText(first).width, centerY + 2 * scale) + ctx.textBaseline = 'alphabetic' +} + +function drawTile( + ctx: CanvasRenderingContext2D, + tile: PosterTile, + x: number, + y: number, + w: number, + style: PosterStyle, + colors: ReturnType, +) { + const h = w * 1.5 + const radius = Math.max(4, w * 0.06) + ctx.save() + if (style.look === 'dark') { + ctx.shadowColor = 'rgba(0, 0, 0, 0.35)' + ctx.shadowBlur = 14 + ctx.shadowOffsetY = 6 + } + roundRect(ctx, x, y, w, h, radius) + ctx.fillStyle = colors.blank + ctx.fill() + ctx.restore() + + ctx.save() + roundRect(ctx, x, y, w, h, radius) + ctx.clip() + if (tile.image && tile.image.naturalWidth > 0) { + const ratio = tile.image.naturalWidth / tile.image.naturalHeight + const sw = ratio > 2 / 3 ? tile.image.naturalHeight * (2 / 3) : tile.image.naturalWidth + const sh = ratio > 2 / 3 ? tile.image.naturalHeight : tile.image.naturalWidth * 1.5 + ctx.drawImage(tile.image, (tile.image.naturalWidth - sw) / 2, (tile.image.naturalHeight - sh) / 2, sw, sh, x, y, w, h) + } else { + ctx.fillStyle = colors.muted + ctx.font = font(700, Math.max(11, w * 0.13), style.sans) + ctx.textAlign = 'center' + ctx.fillText(fitText(ctx, tile.title, w - 10), x + w / 2, y + h / 2) + ctx.textAlign = 'start' + } + if (style.showTitles && tile.image) { + const band = Math.min(h * 0.42, 64) + const gradient = ctx.createLinearGradient(0, y + h - band, 0, y + h) + gradient.addColorStop(0, 'rgba(0, 0, 0, 0)') + gradient.addColorStop(1, 'rgba(0, 0, 0, 0.82)') + ctx.fillStyle = gradient + ctx.fillRect(x, y + h - band, w, band) + ctx.fillStyle = '#ffffff' + ctx.font = font(600, Math.max(10, Math.min(16, w * 0.12)), style.sans) + ctx.fillText(fitText(ctx, tile.title, w - 12), x + 6, y + h - 8) + } + ctx.restore() + + const badge = Math.max(14, Math.min(24, w * 0.2)) + if (style.showRereads && tile.count > 1) { + const label = `×${tile.count}` + ctx.font = font(800, badge * 0.82, style.sans) + const bw = ctx.measureText(label).width + badge * 0.7 + const bx = x + w - bw - 5 + const by = y + h - badge - 5 - (style.showTitles && tile.image ? Math.min(h * 0.42, 64) - 6 : 0) + roundRect(ctx, bx, by, bw, badge, badge / 2) + ctx.fillStyle = style.accent + ctx.fill() + ctx.fillStyle = style.accentInk + ctx.textBaseline = 'middle' + ctx.fillText(label, bx + badge * 0.35, by + badge / 2 + 1) + ctx.textBaseline = 'alphabetic' + } + if (style.showRatings && tile.rating) { + const label = '★'.repeat(tile.rating) + ctx.font = font(700, badge * 0.7, style.sans) + const bw = ctx.measureText(label).width + badge * 0.6 + roundRect(ctx, x + 5, y + 5, bw, badge, badge / 2) + ctx.fillStyle = 'rgba(0, 0, 0, 0.66)' + ctx.fill() + ctx.fillStyle = '#ffd36b' + ctx.textBaseline = 'middle' + ctx.fillText(label, x + 5 + badge * 0.3, y + 5 + badge / 2 + 1) + ctx.textBaseline = 'alphabetic' + } +} + +/** Draws the whole share image at its real size. Covers must already be decoded. */ +export function drawPoster(canvas: HTMLCanvasElement, shape: PosterShape, content: PosterContent, style: PosterStyle) { + const { width: W, height: H } = POSTER_SIZES[shape] + canvas.width = W + canvas.height = H + const ctx = canvas.getContext('2d') + if (!ctx) return + const colors = palette(style) + const wide = shape === 'wide' + const story = shape === 'story' + const pad = wide ? 64 : 72 + + ctx.fillStyle = colors.base + ctx.fillRect(0, 0, W, H) + const glow = ctx.createRadialGradient(0, 0, 0, 0, 0, Math.max(W, H) * (style.look === 'dark' ? 1.05 : 0.8)) + glow.addColorStop(0, colors.glow) + glow.addColorStop(1, withAlpha(style.accent, 0)) + ctx.fillStyle = glow + ctx.fillRect(0, 0, W, H) + + // Header: whose, which period, the four numbers, and the app's sign-off top right. + const eyebrowY = pad + 30 + ctx.fillStyle = colors.muted + ctx.font = font(700, 30, style.sans) + ctx.letterSpacing = '4.8px' + ctx.fillText(fitText(ctx, content.eyebrow.toUpperCase(), W - pad * 2 - 300), pad, eyebrowY) + ctx.letterSpacing = '0px' + drawBrand(ctx, style, colors.ink, content.brand, W - pad, eyebrowY - 11, 1) + + const bigSize = wide ? 150 : story ? 260 : 200 + const bigY = eyebrowY + 18 + bigSize * 0.86 + ctx.fillStyle = colors.ink + ctx.font = font(800, bigSize, style.sans) + ctx.letterSpacing = `${-bigSize * 0.04}px` + ctx.fillText(content.big, pad - bigSize * 0.03, bigY) + const bigWidth = ctx.measureText(content.big).width + ctx.letterSpacing = '0px' + + const statSize = story ? 72 : 64 + let statX = wide ? pad + bigWidth + 64 : pad + const statY = wide ? bigY - 30 : bigY + 28 + statSize + for (const stat of content.stats) { + ctx.fillStyle = colors.ink + ctx.font = font(800, statSize, style.sans) + ctx.fillText(stat.value, statX, statY) + const valueWidth = ctx.measureText(stat.value).width + ctx.fillStyle = colors.muted + ctx.font = font(600, 24, style.sans) + ctx.fillText(stat.label, statX, statY + 32) + statX += Math.max(valueWidth, ctx.measureText(stat.label).width) + 44 + } + + // Footer facts, bottom up: three columns, or three rows on a story. + const factBlock = story ? content.facts.length * 112 : 128 + const footerTop = H - pad - factBlock + if (content.facts.length) { + ctx.fillStyle = colors.rule + ctx.fillRect(pad, footerTop, W - pad * 2, 2) + const columns = story ? 1 : content.facts.length + const columnWidth = (W - pad * 2 - 36 * (columns - 1)) / columns + content.facts.forEach((fact, index) => { + const x = story ? pad : pad + index * (columnWidth + 36) + const y = story ? footerTop + 46 + index * 112 : footerTop + 50 + ctx.fillStyle = colors.muted + ctx.font = font(700, story ? 22 : 18, style.sans) + ctx.letterSpacing = '2.6px' + ctx.fillText(fitText(ctx, fact.label.toUpperCase(), columnWidth), x, y) + ctx.letterSpacing = '0px' + ctx.fillStyle = colors.ink + ctx.font = font(700, story ? 32 : 24, style.sans) + ctx.fillText(fitText(ctx, fact.value, columnWidth), x, y + (story ? 40 : 34)) + ctx.fillStyle = colors.muted + ctx.font = font(500, story ? 26 : 20, style.sans) + ctx.fillText(fitText(ctx, fact.meta, columnWidth), x, y + (story ? 74 : 62)) + }) + } + + // Covers fill what is left between the header and the footer. + const gridTop = (wide ? bigY : statY + 32) + 40 + const gridBottom = (content.facts.length ? footerTop : H - pad) - 32 + const gap = 12 + const fit = fitTiles(content.tiles.length, W - pad * 2, gridBottom - gridTop, gap) + const shown = content.tiles.slice(0, fit.shown) + const rows = Math.ceil((shown.length + (content.tiles.length > fit.shown ? 1 : 0)) / Math.max(1, fit.columns)) + const gridHeight = rows * fit.tile * 1.5 + Math.max(0, rows - 1) * gap + const top = gridTop + Math.max(0, (gridBottom - gridTop - gridHeight) / 2) + shown.forEach((tile, index) => { + const column = index % fit.columns + const row = Math.floor(index / fit.columns) + drawTile(ctx, tile, pad + column * (fit.tile + gap), top + row * (fit.tile * 1.5 + gap), fit.tile, style, colors) + }) + const rest = content.tiles.length - shown.length + if (rest > 0) { + const index = shown.length + const x = pad + (index % fit.columns) * (fit.tile + gap) + const y = top + Math.floor(index / fit.columns) * (fit.tile * 1.5 + gap) + ctx.save() + ctx.setLineDash([6, 5]) + ctx.strokeStyle = colors.muted + ctx.lineWidth = 2 + roundRect(ctx, x + 1, y + 1, fit.tile - 2, fit.tile * 1.5 - 2, 6) + ctx.stroke() + ctx.restore() + ctx.fillStyle = colors.muted + ctx.font = font(700, Math.max(14, fit.tile * 0.24), style.sans) + ctx.textAlign = 'center' + ctx.fillText(content.moreLabel(rest), x + fit.tile / 2, y + fit.tile * 0.82) + ctx.textAlign = 'start' + } +} diff --git a/client/src/features/reading-history/views/ReadingHistoryView.vue b/client/src/features/reading-history/views/ReadingHistoryView.vue new file mode 100644 index 000000000..c42fb1687 --- /dev/null +++ b/client/src/features/reading-history/views/ReadingHistoryView.vue @@ -0,0 +1,338 @@ + + + + + diff --git a/client/src/features/statistics/components/user/BooksCompletedChart.vue b/client/src/features/statistics/components/user/BooksCompletedChart.vue index 3f8deba8c..2a896730b 100644 --- a/client/src/features/statistics/components/user/BooksCompletedChart.vue +++ b/client/src/features/statistics/components/user/BooksCompletedChart.vue @@ -1,17 +1,21 @@