feat(stats): introduce statistics with charts, KPI summary, and filter

This commit is contained in:
neon
2026-03-17 23:38:29 -06:00
parent 3c27802be2
commit e61486fae9
40 changed files with 2168 additions and 12 deletions
+3 -1
View File
@@ -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"
+22 -1
View File
@@ -1,12 +1,33 @@
<script setup lang="ts">
import { computed, onMounted, provide, ref, watch } from 'vue'
import { INIT_OPTIONS_KEY, THEME_KEY } from 'vue-echarts'
import { useThemeStore } from '@/stores/theme'
import { useChangePasswordDialog } from '@/composables/useChangePasswordDialog'
import { initChartThemes } from '@/lib/echarts'
import ChangePasswordDialog from '@/features/auth/ChangePasswordDialog.vue'
import { Toaster } from '@/components/ui/sonner'
import { TooltipProvider } from '@/components/ui/tooltip'
useThemeStore()
const themeStore = useThemeStore()
const { isOpen } = useChangePasswordDialog()
const themeVersion = ref(0)
provide(INIT_OPTIONS_KEY, { renderer: 'svg' })
provide(
THEME_KEY,
computed(() => `projectx-v${themeVersion.value}`),
)
onMounted(() => {
initChartThemes(0)
})
watch([() => themeStore.theme, () => themeStore.accent], () => {
const v = themeVersion.value + 1
initChartThemes(v)
themeVersion.value = v
})
</script>
<template>
+37 -10
View File
@@ -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));
+8
View File
@@ -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' })"
/>
<SidebarNavItem
:is-active="isStatisticsActive"
tooltip="Statistics"
:icon="Icons.BarChart3"
label="Statistics"
@click="router.push({ name: 'statistics' })"
/>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
@@ -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<T>(res: Response): Promise<StatisticsResult<T>> {
if (!res.ok) throw new Error(`Statistics request failed: ${res.status}`)
return res.json() as Promise<StatisticsResult<T>>
}
function buildParams(filters: StatisticsFilterConfig, extra?: Record<string, string>): 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<StatisticsResult<FormatDistributionItem>> {
return parseResult(await api(`/api/v1/statistics/format-distribution${buildParams(filters)}`))
}
export async function fetchLanguageDistribution(filters: StatisticsFilterConfig): Promise<StatisticsResult<LanguageDistributionItem>> {
return parseResult(await api(`/api/v1/statistics/language-distribution${buildParams(filters)}`))
}
export async function fetchBooksAddedOverTime(filters: StatisticsFilterConfig): Promise<StatisticsResult<BooksAddedDataPoint>> {
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<StatisticsResult<StorageByFormatItem>> {
return parseResult(await api(`/api/v1/statistics/storage-by-format${buildParams(filters)}`))
}
export async function fetchPublicationDecade(filters: StatisticsFilterConfig): Promise<StatisticsResult<PublicationDecadeItem>> {
return parseResult(await api(`/api/v1/statistics/publication-decade${buildParams(filters)}`))
}
export async function fetchTopAuthors(filters: StatisticsFilterConfig): Promise<StatisticsResult<TopAuthorItem>> {
return parseResult(await api(`/api/v1/statistics/top-authors${buildParams(filters)}`))
}
export async function fetchMetadataCompleteness(filters: StatisticsFilterConfig): Promise<StatisticsResult<MetadataCompletenessItem>> {
return parseResult(await api(`/api/v1/statistics/metadata-completeness${buildParams(filters)}`))
}
export async function fetchGenreDistribution(filters: StatisticsFilterConfig): Promise<StatisticsResult<GenreDistributionItem>> {
return parseResult(await api(`/api/v1/statistics/genre-distribution${buildParams(filters)}`))
}
export async function fetchStatisticsSummary(filters: StatisticsFilterConfig): Promise<StatisticsSummary> {
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<StatisticsSummary>
}
@@ -0,0 +1,133 @@
<script setup lang="ts">
import { shallowRef, watchEffect } from 'vue'
import VChart from 'vue-echarts'
import { TrendingUp } from 'lucide-vue-next'
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
import { useBooksAddedOverTime } from '../composables/useBooksAddedOverTime'
import { useStatisticsConfig } from '../composables/useStatisticsConfig'
import ChartCard from './ChartCard.vue'
const MONTH_NAMES = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
const DATE_RANGE_LABELS = {
'all-time': 'All Time',
'last-5-years': 'Last 5 Years',
'last-year': 'Last Year',
} as const
const { data, loading, error } = useBooksAddedOverTime()
const { filters, setGranularity, setDateRange } = useStatisticsConfig()
const option = shallowRef({})
watchEffect(() => {
if (!data.value.items.length) return
const isYearly = filters.value.booksOverTimeGranularity === 'yearly'
const labels = data.value.items.map((d) => (isYearly ? String(d.year) : `${MONTH_NAMES[d.month - 1]} ${d.year}`))
option.value = {
tooltip: {
trigger: 'axis',
confine: true,
enterable: false,
formatter: (params: { name: string; value: number }[]) => {
const p = params[0]
if (!p) return ''
return `${p.name}: <strong>${p.value}</strong> books`
},
},
grid: { left: '3%', right: '4%', bottom: '12%', top: '8%', containLabel: true },
xAxis: {
type: 'category',
data: labels,
axisTick: { show: false },
axisLabel: {
fontSize: 11,
rotate: 45,
interval: Math.max(0, Math.floor(data.value.items.length / 8) - 1),
},
},
yAxis: {
type: 'value',
minInterval: 1,
axisLabel: { fontSize: 11 },
},
series: [
{
type: 'bar',
data: data.value.items.map((d) => d.count),
cursor: 'default',
itemStyle: { borderRadius: [3, 3, 0, 0] },
barMaxWidth: 40,
emphasis: { disabled: true },
},
],
}
})
function handleMonthly() {
setGranularity('monthly')
}
function handleYearly() {
setGranularity('yearly')
}
function handleAllTime() {
setDateRange('all-time')
}
function handleLast5Years() {
setDateRange('last-5-years')
}
function handleLastYear() {
setDateRange('last-year')
}
</script>
<template>
<ChartCard title="Books Added Over Time" :icon="TrendingUp" :color-index="3" :loading :error :empty="!data.items.length">
<template #controls>
<div class="flex items-center gap-1">
<div class="border-border flex rounded-md border text-xs">
<button
:class="[
'rounded-l-md px-2 py-1 transition-colors',
filters.booksOverTimeGranularity === 'monthly' ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:text-foreground',
]"
@click="handleMonthly"
>
Monthly
</button>
<button
:class="[
'rounded-r-md px-2 py-1 transition-colors',
filters.booksOverTimeGranularity === 'yearly' ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:text-foreground',
]"
@click="handleYearly"
>
Yearly
</button>
</div>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<button class="border-border text-muted-foreground hover:text-foreground rounded-md border px-2 py-1 text-xs transition-colors">
{{ DATE_RANGE_LABELS[filters.booksOverTimeRange] }}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" class="text-xs">
<DropdownMenuItem @click="handleAllTime">All Time</DropdownMenuItem>
<DropdownMenuItem @click="handleLast5Years">Last 5 Years</DropdownMenuItem>
<DropdownMenuItem @click="handleLastYear">Last Year</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</template>
<VChart :option autoresize style="height: 100%" />
</ChartCard>
</template>
@@ -0,0 +1,72 @@
<script setup lang="ts">
import type { Component } from 'vue'
import { AlertCircle, GripVertical } from 'lucide-vue-next'
import { Skeleton } from '@/components/ui/skeleton'
defineProps<{
title: string
icon: Component
colorIndex: number
loading: boolean
empty: boolean
unknownCount?: number
error?: boolean
tall?: boolean
short?: boolean
}>()
</script>
<template>
<div
:class="[
'bg-card text-card-foreground flex flex-col overflow-hidden rounded-xl border shadow-sm transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md',
tall ? 'h-[560px] md:h-[640px]' : short ? 'h-[240px] md:h-[280px]' : 'h-[320px] md:h-[360px]',
]"
>
<div class="flex min-h-0 flex-1 flex-col p-4">
<div class="mb-3 flex items-center justify-between gap-2 border-b pb-3">
<div class="flex items-center gap-2.5">
<div
class="shrink-0 rounded-md p-2"
:style="{
backgroundColor: `color-mix(in oklch, var(--chart-icon-${colorIndex}) 15%, transparent)`,
color: `var(--chart-icon-${colorIndex})`,
}"
>
<component :is="icon" class="size-4" />
</div>
<p class="text-foreground text-sm font-semibold">{{ title }}</p>
</div>
<div class="flex items-center gap-2">
<slot name="controls" />
<GripVertical class="drag-handle text-muted-foreground/50 hover:text-muted-foreground size-4 cursor-grab active:cursor-grabbing" />
</div>
</div>
<div class="min-h-0 flex-1">
<div v-if="loading" class="flex h-full flex-col gap-2">
<Skeleton class="h-full w-full rounded-lg" />
</div>
<div v-else-if="error" class="text-muted-foreground flex h-full flex-col items-center justify-center gap-2">
<AlertCircle class="size-6" />
<p class="text-sm">Failed to load data</p>
</div>
<div v-else-if="empty" class="text-muted-foreground flex h-full flex-col items-center justify-center gap-3">
<component :is="icon" class="size-9 opacity-20" />
<div class="flex flex-col items-center gap-1">
<p class="text-sm font-medium">No data yet</p>
<p class="text-xs opacity-60">No data available for this chart</p>
</div>
</div>
<slot v-else />
</div>
<p v-if="!loading && !error && unknownCount && unknownCount > 0" class="text-muted-foreground mt-2 text-xs">
{{ unknownCount }} {{ unknownCount === 1 ? 'book has' : 'books have' }} no data for this field
</p>
</div>
</div>
</template>
@@ -0,0 +1,52 @@
<script setup lang="ts">
import { shallowRef, watchEffect } from 'vue'
import VChart from 'vue-echarts'
import { PieChart } from 'lucide-vue-next'
import { breakpointsTailwind, useBreakpoints } from '@vueuse/core'
import { useFormatDistribution } from '../composables/useFormatDistribution'
import ChartCard from './ChartCard.vue'
const { data, loading, error } = useFormatDistribution()
const { md } = useBreakpoints(breakpointsTailwind)
const option = shallowRef({})
watchEffect(() => {
if (!data.value.items.length) return
option.value = {
tooltip: { trigger: 'item', confine: true, enterable: false },
legend: {
orient: md.value ? 'vertical' : 'horizontal',
right: md.value ? '2%' : 'auto',
bottom: md.value ? 'auto' : 0,
top: md.value ? 'middle' : 'auto',
},
series: [
{
type: 'pie',
radius: ['40%', '70%'],
center: md.value ? ['38%', '50%'] : ['50%', '44%'],
data: data.value.items.map((item) => ({ name: item.format.toUpperCase(), value: item.count })),
cursor: 'default',
label: { show: false },
emphasis: { disabled: true },
},
],
}
})
</script>
<template>
<ChartCard
title="Format Distribution"
:icon="PieChart"
:color-index="1"
:loading
:error
:empty="!data.items.length"
:unknown-count="data.unknownCount"
>
<VChart :option autoresize style="height: 100%" />
</ChartCard>
</template>
@@ -0,0 +1,61 @@
<script setup lang="ts">
import { shallowRef, watchEffect } from 'vue'
import VChart from 'vue-echarts'
import { Tag } from 'lucide-vue-next'
import { useGenreDistribution } from '../composables/useGenreDistribution'
import ChartCard from './ChartCard.vue'
const { data, loading, error } = useGenreDistribution()
const option = shallowRef({})
watchEffect(() => {
if (!data.value.items.length) return
option.value = {
tooltip: {
trigger: 'axis',
confine: true,
enterable: false,
formatter: (params: { name: string; value: number }[]) => {
const p = params[0]
if (!p) return ''
return `${p.name}: <strong>${p.value}</strong> books`
},
},
grid: { left: '3%', right: '3%', bottom: '28%', top: '8%', containLabel: false },
xAxis: {
type: 'category',
data: data.value.items.map((d) => d.genre),
axisTick: { show: false },
axisLabel: {
fontSize: 11,
rotate: 45,
interval: 0,
overflow: 'truncate',
width: 80,
},
},
yAxis: {
type: 'value',
minInterval: 1,
axisLabel: { fontSize: 11 },
},
series: [
{
type: 'bar',
data: data.value.items.map((d) => d.count),
cursor: 'default',
itemStyle: { borderRadius: [3, 3, 0, 0] },
barMaxWidth: 32,
emphasis: { disabled: true },
},
],
}
})
</script>
<template>
<ChartCard title="Genre Distribution" :icon="Tag" :color-index="8" :loading :error :empty="!data.items.length" :unknown-count="data.unknownCount">
<VChart :option autoresize style="height: 100%" />
</ChartCard>
</template>
@@ -0,0 +1,52 @@
<script setup lang="ts">
import { shallowRef, watchEffect } from 'vue'
import VChart from 'vue-echarts'
import { Globe } from 'lucide-vue-next'
import { breakpointsTailwind, useBreakpoints } from '@vueuse/core'
import { useLanguageDistribution } from '../composables/useLanguageDistribution'
import ChartCard from './ChartCard.vue'
const { data, loading, error } = useLanguageDistribution()
const { md } = useBreakpoints(breakpointsTailwind)
const option = shallowRef({})
watchEffect(() => {
if (!data.value.items.length) return
option.value = {
tooltip: { trigger: 'item', confine: true, enterable: false },
legend: {
orient: md.value ? 'vertical' : 'horizontal',
right: md.value ? '2%' : 'auto',
bottom: md.value ? 'auto' : 0,
top: md.value ? 'middle' : 'auto',
},
series: [
{
type: 'pie',
radius: ['40%', '70%'],
center: md.value ? ['38%', '50%'] : ['50%', '44%'],
data: data.value.items.map((item) => ({ name: item.language.toUpperCase(), value: item.count })),
cursor: 'default',
label: { show: false },
emphasis: { disabled: true },
},
],
}
})
</script>
<template>
<ChartCard
title="Language Distribution"
:icon="Globe"
:color-index="2"
:loading
:error
:empty="!data.items.length"
:unknown-count="data.unknownCount"
>
<VChart :option autoresize style="height: 100%" />
</ChartCard>
</template>
@@ -0,0 +1,62 @@
<script setup lang="ts">
import { shallowRef, watchEffect } from 'vue'
import VChart from 'vue-echarts'
import { ListChecks } from 'lucide-vue-next'
import { useMetadataCompleteness } from '../composables/useMetadataCompleteness'
import ChartCard from './ChartCard.vue'
const { data, loading, error } = useMetadataCompleteness()
const option = shallowRef({})
watchEffect(() => {
if (!data.value.items.length) return
option.value = {
tooltip: {
trigger: 'axis',
confine: true,
enterable: false,
formatter: (params: { name: string; value: number }[]) => {
const p = params[0]
if (!p) return ''
const item = data.value.items.find((i) => i.field === p.name)
if (!item) return ''
return `${p.name}: <strong>${p.value}%</strong> (${item.presentCount} / ${item.totalCount})`
},
},
grid: { left: '3%', right: '3%', bottom: '24%', top: '8%', containLabel: false },
xAxis: {
type: 'category',
data: [...data.value.items].reverse().map((d) => d.field),
axisTick: { show: false },
axisLabel: {
fontSize: 11,
rotate: 45,
interval: 0,
},
},
yAxis: {
type: 'value',
min: 0,
max: 100,
axisLabel: { fontSize: 11, formatter: '{value}%' },
},
series: [
{
type: 'bar',
data: [...data.value.items].reverse().map((d) => (d.totalCount > 0 ? Math.round((d.presentCount / d.totalCount) * 100) : 0)),
cursor: 'default',
itemStyle: { borderRadius: [3, 3, 0, 0] },
barMaxWidth: 40,
emphasis: { disabled: true },
},
],
}
})
</script>
<template>
<ChartCard title="Metadata Completeness" :icon="ListChecks" :color-index="7" :loading :error :empty="!data.items.length">
<VChart :option autoresize style="height: 100%" />
</ChartCard>
</template>
@@ -0,0 +1,63 @@
<script setup lang="ts">
import { shallowRef, watchEffect } from 'vue'
import VChart from 'vue-echarts'
import { CalendarDays } from 'lucide-vue-next'
import { usePublicationDecade } from '../composables/usePublicationDecade'
import ChartCard from './ChartCard.vue'
const { data, loading, error } = usePublicationDecade()
const option = shallowRef({})
watchEffect(() => {
if (!data.value.items.length) return
option.value = {
tooltip: {
trigger: 'axis',
confine: true,
enterable: false,
formatter: (params: { name: string; value: number }[]) => {
const p = params[0]
if (!p) return ''
return `${p.name}: <strong>${p.value}</strong> books`
},
},
grid: { left: '3%', right: '4%', bottom: '12%', top: '8%', containLabel: true },
xAxis: {
type: 'category',
data: data.value.items.map((d) => `${d.decade}s`),
axisTick: { show: false },
axisLabel: { fontSize: 11 },
},
yAxis: {
type: 'value',
minInterval: 1,
axisLabel: { fontSize: 11 },
},
series: [
{
type: 'bar',
data: data.value.items.map((d) => d.count),
cursor: 'default',
itemStyle: { borderRadius: [3, 3, 0, 0] },
barMaxWidth: 48,
emphasis: { disabled: true },
},
],
}
})
</script>
<template>
<ChartCard
title="Publication Decade"
:icon="CalendarDays"
:color-index="5"
:loading
:error
:empty="!data.items.length"
:unknown-count="data.unknownCount"
>
<VChart :option autoresize style="height: 100%" />
</ChartCard>
</template>
@@ -0,0 +1,83 @@
<script setup lang="ts">
import { type Component, defineAsyncComponent } from 'vue'
import { VueDraggable } from 'vue-draggable-plus'
import type { ChartConfigEntry, StatisticsChartId } from '@projectx/types'
import { useStatisticsConfig } from '../composables/useStatisticsConfig'
interface ChartRegistryEntry {
component: Component
label: string
wide: boolean
fullWidth?: boolean
}
const CHART_REGISTRY: Record<StatisticsChartId, ChartRegistryEntry> = {
'format-distribution': {
component: defineAsyncComponent(() => import('./FormatDistributionChart.vue')),
label: 'Format Distribution',
wide: false,
},
'language-distribution': {
component: defineAsyncComponent(() => import('./LanguageDistributionChart.vue')),
label: 'Language Distribution',
wide: false,
},
'books-added-over-time': {
component: defineAsyncComponent(() => import('./BooksAddedOverTimeChart.vue')),
label: 'Books Added Over Time',
wide: true,
},
'storage-by-format': {
component: defineAsyncComponent(() => import('./StorageByFormatChart.vue')),
label: 'Storage by Format',
wide: false,
},
'publication-decade': {
component: defineAsyncComponent(() => import('./PublicationDecadeChart.vue')),
label: 'Publication Decade',
wide: false,
},
'top-authors': {
component: defineAsyncComponent(() => import('./TopAuthorsChart.vue')),
label: 'Top Authors',
wide: true,
},
'metadata-completeness': {
component: defineAsyncComponent(() => import('./MetadataCompletenessChart.vue')),
label: 'Metadata Completeness',
wide: false,
},
'genre-distribution': {
component: defineAsyncComponent(() => import('./GenreDistributionChart.vue')),
label: 'Genre Distribution',
wide: true,
},
}
const { visibleCharts, reorder } = useStatisticsConfig()
function handleReorder(newList: ChartConfigEntry[]) {
reorder(newList)
}
</script>
<template>
<VueDraggable
:model-value="visibleCharts"
class="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4"
handle=".drag-handle"
:animation="200"
@update:model-value="handleReorder"
>
<div
v-for="chart in visibleCharts"
:key="chart.id"
:class="
CHART_REGISTRY[chart.id].fullWidth ? 'md:col-span-2 xl:col-span-3 2xl:col-span-4' : CHART_REGISTRY[chart.id].wide ? 'md:col-span-2' : ''
"
>
<component :is="CHART_REGISTRY[chart.id].component" />
</div>
</VueDraggable>
</template>
@@ -0,0 +1,220 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import {
CalendarDays,
Check,
ChevronDown,
Globe,
GripVertical,
HardDrive,
ListChecks,
PieChart,
Settings2,
Tag,
TrendingUp,
Users,
} from 'lucide-vue-next'
import { VueDraggable } from 'vue-draggable-plus'
import type { ChartConfigEntry, StatisticsChartId } from '@projectx/types'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle } from '@/components/ui/sheet'
import ToggleSwitch from '@/components/ui/ToggleSwitch.vue'
import { useLibraries } from '@/features/library/composables/useLibraries'
import { useStatisticsConfig } from '../composables/useStatisticsConfig'
import StatisticsGrid from './StatisticsGrid.vue'
import StatisticsSummaryCard from './StatisticsSummaryCard.vue'
const CHART_LABELS: Record<StatisticsChartId, string> = {
'format-distribution': 'Format Distribution',
'language-distribution': 'Language Distribution',
'books-added-over-time': 'Books Added Over Time',
'storage-by-format': 'Storage by Format',
'publication-decade': 'Publication Decade',
'top-authors': 'Top 25 Authors',
'metadata-completeness': 'Metadata Completeness',
'genre-distribution': 'Genre Distribution',
}
const CHART_ICONS: Record<StatisticsChartId, unknown> = {
'format-distribution': PieChart,
'language-distribution': Globe,
'books-added-over-time': TrendingUp,
'storage-by-format': HardDrive,
'publication-decade': CalendarDays,
'top-authors': Users,
'metadata-completeness': ListChecks,
'genre-distribution': Tag,
}
const { orderedCharts, filters, init, toggleVisibility, reorder, resetToDefaults, setLibraryFilter } = useStatisticsConfig()
const { libraries, fetchLibraries } = useLibraries()
const configOpen = ref(false)
// Called in setup (not onMounted) so filters are populated before chart children mount.
init()
onMounted(fetchLibraries)
const visibleCount = computed(() => orderedCharts.value.filter((c) => c.visible).length)
const totalCount = computed(() => orderedCharts.value.length)
const libraryLabel = computed(() => {
const count = filters.value.libraryIds.length
if (count === 0 || count === libraries.value.length) return 'All Libraries'
return `${count} of ${libraries.value.length} Libraries`
})
const isFiltered = computed(() => filters.value.libraryIds.length > 0 && filters.value.libraryIds.length < libraries.value.length)
function isLibrarySelected(id: number) {
return filters.value.libraryIds.length === 0 || filters.value.libraryIds.includes(id)
}
function handleToggleLibrary(id: number) {
const current = filters.value.libraryIds
if (current.length === 0) {
setLibraryFilter(libraries.value.map((l) => l.id).filter((lid) => lid !== id))
} else if (current.includes(id)) {
const next = current.filter((lid) => lid !== id)
setLibraryFilter(next.length === 0 ? [] : next)
} else {
const next = [...current, id]
setLibraryFilter(next.length === libraries.value.length ? [] : next)
}
}
function handleSelectAll() {
setLibraryFilter([])
}
function handleToggleChart(id: StatisticsChartId) {
toggleVisibility(id)
}
function handleReorder(newList: ChartConfigEntry[]) {
reorder(newList)
}
function handleReset() {
resetToDefaults()
}
function openConfig() {
configOpen.value = true
}
</script>
<template>
<div class="flex flex-col gap-6 pt-4">
<div class="flex items-center justify-between">
<div>
<h1 class="text-foreground text-[22px] font-semibold">Library Statistics</h1>
</div>
<div class="flex items-center gap-2">
<Popover v-if="libraries.length > 1">
<PopoverTrigger as-child>
<button
:class="[
'flex h-8 items-center gap-1.5 rounded-md border px-3 text-sm transition-colors',
isFiltered ? 'border-primary/40 bg-primary/10 text-primary hover:bg-primary/15' : 'border-border text-foreground hover:bg-accent',
]"
>
{{ libraryLabel }}
<ChevronDown class="size-3.5 opacity-60" />
</button>
</PopoverTrigger>
<PopoverContent align="end" class="w-56 p-1.5">
<button
class="text-muted-foreground hover:bg-accent hover:text-foreground flex w-full items-center gap-2 rounded px-2 py-1.5 text-sm"
@click="handleSelectAll"
>
<div :class="['flex size-4 items-center justify-center rounded border', !isFiltered ? 'border-primary bg-primary' : 'border-border']">
<Check v-if="!isFiltered" class="text-primary-foreground size-2.5" />
</div>
All Libraries
</button>
<div class="border-border my-1 border-t" />
<button
v-for="lib in libraries"
:key="lib.id"
class="hover:bg-accent flex w-full items-center gap-2 rounded px-2 py-1.5 text-sm"
@click="handleToggleLibrary(lib.id)"
>
<div
:class="[
'flex size-4 shrink-0 items-center justify-center rounded border',
isLibrarySelected(lib.id) ? 'border-primary bg-primary' : 'border-border',
]"
>
<Check v-if="isLibrarySelected(lib.id)" class="text-primary-foreground size-2.5" />
</div>
<span class="truncate">{{ lib.name }}</span>
</button>
</PopoverContent>
</Popover>
<button
class="bg-muted text-foreground hover:bg-muted/70 flex h-8 items-center gap-1.5 rounded-md px-2.5 text-sm font-medium transition-colors"
@click="openConfig"
>
<Settings2 class="size-3.5" />
Configure
</button>
</div>
</div>
<StatisticsSummaryCard />
<Sheet v-model:open="configOpen">
<SheetContent side="right" class="w-[420px] sm:max-w-[420px]">
<SheetHeader>
<div class="flex items-center justify-between pr-8">
<SheetTitle>Configure Charts</SheetTitle>
<span class="bg-muted text-muted-foreground rounded-full px-2 py-0.5 text-xs font-medium tabular-nums">
{{ visibleCount }} / {{ totalCount }}
</span>
</div>
<SheetDescription>Drag to reorder, toggle to show or hide.</SheetDescription>
</SheetHeader>
<div class="flex-1 overflow-y-auto px-4">
<VueDraggable
:model-value="orderedCharts"
class="flex flex-col gap-2"
handle=".drawer-drag-handle"
:animation="150"
@update:model-value="handleReorder"
>
<div
v-for="chart in orderedCharts"
:key="chart.id"
:class="[
'border-border/50 bg-muted/40 flex items-center gap-3 rounded-lg border px-3 py-2.5 transition-opacity',
!chart.visible && 'opacity-50',
]"
>
<GripVertical class="text-muted-foreground/60 drawer-drag-handle size-4 shrink-0 cursor-grab active:cursor-grabbing" />
<component :is="CHART_ICONS[chart.id]" :class="['size-4 shrink-0', chart.visible ? 'text-primary' : 'text-muted-foreground']" />
<span :class="['flex-1 text-sm', chart.visible ? 'text-foreground' : 'text-muted-foreground']">
{{ CHART_LABELS[chart.id] }}
</span>
<ToggleSwitch :model-value="chart.visible" @update:model-value="handleToggleChart(chart.id)" />
</div>
</VueDraggable>
</div>
<SheetFooter>
<button
class="text-muted-foreground hover:text-foreground w-full rounded-md py-2 text-sm transition-colors hover:bg-transparent"
@click="handleReset"
>
Reset to defaults
</button>
</SheetFooter>
</SheetContent>
</Sheet>
<StatisticsGrid />
</div>
</template>
@@ -0,0 +1,69 @@
<script setup lang="ts">
import { computed } from 'vue'
import { BarChart3, BookOpen, Building2, CalendarPlus, CalendarRange, Globe, Layers, Tags, Users } from 'lucide-vue-next'
import { useStatisticsSummary } from '../composables/useStatisticsSummary'
const { data, loading } = useStatisticsSummary()
function formatBytes(bytes: number): string {
if (bytes >= 1_073_741_824) return `${(bytes / 1_073_741_824).toFixed(1)} GB`
if (bytes >= 1_048_576) return `${(bytes / 1_048_576).toFixed(1)} MB`
return `${(bytes / 1024).toFixed(1)} KB`
}
function formatNumber(n: number): string {
return n.toLocaleString()
}
const publicationRange = computed(() => {
if (!data.value) return '-'
const { publicationYearMin: min, publicationYearMax: max } = data.value
if (!min && !max) return '-'
if (min === max) return String(min)
return `${min} - ${max}`
})
const kpis = computed(() => [
{ icon: BookOpen, label: 'Books', value: data.value ? formatNumber(data.value.totalBooks) : '-', colorIndex: 1 },
{ icon: Users, label: 'Authors', value: data.value ? formatNumber(data.value.totalAuthors) : '-', colorIndex: 2 },
{ icon: Layers, label: 'Series', value: data.value ? formatNumber(data.value.totalSeries) : '-', colorIndex: 3 },
{ icon: Building2, label: 'Publishers', value: data.value ? formatNumber(data.value.totalPublishers) : '-', colorIndex: 4 },
{ icon: BarChart3, label: 'Storage', value: data.value ? formatBytes(data.value.totalStorageBytes) : '-', colorIndex: 5 },
{ icon: Tags, label: 'Genres', value: data.value ? formatNumber(data.value.totalGenres) : '-', colorIndex: 6 },
{ icon: Globe, label: 'Languages', value: data.value ? formatNumber(data.value.totalLanguages) : '-', colorIndex: 7 },
{ icon: CalendarRange, label: 'Published', value: publicationRange.value, colorIndex: 8 },
{ icon: CalendarPlus, label: 'This Year', value: data.value ? formatNumber(data.value.booksAddedThisYear) : '-', colorIndex: 9 },
])
</script>
<template>
<div class="relative overflow-hidden rounded-xl border bg-card">
<div class="pointer-events-none absolute inset-0 bg-gradient-to-br from-primary/8 to-transparent" />
<BarChart3 class="pointer-events-none absolute -right-4 -top-2 opacity-[0.04]" :size="160" aria-hidden="true" />
<div class="relative p-4">
<div class="flex gap-3 overflow-x-auto pb-0.5 [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
<div
v-for="kpi in kpis"
:key="kpi.label"
:class="['border-border/60 bg-background/50 flex shrink-0 items-center gap-3 rounded-lg border px-4 py-2.5', loading ? 'opacity-60' : '']"
>
<div
class="shrink-0 rounded-md p-1.5"
:style="{
backgroundColor: `color-mix(in oklch, var(--chart-icon-${kpi.colorIndex}) 15%, transparent)`,
color: `var(--chart-icon-${kpi.colorIndex})`,
}"
>
<component :is="kpi.icon" class="size-4" />
</div>
<div>
<p class="text-foreground text-base font-semibold leading-tight tabular-nums">{{ kpi.value }}</p>
<p class="text-muted-foreground text-xs">{{ kpi.label }}</p>
</div>
</div>
</div>
</div>
</div>
</template>
@@ -0,0 +1,63 @@
<script setup lang="ts">
import { shallowRef, watchEffect } from 'vue'
import VChart from 'vue-echarts'
import { HardDrive } from 'lucide-vue-next'
import { breakpointsTailwind, useBreakpoints } from '@vueuse/core'
import { useStorageByFormat } from '../composables/useStorageByFormat'
import ChartCard from './ChartCard.vue'
const { data, loading, error } = useStorageByFormat()
const { md } = useBreakpoints(breakpointsTailwind)
const option = shallowRef({})
function formatBytes(bytes: number): string {
if (bytes >= 1_073_741_824) return `${(bytes / 1_073_741_824).toFixed(1)} GB`
if (bytes >= 1_048_576) return `${(bytes / 1_048_576).toFixed(1)} MB`
return `${(bytes / 1024).toFixed(1)} KB`
}
watchEffect(() => {
if (!data.value.items.length) return
option.value = {
tooltip: {
trigger: 'item',
confine: true,
enterable: false,
formatter: (params: { name: string; value: number; percent: number }) => `${params.name}: ${formatBytes(params.value)} (${params.percent}%)`,
},
legend: {
orient: md.value ? 'vertical' : 'horizontal',
right: md.value ? '2%' : 'auto',
bottom: md.value ? 'auto' : 0,
top: md.value ? 'middle' : 'auto',
},
series: [
{
type: 'pie',
radius: ['40%', '70%'],
center: md.value ? ['38%', '50%'] : ['50%', '44%'],
data: data.value.items.map((item) => ({ name: item.format.toUpperCase(), value: item.sizeBytes })),
cursor: 'default',
label: { show: false },
emphasis: { disabled: true },
},
],
}
})
</script>
<template>
<ChartCard
title="Storage by Format"
:icon="HardDrive"
:color-index="4"
:loading
:error
:empty="!data.items.length"
:unknown-count="data.unknownCount"
>
<VChart :option autoresize style="height: 100%" />
</ChartCard>
</template>
@@ -0,0 +1,61 @@
<script setup lang="ts">
import { shallowRef, watchEffect } from 'vue'
import VChart from 'vue-echarts'
import { Users } from 'lucide-vue-next'
import { useTopAuthors } from '../composables/useTopAuthors'
import ChartCard from './ChartCard.vue'
const { data, loading, error } = useTopAuthors()
const option = shallowRef({})
watchEffect(() => {
if (!data.value.items.length) return
option.value = {
tooltip: {
trigger: 'axis',
confine: true,
enterable: false,
formatter: (params: { name: string; value: number }[]) => {
const p = params[0]
if (!p) return ''
return `${p.name}: <strong>${p.value}</strong> books`
},
},
grid: { left: '3%', right: '3%', bottom: '28%', top: '8%', containLabel: false },
xAxis: {
type: 'category',
data: data.value.items.map((d) => d.name),
axisTick: { show: false },
axisLabel: {
fontSize: 11,
rotate: 45,
interval: 0,
overflow: 'truncate',
width: 80,
},
},
yAxis: {
type: 'value',
minInterval: 1,
axisLabel: { fontSize: 11 },
},
series: [
{
type: 'bar',
data: data.value.items.map((d) => d.count),
cursor: 'default',
itemStyle: { borderRadius: [3, 3, 0, 0] },
barMaxWidth: 32,
emphasis: { disabled: true },
},
],
}
})
</script>
<template>
<ChartCard title="Top 25 Authors" :icon="Users" :color-index="6" :loading :error :empty="!data.items.length">
<VChart :option autoresize style="height: 100%" />
</ChartCard>
</template>
@@ -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<StatisticsResult<BooksAddedDataPoint>>({ 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 }
}
@@ -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<StatisticsResult<FormatDistributionItem>>({ 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 }
}
@@ -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<StatisticsResult<GenreDistributionItem>>({ 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 }
}
@@ -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<StatisticsResult<LanguageDistributionItem>>({ 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 }
}
@@ -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<StatisticsResult<MetadataCompletenessItem>>({ 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 }
}
@@ -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<StatisticsResult<PublicationDecadeItem>>({ 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 }
}
@@ -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<ChartConfigEntry[]>([])
const filters = ref<StatisticsFilterConfig>({ ...DEFAULT_FILTERS })
function normalizeCharts(saved: ChartConfigEntry[] | undefined): ChartConfigEntry[] {
const knownSet = new Set<StatisticsChartId>(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<typeof setTimeout> | 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 }
}
@@ -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<StatisticsSummary | null>(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 }
}
@@ -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<StatisticsResult<StorageByFormatItem>>({ 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 }
}
@@ -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<StatisticsResult<TopAuthorItem>>({ 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 }
}
+59
View File
@@ -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())
}
+1
View File
@@ -1,4 +1,5 @@
import './assets/main.css'
import './lib/echarts'
import { createApp } from 'vue'
import { createPinia } from 'pinia'
+5
View File
@@ -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',
+1
View File
@@ -1,5 +1,6 @@
export interface UserSettings {
syncReaderPreferences?: boolean;
statisticsConfig?: import('./statistics').StatisticsSettings;
}
export interface AuthUser {
+1
View File
@@ -20,3 +20,4 @@ export * from "./epub";
export * from "./author";
export * from "./metadata-score";
export * from "./book-metadata-fetch";
export * from "./statistics";
+94
View File
@@ -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<T> {
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;
}
+2
View File
@@ -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 },
@@ -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;
}
@@ -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[];
}
@@ -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);
}
}
@@ -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 {}
@@ -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<typeof schema>;
@Injectable()
export class StatisticsRepository {
constructor(@Inject(DB) private readonly db: Db) {}
private async getAccessibleLibraryIds(userId: number, isSuperuser: boolean): Promise<number[] | null> {
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<number>`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<number>`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<number>`count(*)::int`,
})
.from(books)
.innerJoin(bookMetadata, eq(bookMetadata.bookId, books.id))
.where(and(isNotNull(bookMetadata.language), filter))
.groupBy(bookMetadata.language)
.orderBy(desc(sql<number>`count(*)`));
const [{ unknownCount }] = await this.db
.select({ unknownCount: sql<number>`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<number>`extract(year from ${books.addedAt})::int`,
month: sql<number>`0`,
count: sql<number>`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<number>`extract(year from ${books.addedAt})::int`,
month: sql<number>`extract(month from ${books.addedAt})::int`,
count: sql<number>`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<number>`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<number>`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<number>`(floor(${bookMetadata.publishedYear} / 10) * 10)::int`,
count: sql<number>`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<number>`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<number>`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<number>`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<number>`count(*)::int`,
hasTitle: sql<number>`count(${bookMetadata.title})::int`,
hasCover: sql<number>`count(${bookMetadata.coverSource})::int`,
hasDescription: sql<number>`count(${bookMetadata.description})::int`,
hasPublisher: sql<number>`count(${bookMetadata.publisher})::int`,
hasYear: sql<number>`count(${bookMetadata.publishedYear})::int`,
hasLanguage: sql<number>`count(${bookMetadata.language})::int`,
hasPageCount: sql<number>`count(${bookMetadata.pageCount})::int`,
hasRating: sql<number>`count(${bookMetadata.rating})::int`,
hasSeries: sql<number>`count(${bookMetadata.seriesName})::int`,
hasIsbn: sql<number>`count(case when ${bookMetadata.isbn13} is not null or ${bookMetadata.isbn10} is not null then 1 end)::int`,
hasAuthor: sql<number>`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<number>`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<number>`count(distinct ${bookGenres.bookId})`));
const [{ unknownCount }] = await this.db
.select({ unknownCount: sql<number>`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<number>`count(distinct ${books.id})::int` })
.from(books)
.where(filter),
this.db
.select({ count: sql<number>`count(distinct ${bookAuthors.authorId})::int` })
.from(bookAuthors)
.innerJoin(books, eq(books.id, bookAuthors.bookId))
.where(filter),
this.db
.select({ count: sql<number>`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<number>`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<number>`coalesce(sum(${bookFiles.sizeBytes}), 0)::bigint` })
.from(bookFiles)
.innerJoin(books, eq(books.id, bookFiles.bookId))
.where(filter),
this.db
.select({ count: sql<number>`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<number>`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<number | null>`min(${bookMetadata.publishedYear})`,
maxYear: sql<number | null>`max(${bookMetadata.publishedYear})`,
})
.from(bookMetadata)
.innerJoin(books, eq(books.id, bookMetadata.bookId))
.where(and(isNotNull(bookMetadata.publishedYear), filter)),
this.db
.select({ count: sql<number>`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,
};
}
}
@@ -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<StatisticsResult<FormatDistributionItem>> {
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<StatisticsResult<LanguageDistributionItem>> {
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<StatisticsResult<BooksAddedDataPoint>> {
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<StatisticsResult<StorageByFormatItem>> {
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<StatisticsResult<PublicationDecadeItem>> {
const { items, unknownCount } = await this.repo.publicationDecade(user.id, user.isSuperuser, query.libraryIds);
return { items, unknownCount };
}
async getTopAuthors(user: RequestUser, query: StatisticsFilterQueryDto): Promise<StatisticsResult<TopAuthorItem>> {
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<StatisticsResult<MetadataCompletenessItem>> {
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<StatisticsResult<GenreDistributionItem>> {
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<T extends { count: number }>(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<StatisticsSummary> {
return this.repo.getSummary(user.id, user.isSuperuser, query.libraryIds);
}
}