From e61486fae96d7580b317fce06f19fac5200175ca Mon Sep 17 00:00:00 2001 From: neon <18559658+neonsolstice@users.noreply.github.com> Date: Tue, 17 Mar 2026 23:38:29 -0600 Subject: [PATCH] feat(stats): introduce statistics with charts, KPI summary, and filter --- client/package.json | 4 +- client/src/App.vue | 23 +- client/src/assets/theme/tokens.css | 47 ++- client/src/components/AppSidebar.vue | 8 + .../features/statistics/api/statistics.api.ts | 72 +++++ .../components/BooksAddedOverTimeChart.vue | 133 ++++++++ .../statistics/components/ChartCard.vue | 72 +++++ .../components/FormatDistributionChart.vue | 52 ++++ .../components/GenreDistributionChart.vue | 61 ++++ .../components/LanguageDistributionChart.vue | 52 ++++ .../components/MetadataCompletenessChart.vue | 62 ++++ .../components/PublicationDecadeChart.vue | 63 ++++ .../statistics/components/StatisticsGrid.vue | 83 +++++ .../statistics/components/StatisticsPage.vue | 220 +++++++++++++ .../components/StatisticsSummaryCard.vue | 69 +++++ .../components/StorageByFormatChart.vue | 63 ++++ .../statistics/components/TopAuthorsChart.vue | 61 ++++ .../composables/useBooksAddedOverTime.ts | 29 ++ .../composables/useFormatDistribution.ts | 29 ++ .../composables/useGenreDistribution.ts | 29 ++ .../composables/useLanguageDistribution.ts | 29 ++ .../composables/useMetadataCompleteness.ts | 29 ++ .../composables/usePublicationDecade.ts | 29 ++ .../composables/useStatisticsConfig.ts | 121 ++++++++ .../composables/useStatisticsSummary.ts | 27 ++ .../composables/useStorageByFormat.ts | 29 ++ .../statistics/composables/useTopAuthors.ts | 29 ++ client/src/lib/echarts.ts | 59 ++++ client/src/main.ts | 1 + client/src/router/index.ts | 5 + packages/types/src/auth.ts | 1 + packages/types/src/index.ts | 1 + packages/types/src/statistics.ts | 94 ++++++ server/src/app.module.ts | 2 + .../dto/books-over-time-query.dto.ts | 15 + .../dto/statistics-filter-query.dto.ts | 11 + .../statistics/statistics.controller.ts | 57 ++++ .../modules/statistics/statistics.module.ts | 11 + .../statistics/statistics.repository.ts | 293 ++++++++++++++++++ .../modules/statistics/statistics.service.ts | 105 +++++++ 40 files changed, 2168 insertions(+), 12 deletions(-) create mode 100644 client/src/features/statistics/api/statistics.api.ts create mode 100644 client/src/features/statistics/components/BooksAddedOverTimeChart.vue create mode 100644 client/src/features/statistics/components/ChartCard.vue create mode 100644 client/src/features/statistics/components/FormatDistributionChart.vue create mode 100644 client/src/features/statistics/components/GenreDistributionChart.vue create mode 100644 client/src/features/statistics/components/LanguageDistributionChart.vue create mode 100644 client/src/features/statistics/components/MetadataCompletenessChart.vue create mode 100644 client/src/features/statistics/components/PublicationDecadeChart.vue create mode 100644 client/src/features/statistics/components/StatisticsGrid.vue create mode 100644 client/src/features/statistics/components/StatisticsPage.vue create mode 100644 client/src/features/statistics/components/StatisticsSummaryCard.vue create mode 100644 client/src/features/statistics/components/StorageByFormatChart.vue create mode 100644 client/src/features/statistics/components/TopAuthorsChart.vue create mode 100644 client/src/features/statistics/composables/useBooksAddedOverTime.ts create mode 100644 client/src/features/statistics/composables/useFormatDistribution.ts create mode 100644 client/src/features/statistics/composables/useGenreDistribution.ts create mode 100644 client/src/features/statistics/composables/useLanguageDistribution.ts create mode 100644 client/src/features/statistics/composables/useMetadataCompleteness.ts create mode 100644 client/src/features/statistics/composables/usePublicationDecade.ts create mode 100644 client/src/features/statistics/composables/useStatisticsConfig.ts create mode 100644 client/src/features/statistics/composables/useStatisticsSummary.ts create mode 100644 client/src/features/statistics/composables/useStorageByFormat.ts create mode 100644 client/src/features/statistics/composables/useTopAuthors.ts create mode 100644 client/src/lib/echarts.ts create mode 100644 packages/types/src/statistics.ts create mode 100644 server/src/modules/statistics/dto/books-over-time-query.dto.ts create mode 100644 server/src/modules/statistics/dto/statistics-filter-query.dto.ts create mode 100644 server/src/modules/statistics/statistics.controller.ts create mode 100644 server/src/modules/statistics/statistics.module.ts create mode 100644 server/src/modules/statistics/statistics.repository.ts create mode 100644 server/src/modules/statistics/statistics.service.ts diff --git a/client/package.json b/client/package.json index aa7b1b696..ff4c16a80 100644 --- a/client/package.json +++ b/client/package.json @@ -29,6 +29,7 @@ "@vueuse/core": "^14.2.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "echarts": "^6.0.0", "lucide-vue-next": "^0.577.0", "pdfjs-dist": "^5.5.207", "pinia": "^3.0.4", @@ -38,6 +39,7 @@ "tailwindcss": "^4.2.1", "vue": "^3.5.30", "vue-draggable-plus": "^0.6.1", + "vue-echarts": "^8.0.1", "vue-router": "^5.0.3", "vue-sonner": "^2.0.9", "vue-virtual-scroller": "^1.1.2" @@ -66,7 +68,7 @@ "vite": "^8.0.0", "vite-plugin-vue-devtools": "^8.1.0", "vitest": "^4.1.0", - "vue-tsc": "^3.2.5" + "vue-tsc": "^3.2.6" }, "engines": { "node": "^20.19.0 || >=22.12.0" diff --git a/client/src/App.vue b/client/src/App.vue index 458c497c8..413768c88 100644 --- a/client/src/App.vue +++ b/client/src/App.vue @@ -1,12 +1,33 @@ diff --git a/client/src/assets/theme/tokens.css b/client/src/assets/theme/tokens.css index 9d0aa3f43..2d5f8bfbe 100644 --- a/client/src/assets/theme/tokens.css +++ b/client/src/assets/theme/tokens.css @@ -52,11 +52,25 @@ --sidebar-border: oklch(0.878 var(--tint-c-surface) var(--tint-h)); --sidebar-ring: oklch(0.65 var(--tint-c-surface) var(--tint-h)); - --chart-1: oklch(0.646 0.222 41.116); - --chart-2: oklch(0.6 0.118 184.704); - --chart-3: oklch(0.398 0.07 227.392); - --chart-4: oklch(0.828 0.189 84.429); - --chart-5: oklch(0.769 0.188 70.08); + --chart-1: oklch(0.62 0.16 var(--tint-h)); + --chart-2: oklch(0.6 0.15 calc(var(--tint-h) + 72)); + --chart-3: oklch(0.58 0.14 calc(var(--tint-h) + 144)); + --chart-4: oklch(0.64 0.15 calc(var(--tint-h) + 216)); + --chart-5: oklch(0.62 0.16 calc(var(--tint-h) + 288)); + --chart-6: oklch(0.44 0.14 calc(var(--tint-h) + 36)); + --chart-7: oklch(0.42 0.13 calc(var(--tint-h) + 108)); + --chart-8: oklch(0.4 0.12 calc(var(--tint-h) + 180)); + --chart-9: oklch(0.46 0.13 calc(var(--tint-h) + 252)); + --chart-10: oklch(0.43 0.14 calc(var(--tint-h) + 324)); + --chart-icon-1: oklch(0.58 0.17 var(--tint-h)); + --chart-icon-2: oklch(0.58 0.17 calc(var(--tint-h) + 45)); + --chart-icon-3: oklch(0.58 0.17 calc(var(--tint-h) + 90)); + --chart-icon-4: oklch(0.58 0.17 calc(var(--tint-h) + 135)); + --chart-icon-5: oklch(0.58 0.17 calc(var(--tint-h) + 180)); + --chart-icon-6: oklch(0.58 0.17 calc(var(--tint-h) + 225)); + --chart-icon-7: oklch(0.58 0.17 calc(var(--tint-h) + 270)); + --chart-icon-8: oklch(0.58 0.17 calc(var(--tint-h) + 315)); + --chart-icon-9: oklch(0.58 0.17 calc(var(--tint-h) + 337)); /* Elevation surfaces */ --surface-1: oklch(0.975 var(--tint-c-surface) var(--tint-h)); @@ -120,11 +134,24 @@ --sidebar-border: oklch(1 0 0 / 10%); --sidebar-ring: oklch(0.52 0.008 var(--tint-h)); - --chart-1: oklch(0.488 0.243 264.376); - --chart-2: oklch(0.696 0.17 162.48); - --chart-3: oklch(0.769 0.188 70.08); - --chart-4: oklch(0.627 0.265 303.9); - --chart-5: oklch(0.645 0.246 16.439); + --chart-1: oklch(0.7 0.16 var(--tint-h)); + --chart-2: oklch(0.68 0.15 calc(var(--tint-h) + 72)); + --chart-3: oklch(0.66 0.14 calc(var(--tint-h) + 144)); + --chart-4: oklch(0.72 0.15 calc(var(--tint-h) + 216)); + --chart-5: oklch(0.7 0.16 calc(var(--tint-h) + 288)); + --chart-6: oklch(0.52 0.14 calc(var(--tint-h) + 36)); + --chart-7: oklch(0.5 0.13 calc(var(--tint-h) + 108)); + --chart-8: oklch(0.48 0.12 calc(var(--tint-h) + 180)); + --chart-9: oklch(0.54 0.13 calc(var(--tint-h) + 252)); + --chart-10: oklch(0.51 0.14 calc(var(--tint-h) + 324)); + --chart-icon-1: oklch(0.7 0.17 var(--tint-h)); + --chart-icon-2: oklch(0.7 0.17 calc(var(--tint-h) + 45)); + --chart-icon-3: oklch(0.7 0.17 calc(var(--tint-h) + 90)); + --chart-icon-4: oklch(0.7 0.17 calc(var(--tint-h) + 135)); + --chart-icon-5: oklch(0.7 0.17 calc(var(--tint-h) + 180)); + --chart-icon-6: oklch(0.7 0.17 calc(var(--tint-h) + 225)); + --chart-icon-7: oklch(0.7 0.17 calc(var(--tint-h) + 270)); + --chart-icon-8: oklch(0.7 0.17 calc(var(--tint-h) + 315)); /* Elevation surfaces */ --surface-1: oklch(calc(0.205 + var(--bg-lift)) var(--tint-c-surface) var(--tint-h)); diff --git a/client/src/components/AppSidebar.vue b/client/src/components/AppSidebar.vue index 1fbff4b02..c7fa91c22 100644 --- a/client/src/components/AppSidebar.vue +++ b/client/src/components/AppSidebar.vue @@ -86,6 +86,7 @@ const { const isDashboardActive = computed(() => route.name === 'dashboard') const isStagingActive = computed(() => route.name === 'staging') +const isStatisticsActive = computed(() => route.name === 'statistics') const isAuthorsActive = computed(() => route.name === 'authors' || route.name === 'author-detail') const activeLibraryId = computed(() => { @@ -222,6 +223,13 @@ onUnmounted(() => stopUploadCompletedListener()) label="Authors" @click="router.push({ name: 'authors' })" /> + diff --git a/client/src/features/statistics/api/statistics.api.ts b/client/src/features/statistics/api/statistics.api.ts new file mode 100644 index 000000000..96c3911d7 --- /dev/null +++ b/client/src/features/statistics/api/statistics.api.ts @@ -0,0 +1,72 @@ +import { api } from '@/lib/api' +import type { + BooksAddedDataPoint, + StatisticsSummary, + FormatDistributionItem, + GenreDistributionItem, + LanguageDistributionItem, + MetadataCompletenessItem, + PublicationDecadeItem, + StatisticsFilterConfig, + StatisticsResult, + StorageByFormatItem, + TopAuthorItem, +} from '@projectx/types' + +async function parseResult(res: Response): Promise> { + if (!res.ok) throw new Error(`Statistics request failed: ${res.status}`) + return res.json() as Promise> +} + +function buildParams(filters: StatisticsFilterConfig, extra?: Record): string { + const params = new URLSearchParams() + filters.libraryIds.forEach((id) => params.append('libraryIds', String(id))) + if (extra) Object.entries(extra).forEach(([k, v]) => params.set(k, v)) + const str = params.toString() + return str ? `?${str}` : '' +} + +export async function fetchFormatDistribution(filters: StatisticsFilterConfig): Promise> { + return parseResult(await api(`/api/v1/statistics/format-distribution${buildParams(filters)}`)) +} + +export async function fetchLanguageDistribution(filters: StatisticsFilterConfig): Promise> { + return parseResult(await api(`/api/v1/statistics/language-distribution${buildParams(filters)}`)) +} + +export async function fetchBooksAddedOverTime(filters: StatisticsFilterConfig): Promise> { + return parseResult( + await api( + `/api/v1/statistics/books-added-over-time${buildParams(filters, { + granularity: filters.booksOverTimeGranularity, + range: filters.booksOverTimeRange, + })}`, + ), + ) +} + +export async function fetchStorageByFormat(filters: StatisticsFilterConfig): Promise> { + return parseResult(await api(`/api/v1/statistics/storage-by-format${buildParams(filters)}`)) +} + +export async function fetchPublicationDecade(filters: StatisticsFilterConfig): Promise> { + return parseResult(await api(`/api/v1/statistics/publication-decade${buildParams(filters)}`)) +} + +export async function fetchTopAuthors(filters: StatisticsFilterConfig): Promise> { + return parseResult(await api(`/api/v1/statistics/top-authors${buildParams(filters)}`)) +} + +export async function fetchMetadataCompleteness(filters: StatisticsFilterConfig): Promise> { + return parseResult(await api(`/api/v1/statistics/metadata-completeness${buildParams(filters)}`)) +} + +export async function fetchGenreDistribution(filters: StatisticsFilterConfig): Promise> { + return parseResult(await api(`/api/v1/statistics/genre-distribution${buildParams(filters)}`)) +} + +export async function fetchStatisticsSummary(filters: StatisticsFilterConfig): Promise { + const res = await api(`/api/v1/statistics/summary${buildParams(filters)}`) + if (!res.ok) throw new Error(`Statistics summary request failed: ${res.status}`) + return res.json() as Promise +} diff --git a/client/src/features/statistics/components/BooksAddedOverTimeChart.vue b/client/src/features/statistics/components/BooksAddedOverTimeChart.vue new file mode 100644 index 000000000..4042925c6 --- /dev/null +++ b/client/src/features/statistics/components/BooksAddedOverTimeChart.vue @@ -0,0 +1,133 @@ + + + + + + + + + Monthly + + + Yearly + + + + + + + {{ DATE_RANGE_LABELS[filters.booksOverTimeRange] }} + + + + All Time + Last 5 Years + Last Year + + + + + + + + diff --git a/client/src/features/statistics/components/ChartCard.vue b/client/src/features/statistics/components/ChartCard.vue new file mode 100644 index 000000000..3e65af2f5 --- /dev/null +++ b/client/src/features/statistics/components/ChartCard.vue @@ -0,0 +1,72 @@ + + + + + + + + + + + {{ title }} + + + + + + + + + + + + + + + Failed to load data + + + + + + No data yet + No data available for this chart + + + + + + + + {{ unknownCount }} {{ unknownCount === 1 ? 'book has' : 'books have' }} no data for this field + + + + diff --git a/client/src/features/statistics/components/FormatDistributionChart.vue b/client/src/features/statistics/components/FormatDistributionChart.vue new file mode 100644 index 000000000..78a962dda --- /dev/null +++ b/client/src/features/statistics/components/FormatDistributionChart.vue @@ -0,0 +1,52 @@ + + + + + + + diff --git a/client/src/features/statistics/components/GenreDistributionChart.vue b/client/src/features/statistics/components/GenreDistributionChart.vue new file mode 100644 index 000000000..1ed38c150 --- /dev/null +++ b/client/src/features/statistics/components/GenreDistributionChart.vue @@ -0,0 +1,61 @@ + + + + + + + diff --git a/client/src/features/statistics/components/LanguageDistributionChart.vue b/client/src/features/statistics/components/LanguageDistributionChart.vue new file mode 100644 index 000000000..b06631639 --- /dev/null +++ b/client/src/features/statistics/components/LanguageDistributionChart.vue @@ -0,0 +1,52 @@ + + + + + + + diff --git a/client/src/features/statistics/components/MetadataCompletenessChart.vue b/client/src/features/statistics/components/MetadataCompletenessChart.vue new file mode 100644 index 000000000..0dd997021 --- /dev/null +++ b/client/src/features/statistics/components/MetadataCompletenessChart.vue @@ -0,0 +1,62 @@ + + + + + + + diff --git a/client/src/features/statistics/components/PublicationDecadeChart.vue b/client/src/features/statistics/components/PublicationDecadeChart.vue new file mode 100644 index 000000000..b1a3c041a --- /dev/null +++ b/client/src/features/statistics/components/PublicationDecadeChart.vue @@ -0,0 +1,63 @@ + + + + + + + diff --git a/client/src/features/statistics/components/StatisticsGrid.vue b/client/src/features/statistics/components/StatisticsGrid.vue new file mode 100644 index 000000000..bb7ba16ca --- /dev/null +++ b/client/src/features/statistics/components/StatisticsGrid.vue @@ -0,0 +1,83 @@ + + + + + + + + + diff --git a/client/src/features/statistics/components/StatisticsPage.vue b/client/src/features/statistics/components/StatisticsPage.vue new file mode 100644 index 000000000..f61f97dac --- /dev/null +++ b/client/src/features/statistics/components/StatisticsPage.vue @@ -0,0 +1,220 @@ + + + + + + + Library Statistics + + + + + + + {{ libraryLabel }} + + + + + + + + + All Libraries + + + + + + + {{ lib.name }} + + + + + + + Configure + + + + + + + + + + + Configure Charts + + {{ visibleCount }} / {{ totalCount }} + + + Drag to reorder, toggle to show or hide. + + + + + + + + + {{ CHART_LABELS[chart.id] }} + + + + + + + + + Reset to defaults + + + + + + + + diff --git a/client/src/features/statistics/components/StatisticsSummaryCard.vue b/client/src/features/statistics/components/StatisticsSummaryCard.vue new file mode 100644 index 000000000..b2ed20cd8 --- /dev/null +++ b/client/src/features/statistics/components/StatisticsSummaryCard.vue @@ -0,0 +1,69 @@ + + + + + + + + + + + + + + + {{ kpi.value }} + {{ kpi.label }} + + + + + + diff --git a/client/src/features/statistics/components/StorageByFormatChart.vue b/client/src/features/statistics/components/StorageByFormatChart.vue new file mode 100644 index 000000000..17b615efe --- /dev/null +++ b/client/src/features/statistics/components/StorageByFormatChart.vue @@ -0,0 +1,63 @@ + + + + + + + diff --git a/client/src/features/statistics/components/TopAuthorsChart.vue b/client/src/features/statistics/components/TopAuthorsChart.vue new file mode 100644 index 000000000..6d6e0261f --- /dev/null +++ b/client/src/features/statistics/components/TopAuthorsChart.vue @@ -0,0 +1,61 @@ + + + + + + + diff --git a/client/src/features/statistics/composables/useBooksAddedOverTime.ts b/client/src/features/statistics/composables/useBooksAddedOverTime.ts new file mode 100644 index 000000000..3c622a9be --- /dev/null +++ b/client/src/features/statistics/composables/useBooksAddedOverTime.ts @@ -0,0 +1,29 @@ +import { onMounted, ref, watch } from 'vue' + +import type { BooksAddedDataPoint, StatisticsResult } from '@projectx/types' +import { fetchBooksAddedOverTime } from '../api/statistics.api' +import { useStatisticsConfig } from './useStatisticsConfig' + +export function useBooksAddedOverTime() { + const data = ref>({ items: [], unknownCount: 0 }) + const loading = ref(true) + const error = ref(false) + + const { filters } = useStatisticsConfig() + + async function load() { + loading.value = true + error.value = false + try { + data.value = await fetchBooksAddedOverTime(filters.value) + } catch { + error.value = true + } finally { + loading.value = false + } + } + + watch([() => filters.value.libraryIds.join(','), () => filters.value.booksOverTimeGranularity, () => filters.value.booksOverTimeRange], load) + onMounted(load) + return { data, loading, error } +} diff --git a/client/src/features/statistics/composables/useFormatDistribution.ts b/client/src/features/statistics/composables/useFormatDistribution.ts new file mode 100644 index 000000000..e2e800908 --- /dev/null +++ b/client/src/features/statistics/composables/useFormatDistribution.ts @@ -0,0 +1,29 @@ +import { onMounted, ref, watch } from 'vue' + +import type { FormatDistributionItem, StatisticsResult } from '@projectx/types' +import { fetchFormatDistribution } from '../api/statistics.api' +import { useStatisticsConfig } from './useStatisticsConfig' + +export function useFormatDistribution() { + const data = ref>({ items: [], unknownCount: 0 }) + const loading = ref(true) + const error = ref(false) + + const { filters } = useStatisticsConfig() + + async function load() { + loading.value = true + error.value = false + try { + data.value = await fetchFormatDistribution(filters.value) + } catch { + error.value = true + } finally { + loading.value = false + } + } + + watch(() => filters.value.libraryIds.join(','), load) + onMounted(load) + return { data, loading, error } +} diff --git a/client/src/features/statistics/composables/useGenreDistribution.ts b/client/src/features/statistics/composables/useGenreDistribution.ts new file mode 100644 index 000000000..b4117e256 --- /dev/null +++ b/client/src/features/statistics/composables/useGenreDistribution.ts @@ -0,0 +1,29 @@ +import { onMounted, ref, watch } from 'vue' + +import type { GenreDistributionItem, StatisticsResult } from '@projectx/types' +import { fetchGenreDistribution } from '../api/statistics.api' +import { useStatisticsConfig } from './useStatisticsConfig' + +export function useGenreDistribution() { + const data = ref>({ items: [], unknownCount: 0 }) + const loading = ref(true) + const error = ref(false) + + const { filters } = useStatisticsConfig() + + async function load() { + loading.value = true + error.value = false + try { + data.value = await fetchGenreDistribution(filters.value) + } catch { + error.value = true + } finally { + loading.value = false + } + } + + watch(() => filters.value.libraryIds.join(','), load) + onMounted(load) + return { data, loading, error } +} diff --git a/client/src/features/statistics/composables/useLanguageDistribution.ts b/client/src/features/statistics/composables/useLanguageDistribution.ts new file mode 100644 index 000000000..08fd2c1a9 --- /dev/null +++ b/client/src/features/statistics/composables/useLanguageDistribution.ts @@ -0,0 +1,29 @@ +import { onMounted, ref, watch } from 'vue' + +import type { LanguageDistributionItem, StatisticsResult } from '@projectx/types' +import { fetchLanguageDistribution } from '../api/statistics.api' +import { useStatisticsConfig } from './useStatisticsConfig' + +export function useLanguageDistribution() { + const data = ref>({ items: [], unknownCount: 0 }) + const loading = ref(true) + const error = ref(false) + + const { filters } = useStatisticsConfig() + + async function load() { + loading.value = true + error.value = false + try { + data.value = await fetchLanguageDistribution(filters.value) + } catch { + error.value = true + } finally { + loading.value = false + } + } + + watch(() => filters.value.libraryIds.join(','), load) + onMounted(load) + return { data, loading, error } +} diff --git a/client/src/features/statistics/composables/useMetadataCompleteness.ts b/client/src/features/statistics/composables/useMetadataCompleteness.ts new file mode 100644 index 000000000..dfdbee0cd --- /dev/null +++ b/client/src/features/statistics/composables/useMetadataCompleteness.ts @@ -0,0 +1,29 @@ +import { onMounted, ref, watch } from 'vue' + +import type { MetadataCompletenessItem, StatisticsResult } from '@projectx/types' +import { fetchMetadataCompleteness } from '../api/statistics.api' +import { useStatisticsConfig } from './useStatisticsConfig' + +export function useMetadataCompleteness() { + const data = ref>({ items: [], unknownCount: 0 }) + const loading = ref(true) + const error = ref(false) + + const { filters } = useStatisticsConfig() + + async function load() { + loading.value = true + error.value = false + try { + data.value = await fetchMetadataCompleteness(filters.value) + } catch { + error.value = true + } finally { + loading.value = false + } + } + + watch(() => filters.value.libraryIds.join(','), load) + onMounted(load) + return { data, loading, error } +} diff --git a/client/src/features/statistics/composables/usePublicationDecade.ts b/client/src/features/statistics/composables/usePublicationDecade.ts new file mode 100644 index 000000000..d773bfccb --- /dev/null +++ b/client/src/features/statistics/composables/usePublicationDecade.ts @@ -0,0 +1,29 @@ +import { onMounted, ref, watch } from 'vue' + +import type { PublicationDecadeItem, StatisticsResult } from '@projectx/types' +import { fetchPublicationDecade } from '../api/statistics.api' +import { useStatisticsConfig } from './useStatisticsConfig' + +export function usePublicationDecade() { + const data = ref>({ items: [], unknownCount: 0 }) + const loading = ref(true) + const error = ref(false) + + const { filters } = useStatisticsConfig() + + async function load() { + loading.value = true + error.value = false + try { + data.value = await fetchPublicationDecade(filters.value) + } catch { + error.value = true + } finally { + loading.value = false + } + } + + watch(() => filters.value.libraryIds.join(','), load) + onMounted(load) + return { data, loading, error } +} diff --git a/client/src/features/statistics/composables/useStatisticsConfig.ts b/client/src/features/statistics/composables/useStatisticsConfig.ts new file mode 100644 index 000000000..6d8ab0a11 --- /dev/null +++ b/client/src/features/statistics/composables/useStatisticsConfig.ts @@ -0,0 +1,121 @@ +import { computed, ref } from 'vue' +import { toast } from 'vue-sonner' + +import type { + ChartConfigEntry, + StatisticsChartId, + StatisticsDateRange, + StatisticsFilterConfig, + StatisticsGranularity, + StatisticsSettings, +} from '@projectx/types' +import { api } from '@/lib/api' +import { useAuth } from '@/features/auth/composables/useAuth' + +// Must stay in sync with CHART_REGISTRY in StatisticsGrid.vue. +const KNOWN_CHART_IDS: StatisticsChartId[] = [ + 'format-distribution', + 'language-distribution', + 'books-added-over-time', + 'storage-by-format', + 'publication-decade', + 'top-authors', + 'metadata-completeness', + 'genre-distribution', +] + +const DEFAULT_FILTERS: StatisticsFilterConfig = { + libraryIds: [], + booksOverTimeGranularity: 'monthly', + booksOverTimeRange: 'all-time', +} + +const config = ref([]) +const filters = ref({ ...DEFAULT_FILTERS }) + +function normalizeCharts(saved: ChartConfigEntry[] | undefined): ChartConfigEntry[] { + const knownSet = new Set(KNOWN_CHART_IDS) + const filtered = (saved ?? []).filter((e) => knownSet.has(e.id)) + const savedIds = new Set(filtered.map((e) => e.id)) + const newEntries: ChartConfigEntry[] = KNOWN_CHART_IDS.filter((id) => !savedIds.has(id)).map((id, i) => ({ + id, + visible: true, + order: filtered.length + i, + })) + return [...filtered, ...newEntries] +} + +function normalizeFilters(saved: StatisticsFilterConfig | undefined): StatisticsFilterConfig { + return { + libraryIds: saved?.libraryIds ?? DEFAULT_FILTERS.libraryIds, + booksOverTimeGranularity: saved?.booksOverTimeGranularity ?? DEFAULT_FILTERS.booksOverTimeGranularity, + booksOverTimeRange: saved?.booksOverTimeRange ?? DEFAULT_FILTERS.booksOverTimeRange, + } +} + +async function persist() { + const payload: StatisticsSettings = { charts: config.value, filters: filters.value } + try { + await api('/api/v1/users/me', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ settings: { statisticsConfig: payload } }), + }) + } catch { + toast.error('Failed to save chart configuration') + } +} + +let saveTimer: ReturnType | null = null +function scheduleSave() { + if (saveTimer) clearTimeout(saveTimer) + saveTimer = setTimeout(persist, 600) +} + +export function useStatisticsConfig() { + const { user } = useAuth() + + function init() { + const saved = user.value?.settings?.statisticsConfig + config.value = normalizeCharts(saved?.charts) + filters.value = normalizeFilters(saved?.filters) + } + + const orderedCharts = computed(() => [...config.value].sort((a, b) => a.order - b.order)) + const visibleCharts = computed(() => orderedCharts.value.filter((c) => c.visible)) + + function toggleVisibility(id: StatisticsChartId) { + const entry = config.value.find((c) => c.id === id) + if (!entry) return + entry.visible = !entry.visible + scheduleSave() + } + + function reorder(newOrder: ChartConfigEntry[]) { + config.value = newOrder.map((entry, i) => ({ ...entry, order: i })) + scheduleSave() + } + + function setLibraryFilter(ids: number[]) { + filters.value = { ...filters.value, libraryIds: ids } + scheduleSave() + } + + function setGranularity(g: StatisticsGranularity) { + filters.value = { ...filters.value, booksOverTimeGranularity: g } + scheduleSave() + } + + function setDateRange(r: StatisticsDateRange) { + filters.value = { ...filters.value, booksOverTimeRange: r } + scheduleSave() + } + + function resetToDefaults() { + config.value = KNOWN_CHART_IDS.map((id, i) => ({ id, visible: true, order: i })) + filters.value = { ...DEFAULT_FILTERS } + scheduleSave() + } + + return { orderedCharts, visibleCharts, filters, init, toggleVisibility, reorder, resetToDefaults, setLibraryFilter, setGranularity, setDateRange } +} diff --git a/client/src/features/statistics/composables/useStatisticsSummary.ts b/client/src/features/statistics/composables/useStatisticsSummary.ts new file mode 100644 index 000000000..5fb0eb5af --- /dev/null +++ b/client/src/features/statistics/composables/useStatisticsSummary.ts @@ -0,0 +1,27 @@ +import { ref, watch } from 'vue' + +import type { StatisticsSummary } from '@projectx/types' + +import { fetchStatisticsSummary } from '../api/statistics.api' +import { useStatisticsConfig } from './useStatisticsConfig' + +export function useStatisticsSummary() { + const { filters } = useStatisticsConfig() + const data = ref(null) + const loading = ref(true) + + async function load() { + loading.value = true + try { + data.value = await fetchStatisticsSummary(filters.value) + } catch { + // leave stale data on error — header degrades gracefully with dashes + } finally { + loading.value = false + } + } + + watch(() => filters.value.libraryIds.join(','), load, { immediate: true }) + + return { data, loading } +} diff --git a/client/src/features/statistics/composables/useStorageByFormat.ts b/client/src/features/statistics/composables/useStorageByFormat.ts new file mode 100644 index 000000000..acc52896b --- /dev/null +++ b/client/src/features/statistics/composables/useStorageByFormat.ts @@ -0,0 +1,29 @@ +import { onMounted, ref, watch } from 'vue' + +import type { StorageByFormatItem, StatisticsResult } from '@projectx/types' +import { fetchStorageByFormat } from '../api/statistics.api' +import { useStatisticsConfig } from './useStatisticsConfig' + +export function useStorageByFormat() { + const data = ref>({ items: [], unknownCount: 0 }) + const loading = ref(true) + const error = ref(false) + + const { filters } = useStatisticsConfig() + + async function load() { + loading.value = true + error.value = false + try { + data.value = await fetchStorageByFormat(filters.value) + } catch { + error.value = true + } finally { + loading.value = false + } + } + + watch(() => filters.value.libraryIds.join(','), load) + onMounted(load) + return { data, loading, error } +} diff --git a/client/src/features/statistics/composables/useTopAuthors.ts b/client/src/features/statistics/composables/useTopAuthors.ts new file mode 100644 index 000000000..93916f258 --- /dev/null +++ b/client/src/features/statistics/composables/useTopAuthors.ts @@ -0,0 +1,29 @@ +import { onMounted, ref, watch } from 'vue' + +import type { TopAuthorItem, StatisticsResult } from '@projectx/types' +import { fetchTopAuthors } from '../api/statistics.api' +import { useStatisticsConfig } from './useStatisticsConfig' + +export function useTopAuthors() { + const data = ref>({ items: [], unknownCount: 0 }) + const loading = ref(true) + const error = ref(false) + + const { filters } = useStatisticsConfig() + + async function load() { + loading.value = true + error.value = false + try { + data.value = await fetchTopAuthors(filters.value) + } catch { + error.value = true + } finally { + loading.value = false + } + } + + watch(() => filters.value.libraryIds.join(','), load) + onMounted(load) + return { data, loading, error } +} diff --git a/client/src/lib/echarts.ts b/client/src/lib/echarts.ts new file mode 100644 index 000000000..2592be992 --- /dev/null +++ b/client/src/lib/echarts.ts @@ -0,0 +1,59 @@ +import { use, registerTheme } from 'echarts/core' +import { SVGRenderer } from 'echarts/renderers' +import { BarChart, LineChart, PieChart } from 'echarts/charts' +import { GridComponent, LegendComponent, TitleComponent, TooltipComponent } from 'echarts/components' + +// SVG renderer: events fire on real DOM elements, not via canvas hit-test. +// This eliminates the cursor-flicker / hover-disappear bug that canvas +// hit-test coordinate mismatches cause with this layout. +use([SVGRenderer, PieChart, BarChart, LineChart, TitleComponent, TooltipComponent, LegendComponent, GridComponent]) + +function readCssColor(varName: string): string { + const el = document.createElement('span') + el.style.color = `var(${varName})` + document.body.appendChild(el) + const color = getComputedStyle(el).color + el.remove() + return color +} + +function buildTheme() { + const border = readCssColor('--border') + const mutedFg = readCssColor('--muted-foreground') + const axisConfig = { + axisLine: { show: true, lineStyle: { color: border } }, + axisTick: { show: false }, + axisLabel: { show: true, color: mutedFg }, + splitLine: { show: true, lineStyle: { color: [border] } }, + splitArea: { show: false }, + } + return { + color: [ + readCssColor('--chart-1'), + readCssColor('--chart-2'), + readCssColor('--chart-3'), + readCssColor('--chart-4'), + readCssColor('--chart-5'), + readCssColor('--chart-6'), + readCssColor('--chart-7'), + readCssColor('--chart-8'), + readCssColor('--chart-9'), + readCssColor('--chart-10'), + ], + backgroundColor: 'transparent', + legend: { textStyle: { color: readCssColor('--foreground') } }, + tooltip: { + backgroundColor: readCssColor('--card'), + borderColor: border, + textStyle: { color: readCssColor('--card-foreground') }, + }, + categoryAxis: axisConfig, + valueAxis: axisConfig, + logAxis: axisConfig, + timeAxis: axisConfig, + } +} + +export function initChartThemes(version: number = 0): void { + registerTheme(`projectx-v${version}`, buildTheme()) +} diff --git a/client/src/main.ts b/client/src/main.ts index 6e70a93bc..b2d3e1709 100644 --- a/client/src/main.ts +++ b/client/src/main.ts @@ -1,4 +1,5 @@ import './assets/main.css' +import './lib/echarts' import { createApp } from 'vue' import { createPinia } from 'pinia' diff --git a/client/src/router/index.ts b/client/src/router/index.ts index 63727e873..4f2e8455c 100644 --- a/client/src/router/index.ts +++ b/client/src/router/index.ts @@ -116,6 +116,11 @@ const router = createRouter({ name: 'staging', component: () => import('@/views/StagingView.vue'), }, + { + path: '/statistics', + name: 'statistics', + component: () => import('@/features/statistics/components/StatisticsPage.vue'), + }, { path: '/library/:id', name: 'library', diff --git a/packages/types/src/auth.ts b/packages/types/src/auth.ts index b3799b6dc..8c01e6991 100644 --- a/packages/types/src/auth.ts +++ b/packages/types/src/auth.ts @@ -1,5 +1,6 @@ export interface UserSettings { syncReaderPreferences?: boolean; + statisticsConfig?: import('./statistics').StatisticsSettings; } export interface AuthUser { diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index aa4405df7..f99048db9 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -20,3 +20,4 @@ export * from "./epub"; export * from "./author"; export * from "./metadata-score"; export * from "./book-metadata-fetch"; +export * from "./statistics"; diff --git a/packages/types/src/statistics.ts b/packages/types/src/statistics.ts new file mode 100644 index 000000000..3cde5fa0b --- /dev/null +++ b/packages/types/src/statistics.ts @@ -0,0 +1,94 @@ +export type StatisticsChartId = + | 'format-distribution' + | 'language-distribution' + | 'books-added-over-time' + | 'storage-by-format' + | 'publication-decade' + | 'top-authors' + | 'metadata-completeness' + | 'genre-distribution'; + +export type StatisticsGranularity = 'monthly' | 'yearly'; +export type StatisticsDateRange = 'last-year' | 'last-5-years' | 'all-time'; + +// Stored in users.settings JSONB under key 'statisticsConfig'. +// Does NOT include 'wide' — that is fixed per chart type in CHART_REGISTRY. +export interface ChartConfigEntry { + id: StatisticsChartId; + visible: boolean; + order: number; +} + +export interface StatisticsFilterConfig { + libraryIds: number[]; + booksOverTimeGranularity: StatisticsGranularity; + booksOverTimeRange: StatisticsDateRange; +} + +export interface StatisticsSettings { + charts: ChartConfigEntry[]; + filters: StatisticsFilterConfig; +} + +// Generic wrapper returned by all statistics endpoints. +// unknownCount = books excluded due to NULL in the relevant metadata field. +// Is 0 for charts where the source column is never NULL (format, addedAt). +export interface StatisticsResult { + items: T[]; + unknownCount: number; +} + +export interface FormatDistributionItem { + format: string; + count: number; +} + +export interface LanguageDistributionItem { + language: string; + count: number; +} + +export interface BooksAddedDataPoint { + year: number; + month: number; + count: number; +} + +export interface StorageByFormatItem { + format: string; + sizeBytes: number; +} + +export interface PublicationDecadeItem { + decade: number; + count: number; +} + +export interface TopAuthorItem { + name: string; + count: number; +} + +export interface MetadataCompletenessItem { + field: string; + presentCount: number; + totalCount: number; +} + +export interface GenreDistributionItem { + genre: string; + count: number; +} + +export interface StatisticsSummary { + totalBooks: number; + totalAuthors: number; + totalSeries: number; + totalPublishers: number; + totalStorageBytes: number; + totalGenres: number; + totalLanguages: number; + publicationYearMin: number | null; + publicationYearMax: number | null; + booksAddedThisYear: number; +} diff --git a/server/src/app.module.ts b/server/src/app.module.ts index 969169763..6d959301b 100644 --- a/server/src/app.module.ts +++ b/server/src/app.module.ts @@ -44,6 +44,7 @@ import { UserModule } from './modules/user/user.module'; import { AuthorsModule } from './modules/authors/authors.module'; import { BookMetadataFetchModule } from './modules/book-metadata-fetch/book-metadata-fetch.module'; import { MetadataScoreModule } from './modules/metadata-score/metadata-score.module'; +import { StatisticsModule } from './modules/statistics/statistics.module'; @Module({ imports: [ @@ -88,6 +89,7 @@ import { MetadataScoreModule } from './modules/metadata-score/metadata-score.mod StagingModule, EmailModule, MetadataScoreModule, + StatisticsModule, ], providers: [ { provide: APP_GUARD, useClass: JwtAuthGuard }, diff --git a/server/src/modules/statistics/dto/books-over-time-query.dto.ts b/server/src/modules/statistics/dto/books-over-time-query.dto.ts new file mode 100644 index 000000000..0e5940520 --- /dev/null +++ b/server/src/modules/statistics/dto/books-over-time-query.dto.ts @@ -0,0 +1,15 @@ +import { IsEnum, IsOptional } from 'class-validator'; + +import type { StatisticsDateRange, StatisticsGranularity } from '@projectx/types'; + +import { StatisticsFilterQueryDto } from './statistics-filter-query.dto'; + +export class BooksOverTimeQueryDto extends StatisticsFilterQueryDto { + @IsOptional() + @IsEnum(['monthly', 'yearly']) + granularity?: StatisticsGranularity; + + @IsOptional() + @IsEnum(['last-year', 'last-5-years', 'all-time']) + range?: StatisticsDateRange; +} diff --git a/server/src/modules/statistics/dto/statistics-filter-query.dto.ts b/server/src/modules/statistics/dto/statistics-filter-query.dto.ts new file mode 100644 index 000000000..eeff199a2 --- /dev/null +++ b/server/src/modules/statistics/dto/statistics-filter-query.dto.ts @@ -0,0 +1,11 @@ +import { Transform, Type } from 'class-transformer'; +import { IsArray, IsInt, IsOptional } from 'class-validator'; + +export class StatisticsFilterQueryDto { + @IsOptional() + @Transform(({ value }) => (Array.isArray(value) ? value : value != null ? [value] : [])) + @Type(() => Number) + @IsArray() + @IsInt({ each: true }) + libraryIds?: number[]; +} diff --git a/server/src/modules/statistics/statistics.controller.ts b/server/src/modules/statistics/statistics.controller.ts new file mode 100644 index 000000000..c61d26501 --- /dev/null +++ b/server/src/modules/statistics/statistics.controller.ts @@ -0,0 +1,57 @@ +import { Controller, Get, Query } from '@nestjs/common'; + +import { CurrentUser } from '../../common/decorators/current-user.decorator'; +import type { RequestUser } from '../../common/types/request-user'; +import { BooksOverTimeQueryDto } from './dto/books-over-time-query.dto'; +import { StatisticsFilterQueryDto } from './dto/statistics-filter-query.dto'; +import { StatisticsService } from './statistics.service'; + +@Controller('statistics') +export class StatisticsController { + constructor(private readonly statisticsService: StatisticsService) {} + + @Get('format-distribution') + getFormatDistribution(@CurrentUser() user: RequestUser, @Query() query: StatisticsFilterQueryDto) { + return this.statisticsService.getFormatDistribution(user, query); + } + + @Get('language-distribution') + getLanguageDistribution(@CurrentUser() user: RequestUser, @Query() query: StatisticsFilterQueryDto) { + return this.statisticsService.getLanguageDistribution(user, query); + } + + @Get('books-added-over-time') + getBooksAddedOverTime(@CurrentUser() user: RequestUser, @Query() query: BooksOverTimeQueryDto) { + return this.statisticsService.getBooksAddedOverTime(user, query); + } + + @Get('storage-by-format') + getStorageByFormat(@CurrentUser() user: RequestUser, @Query() query: StatisticsFilterQueryDto) { + return this.statisticsService.getStorageByFormat(user, query); + } + + @Get('publication-decade') + getPublicationDecade(@CurrentUser() user: RequestUser, @Query() query: StatisticsFilterQueryDto) { + return this.statisticsService.getPublicationDecade(user, query); + } + + @Get('top-authors') + getTopAuthors(@CurrentUser() user: RequestUser, @Query() query: StatisticsFilterQueryDto) { + return this.statisticsService.getTopAuthors(user, query); + } + + @Get('metadata-completeness') + getMetadataCompleteness(@CurrentUser() user: RequestUser, @Query() query: StatisticsFilterQueryDto) { + return this.statisticsService.getMetadataCompleteness(user, query); + } + + @Get('genre-distribution') + getGenreDistribution(@CurrentUser() user: RequestUser, @Query() query: StatisticsFilterQueryDto) { + return this.statisticsService.getGenreDistribution(user, query); + } + + @Get('summary') + getSummary(@CurrentUser() user: RequestUser, @Query() query: StatisticsFilterQueryDto) { + return this.statisticsService.getSummary(user, query); + } +} diff --git a/server/src/modules/statistics/statistics.module.ts b/server/src/modules/statistics/statistics.module.ts new file mode 100644 index 000000000..95faa5b56 --- /dev/null +++ b/server/src/modules/statistics/statistics.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; + +import { StatisticsController } from './statistics.controller'; +import { StatisticsRepository } from './statistics.repository'; +import { StatisticsService } from './statistics.service'; + +@Module({ + controllers: [StatisticsController], + providers: [StatisticsService, StatisticsRepository], +}) +export class StatisticsModule {} diff --git a/server/src/modules/statistics/statistics.repository.ts b/server/src/modules/statistics/statistics.repository.ts new file mode 100644 index 000000000..1da6b26ef --- /dev/null +++ b/server/src/modules/statistics/statistics.repository.ts @@ -0,0 +1,293 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { and, desc, eq, gte, inArray, isNotNull, isNull, sql } from 'drizzle-orm'; +import type { NodePgDatabase } from 'drizzle-orm/node-postgres'; + +import type { StatisticsDateRange, StatisticsGranularity } from '@projectx/types'; + +import { DB } from '../../db'; +import * as schema from '../../db/schema'; +import { bookAuthors, bookFiles, bookGenres, bookMetadata, books, genres, authors, userLibraryAccess } from '../../db/schema'; + +type Db = NodePgDatabase; + +@Injectable() +export class StatisticsRepository { + constructor(@Inject(DB) private readonly db: Db) {} + + private async getAccessibleLibraryIds(userId: number, isSuperuser: boolean): Promise { + if (isSuperuser) return null; + const rows = await this.db.select({ libraryId: userLibraryAccess.libraryId }).from(userLibraryAccess).where(eq(userLibraryAccess.userId, userId)); + return rows.map((r) => r.libraryId); + } + + private intersectLibraryIds(accessible: number[] | null, requested: number[] | undefined): number[] | null { + if (!requested || requested.length === 0) return accessible; + if (accessible === null) return requested; + const set = new Set(accessible); + return requested.filter((id) => set.has(id)); + } + + private libraryFilter(libraryIds: number[] | null) { + if (libraryIds === null) return undefined; + if (libraryIds.length === 0) return sql`false`; + return inArray(books.libraryId, libraryIds); + } + + async formatDistribution(userId: number, isSuperuser: boolean, filterLibraryIds?: number[]) { + const accessible = await this.getAccessibleLibraryIds(userId, isSuperuser); + const filter = this.libraryFilter(this.intersectLibraryIds(accessible, filterLibraryIds)); + return this.db + .select({ + format: bookFiles.format, + count: sql`count(distinct ${bookFiles.bookId})::int`, + }) + .from(bookFiles) + .innerJoin(books, eq(bookFiles.bookId, books.id)) + .where(and(isNotNull(bookFiles.format), filter)) + .groupBy(bookFiles.format) + .orderBy(desc(sql`count(distinct ${bookFiles.bookId})`)); + } + + async languageDistribution(userId: number, isSuperuser: boolean, filterLibraryIds?: number[]) { + const accessible = await this.getAccessibleLibraryIds(userId, isSuperuser); + const filter = this.libraryFilter(this.intersectLibraryIds(accessible, filterLibraryIds)); + + const items = await this.db + .select({ + language: bookMetadata.language, + count: sql`count(*)::int`, + }) + .from(books) + .innerJoin(bookMetadata, eq(bookMetadata.bookId, books.id)) + .where(and(isNotNull(bookMetadata.language), filter)) + .groupBy(bookMetadata.language) + .orderBy(desc(sql`count(*)`)); + + const [{ unknownCount }] = await this.db + .select({ unknownCount: sql`count(*)::int` }) + .from(books) + .leftJoin(bookMetadata, eq(bookMetadata.bookId, books.id)) + .where(and(isNull(bookMetadata.language), filter)); + + return { items, unknownCount }; + } + + async booksAddedOverTime( + userId: number, + isSuperuser: boolean, + filterLibraryIds?: number[], + granularity: StatisticsGranularity = 'monthly', + range: StatisticsDateRange = 'all-time', + ) { + const accessible = await this.getAccessibleLibraryIds(userId, isSuperuser); + const libFilter = this.libraryFilter(this.intersectLibraryIds(accessible, filterLibraryIds)); + + const rangeFilter = + range === 'last-year' + ? gte(books.addedAt, sql`now() - interval '1 year'`) + : range === 'last-5-years' + ? gte(books.addedAt, sql`now() - interval '5 years'`) + : undefined; + + if (granularity === 'yearly') { + return this.db + .select({ + year: sql`extract(year from ${books.addedAt})::int`, + month: sql`0`, + count: sql`count(*)::int`, + }) + .from(books) + .where(and(libFilter, rangeFilter)) + .groupBy(sql`extract(year from ${books.addedAt})`) + .orderBy(sql`extract(year from ${books.addedAt})`); + } + + return this.db + .select({ + year: sql`extract(year from ${books.addedAt})::int`, + month: sql`extract(month from ${books.addedAt})::int`, + count: sql`count(*)::int`, + }) + .from(books) + .where(and(libFilter, rangeFilter)) + .groupBy(sql`extract(year from ${books.addedAt})`, sql`extract(month from ${books.addedAt})`) + .orderBy(sql`extract(year from ${books.addedAt})`, sql`extract(month from ${books.addedAt})`); + } + + async storageByFormat(userId: number, isSuperuser: boolean, filterLibraryIds?: number[]) { + const accessible = await this.getAccessibleLibraryIds(userId, isSuperuser); + const filter = this.libraryFilter(this.intersectLibraryIds(accessible, filterLibraryIds)); + return this.db + .select({ + format: bookFiles.format, + sizeBytes: sql`coalesce(sum(${bookFiles.sizeBytes}), 0)::bigint`, + }) + .from(bookFiles) + .innerJoin(books, eq(bookFiles.bookId, books.id)) + .where(and(isNotNull(bookFiles.format), isNotNull(bookFiles.sizeBytes), filter)) + .groupBy(bookFiles.format) + .orderBy(desc(sql`sum(${bookFiles.sizeBytes})`)); + } + + async publicationDecade(userId: number, isSuperuser: boolean, filterLibraryIds?: number[]) { + const accessible = await this.getAccessibleLibraryIds(userId, isSuperuser); + const filter = this.libraryFilter(this.intersectLibraryIds(accessible, filterLibraryIds)); + + const items = await this.db + .select({ + decade: sql`(floor(${bookMetadata.publishedYear} / 10) * 10)::int`, + count: sql`count(*)::int`, + }) + .from(books) + .innerJoin(bookMetadata, eq(bookMetadata.bookId, books.id)) + .where(and(isNotNull(bookMetadata.publishedYear), filter)) + .groupBy(sql`floor(${bookMetadata.publishedYear} / 10) * 10`) + .orderBy(sql`floor(${bookMetadata.publishedYear} / 10) * 10`); + + const [{ unknownCount }] = await this.db + .select({ unknownCount: sql`count(*)::int` }) + .from(books) + .leftJoin(bookMetadata, eq(bookMetadata.bookId, books.id)) + .where(and(isNull(bookMetadata.publishedYear), filter)); + + return { items, unknownCount }; + } + + async topAuthors(userId: number, isSuperuser: boolean, filterLibraryIds?: number[]) { + const accessible = await this.getAccessibleLibraryIds(userId, isSuperuser); + const filter = this.libraryFilter(this.intersectLibraryIds(accessible, filterLibraryIds)); + + return this.db + .select({ + name: authors.name, + count: sql`count(distinct ${bookAuthors.bookId})::int`, + }) + .from(bookAuthors) + .innerJoin(authors, eq(authors.id, bookAuthors.authorId)) + .innerJoin(books, eq(books.id, bookAuthors.bookId)) + .where(filter) + .groupBy(authors.name) + .orderBy(desc(sql`count(distinct ${bookAuthors.bookId})`)); + } + + async metadataCompleteness(userId: number, isSuperuser: boolean, filterLibraryIds?: number[]) { + const accessible = await this.getAccessibleLibraryIds(userId, isSuperuser); + const filter = this.libraryFilter(this.intersectLibraryIds(accessible, filterLibraryIds)); + + const [row] = await this.db + .select({ + total: sql`count(*)::int`, + hasTitle: sql`count(${bookMetadata.title})::int`, + hasCover: sql`count(${bookMetadata.coverSource})::int`, + hasDescription: sql`count(${bookMetadata.description})::int`, + hasPublisher: sql`count(${bookMetadata.publisher})::int`, + hasYear: sql`count(${bookMetadata.publishedYear})::int`, + hasLanguage: sql`count(${bookMetadata.language})::int`, + hasPageCount: sql`count(${bookMetadata.pageCount})::int`, + hasRating: sql`count(${bookMetadata.rating})::int`, + hasSeries: sql`count(${bookMetadata.seriesName})::int`, + hasIsbn: sql`count(case when ${bookMetadata.isbn13} is not null or ${bookMetadata.isbn10} is not null then 1 end)::int`, + hasAuthor: sql`count(distinct ba.book_id)::int`, + }) + .from(books) + .leftJoin(bookMetadata, eq(bookMetadata.bookId, books.id)) + .leftJoin(sql`(select distinct book_id from book_authors) ba`, sql`ba.book_id = ${books.id}`) + .where(filter); + + return row; + } + + async genreDistribution(userId: number, isSuperuser: boolean, filterLibraryIds?: number[]) { + const accessible = await this.getAccessibleLibraryIds(userId, isSuperuser); + const filter = this.libraryFilter(this.intersectLibraryIds(accessible, filterLibraryIds)); + + const items = await this.db + .select({ + genre: genres.name, + count: sql`count(distinct ${bookGenres.bookId})::int`, + }) + .from(bookGenres) + .innerJoin(genres, eq(genres.id, bookGenres.genreId)) + .innerJoin(books, eq(books.id, bookGenres.bookId)) + .where(filter) + .groupBy(genres.name) + .orderBy(desc(sql`count(distinct ${bookGenres.bookId})`)); + + const [{ unknownCount }] = await this.db + .select({ unknownCount: sql`count(distinct ${books.id})::int` }) + .from(books) + .leftJoin(bookGenres, eq(bookGenres.bookId, books.id)) + .where(and(isNull(bookGenres.genreId), filter)); + + return { items, unknownCount }; + } + + async getSummary(userId: number, isSuperuser: boolean, filterLibraryIds?: number[]) { + const accessible = await this.getAccessibleLibraryIds(userId, isSuperuser); + const filter = this.libraryFilter(this.intersectLibraryIds(accessible, filterLibraryIds)); + + const [[booksRow], [authorsRow], [seriesRow], [publishersRow], [storageRow], [genresRow], [languagesRow], [pubRangeRow], [thisYearRow]] = + await Promise.all([ + this.db + .select({ count: sql`count(distinct ${books.id})::int` }) + .from(books) + .where(filter), + this.db + .select({ count: sql`count(distinct ${bookAuthors.authorId})::int` }) + .from(bookAuthors) + .innerJoin(books, eq(books.id, bookAuthors.bookId)) + .where(filter), + this.db + .select({ count: sql`count(distinct ${bookMetadata.seriesName})::int` }) + .from(bookMetadata) + .innerJoin(books, eq(books.id, bookMetadata.bookId)) + .where(and(isNotNull(bookMetadata.seriesName), filter)), + this.db + .select({ count: sql`count(distinct ${bookMetadata.publisher})::int` }) + .from(bookMetadata) + .innerJoin(books, eq(books.id, bookMetadata.bookId)) + .where(and(isNotNull(bookMetadata.publisher), filter)), + this.db + .select({ total: sql`coalesce(sum(${bookFiles.sizeBytes}), 0)::bigint` }) + .from(bookFiles) + .innerJoin(books, eq(books.id, bookFiles.bookId)) + .where(filter), + this.db + .select({ count: sql`count(distinct ${genres.id})::int` }) + .from(genres) + .innerJoin(bookGenres, eq(bookGenres.genreId, genres.id)) + .innerJoin(books, eq(books.id, bookGenres.bookId)) + .where(filter), + this.db + .select({ count: sql`count(distinct ${bookMetadata.language})::int` }) + .from(bookMetadata) + .innerJoin(books, eq(books.id, bookMetadata.bookId)) + .where(and(isNotNull(bookMetadata.language), filter)), + this.db + .select({ + minYear: sql`min(${bookMetadata.publishedYear})`, + maxYear: sql`max(${bookMetadata.publishedYear})`, + }) + .from(bookMetadata) + .innerJoin(books, eq(books.id, bookMetadata.bookId)) + .where(and(isNotNull(bookMetadata.publishedYear), filter)), + this.db + .select({ count: sql`count(*)::int` }) + .from(books) + .where(and(gte(books.addedAt, sql`date_trunc('year', current_date)`), filter)), + ]); + + return { + totalBooks: booksRow.count, + totalAuthors: authorsRow.count, + totalSeries: seriesRow.count, + totalPublishers: publishersRow.count, + totalStorageBytes: storageRow.total, + totalGenres: genresRow.count, + totalLanguages: languagesRow.count, + publicationYearMin: pubRangeRow.minYear, + publicationYearMax: pubRangeRow.maxYear, + booksAddedThisYear: thisYearRow.count, + }; + } +} diff --git a/server/src/modules/statistics/statistics.service.ts b/server/src/modules/statistics/statistics.service.ts new file mode 100644 index 000000000..e414b0e38 --- /dev/null +++ b/server/src/modules/statistics/statistics.service.ts @@ -0,0 +1,105 @@ +import { Injectable } from '@nestjs/common'; + +import type { + BooksAddedDataPoint, + StatisticsSummary, + FormatDistributionItem, + GenreDistributionItem, + LanguageDistributionItem, + MetadataCompletenessItem, + PublicationDecadeItem, + StatisticsResult, + StorageByFormatItem, + TopAuthorItem, +} from '@projectx/types'; + +import type { RequestUser } from '../../common/types/request-user'; +import type { BooksOverTimeQueryDto } from './dto/books-over-time-query.dto'; +import type { StatisticsFilterQueryDto } from './dto/statistics-filter-query.dto'; +import { StatisticsRepository } from './statistics.repository'; + +const STATISTICS_TOP_N = 10; + +@Injectable() +export class StatisticsService { + constructor(private readonly repo: StatisticsRepository) {} + + async getFormatDistribution(user: RequestUser, query: StatisticsFilterQueryDto): Promise> { + const raw = await this.repo.formatDistribution(user.id, user.isSuperuser, query.libraryIds); + const all = raw.map((r) => ({ format: r.format!, count: r.count })); + return { items: this.clipToTopN(all, 'format'), unknownCount: 0 }; + } + + async getLanguageDistribution(user: RequestUser, query: StatisticsFilterQueryDto): Promise> { + const { items: raw, unknownCount } = await this.repo.languageDistribution(user.id, user.isSuperuser, query.libraryIds); + const all = raw.map((r) => ({ language: r.language!, count: r.count })); + return { items: this.clipToTopN(all, 'language'), unknownCount }; + } + + async getBooksAddedOverTime(user: RequestUser, query: BooksOverTimeQueryDto): Promise> { + const items = await this.repo.booksAddedOverTime(user.id, user.isSuperuser, query.libraryIds, query.granularity, query.range); + return { items, unknownCount: 0 }; + } + + async getStorageByFormat(user: RequestUser, query: StatisticsFilterQueryDto): Promise> { + const raw = await this.repo.storageByFormat(user.id, user.isSuperuser, query.libraryIds); + const all = raw.map((r) => ({ format: r.format!, sizeBytes: Number(r.sizeBytes) })); + return { items: this.clipStorageToTopN(all), unknownCount: 0 }; + } + + async getPublicationDecade(user: RequestUser, query: StatisticsFilterQueryDto): Promise> { + const { items, unknownCount } = await this.repo.publicationDecade(user.id, user.isSuperuser, query.libraryIds); + return { items, unknownCount }; + } + + async getTopAuthors(user: RequestUser, query: StatisticsFilterQueryDto): Promise> { + const raw = await this.repo.topAuthors(user.id, user.isSuperuser, query.libraryIds); + const items = raw.slice(0, 25).map((r) => ({ name: r.name, count: r.count })); + return { items, unknownCount: 0 }; + } + + async getMetadataCompleteness(user: RequestUser, query: StatisticsFilterQueryDto): Promise> { + const row = await this.repo.metadataCompleteness(user.id, user.isSuperuser, query.libraryIds); + const total = row.total ?? 0; + const fields: Array<{ field: string; presentCount: number }> = [ + { field: 'Cover', presentCount: row.hasCover ?? 0 }, + { field: 'Author', presentCount: row.hasAuthor ?? 0 }, + { field: 'Description', presentCount: row.hasDescription ?? 0 }, + { field: 'Publisher', presentCount: row.hasPublisher ?? 0 }, + { field: 'Year', presentCount: row.hasYear ?? 0 }, + { field: 'Language', presentCount: row.hasLanguage ?? 0 }, + { field: 'Page Count', presentCount: row.hasPageCount ?? 0 }, + { field: 'Rating', presentCount: row.hasRating ?? 0 }, + { field: 'Series', presentCount: row.hasSeries ?? 0 }, + { field: 'ISBN', presentCount: row.hasIsbn ?? 0 }, + ]; + const items = fields + .map((f) => ({ field: f.field, presentCount: f.presentCount, totalCount: total })) + .sort((a, b) => b.presentCount - a.presentCount); + return { items, unknownCount: 0 }; + } + + async getGenreDistribution(user: RequestUser, query: StatisticsFilterQueryDto): Promise> { + const { items: raw, unknownCount } = await this.repo.genreDistribution(user.id, user.isSuperuser, query.libraryIds); + const items = raw.slice(0, 25).map((r) => ({ genre: r.genre, count: r.count })); + return { items, unknownCount }; + } + + private clipToTopN(items: T[], labelKey: keyof T, n = STATISTICS_TOP_N): T[] { + if (items.length <= n) return items; + const top = items.slice(0, n); + const otherCount = items.slice(n).reduce((s, item) => s + item.count, 0); + return [...top, { [labelKey]: 'Other', count: otherCount } as unknown as T]; + } + + private clipStorageToTopN(items: StorageByFormatItem[]): StorageByFormatItem[] { + if (items.length <= STATISTICS_TOP_N) return items; + const top = items.slice(0, STATISTICS_TOP_N); + const otherBytes = items.slice(STATISTICS_TOP_N).reduce((s, item) => s + item.sizeBytes, 0); + return [...top, { format: 'Other', sizeBytes: otherBytes }]; + } + + async getSummary(user: RequestUser, query: StatisticsFilterQueryDto): Promise { + return this.repo.getSummary(user.id, user.isSuperuser, query.libraryIds); + } +}
{{ title }}
Failed to load data
No data yet
No data available for this chart
+ {{ unknownCount }} {{ unknownCount === 1 ? 'book has' : 'books have' }} no data for this field +
{{ kpi.value }}
{{ kpi.label }}