mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
feat(stats): introduce statistics with charts, KPI summary, and filter
This commit is contained in:
+3
-1
@@ -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
@@ -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>
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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,4 +1,5 @@
|
||||
import './assets/main.css'
|
||||
import './lib/echarts'
|
||||
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
|
||||
@@ -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,5 +1,6 @@
|
||||
export interface UserSettings {
|
||||
syncReaderPreferences?: boolean;
|
||||
statisticsConfig?: import('./statistics').StatisticsSettings;
|
||||
}
|
||||
|
||||
export interface AuthUser {
|
||||
|
||||
@@ -20,3 +20,4 @@ export * from "./epub";
|
||||
export * from "./author";
|
||||
export * from "./metadata-score";
|
||||
export * from "./book-metadata-fetch";
|
||||
export * from "./statistics";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user