mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
feat(stats): add advanced analytics charts and defaults
This commit is contained in:
+28
-1
@@ -1,10 +1,19 @@
|
||||
version: 2
|
||||
updates:
|
||||
# Monorepo – pnpm (Root, Server, Client, Packages)
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
versioning-strategy: "auto"
|
||||
day: "friday"
|
||||
time: "03:00"
|
||||
timezone: "UTC"
|
||||
open-pull-requests-limit: 5
|
||||
labels:
|
||||
- "dependencies"
|
||||
- "npm"
|
||||
commit-message:
|
||||
prefix: "chore(deps)"
|
||||
groups:
|
||||
production-dependencies:
|
||||
dependency-type: "production"
|
||||
@@ -13,15 +22,33 @@ updates:
|
||||
dependency-type: "development"
|
||||
update-types: ["minor", "patch"]
|
||||
|
||||
# Docker – Root (Dev & Production Base Images)
|
||||
- package-ecosystem: "docker"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
day: "friday"
|
||||
time: "03:00"
|
||||
timezone: "UTC"
|
||||
labels:
|
||||
- "dependencies"
|
||||
- "docker"
|
||||
commit-message:
|
||||
prefix: "chore(deps)"
|
||||
|
||||
# GitHub Actions
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
day: "friday"
|
||||
time: "03:00"
|
||||
timezone: "UTC"
|
||||
labels:
|
||||
- "dependencies"
|
||||
- "ci"
|
||||
commit-message:
|
||||
prefix: "chore(deps)"
|
||||
groups:
|
||||
github-actions:
|
||||
patterns: ["*"]
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
import { api } from '@/lib/api'
|
||||
import type {
|
||||
BooksAddedDataPoint,
|
||||
FormatShareOverTimeItem,
|
||||
StatisticsSummary,
|
||||
FormatDistributionItem,
|
||||
GenreRankOverTimeItem,
|
||||
GenreDistributionItem,
|
||||
LibraryMetadataCompletenessItem,
|
||||
LanguageDistributionItem,
|
||||
MetadataScoreDistribution,
|
||||
MetadataCompletenessItem,
|
||||
PageCountDistributionItem,
|
||||
PublicationDecadeItem,
|
||||
StatisticsFilterConfig,
|
||||
StatisticsResult,
|
||||
@@ -65,6 +70,28 @@ export async function fetchGenreDistribution(filters: StatisticsFilterConfig): P
|
||||
return parseResult(await api(`/api/v1/statistics/genre-distribution${buildParams(filters)}`))
|
||||
}
|
||||
|
||||
export async function fetchMetadataScoreDistribution(filters: StatisticsFilterConfig): Promise<MetadataScoreDistribution> {
|
||||
const res = await api(`/api/v1/statistics/metadata-score-distribution${buildParams(filters)}`)
|
||||
if (!res.ok) throw new Error(`Metadata score distribution request failed: ${res.status}`)
|
||||
return res.json() as Promise<MetadataScoreDistribution>
|
||||
}
|
||||
|
||||
export async function fetchLibraryMetadataCompleteness(filters: StatisticsFilterConfig): Promise<StatisticsResult<LibraryMetadataCompletenessItem>> {
|
||||
return parseResult(await api(`/api/v1/statistics/library-metadata-completeness${buildParams(filters)}`))
|
||||
}
|
||||
|
||||
export async function fetchFormatShareOverTime(filters: StatisticsFilterConfig): Promise<StatisticsResult<FormatShareOverTimeItem>> {
|
||||
return parseResult(await api(`/api/v1/statistics/format-share-over-time${buildParams(filters)}`))
|
||||
}
|
||||
|
||||
export async function fetchGenreRankOverTime(filters: StatisticsFilterConfig): Promise<StatisticsResult<GenreRankOverTimeItem>> {
|
||||
return parseResult(await api(`/api/v1/statistics/genre-rank-over-time${buildParams(filters)}`))
|
||||
}
|
||||
|
||||
export async function fetchPageCountDistribution(filters: StatisticsFilterConfig): Promise<StatisticsResult<PageCountDistributionItem>> {
|
||||
return parseResult(await api(`/api/v1/statistics/page-count-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}`)
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
<script setup lang="ts">
|
||||
import { shallowRef, watchEffect } from 'vue'
|
||||
import VChart from 'vue-echarts'
|
||||
import { TrendingUp } from 'lucide-vue-next'
|
||||
|
||||
import { useFormatShareOverTime } from '../composables/useFormatShareOverTime'
|
||||
import ChartCard from './ChartCard.vue'
|
||||
|
||||
const MONTH_NAMES = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
|
||||
|
||||
const { data, loading, error } = useFormatShareOverTime()
|
||||
const option = shallowRef({})
|
||||
|
||||
watchEffect(() => {
|
||||
if (!data.value.items.length) return
|
||||
|
||||
const keys = [...new Set(data.value.items.map((d) => `${d.year}-${String(d.month).padStart(2, '0')}`))].sort()
|
||||
const labels = keys.map((k) => {
|
||||
const [year, month] = k.split('-')
|
||||
return `${MONTH_NAMES[Number(month) - 1]} ${year}`
|
||||
})
|
||||
|
||||
const formats = [...new Set(data.value.items.map((d) => d.format))].sort()
|
||||
const counts = new Map(data.value.items.map((d) => [`${d.year}-${String(d.month).padStart(2, '0')}|${d.format}`, d.count]))
|
||||
const totalsByKey = new Map<string, number>()
|
||||
for (const key of keys) totalsByKey.set(key, 0)
|
||||
for (const item of data.value.items) {
|
||||
const key = `${item.year}-${String(item.month).padStart(2, '0')}`
|
||||
totalsByKey.set(key, (totalsByKey.get(key) ?? 0) + item.count)
|
||||
}
|
||||
|
||||
option.value = {
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
confine: true,
|
||||
enterable: false,
|
||||
formatter: (params: { dataIndex: number; marker: string; seriesName: string }[]) => {
|
||||
const first = params[0]
|
||||
if (!first) return ''
|
||||
const dataIndex = first.dataIndex
|
||||
const key = keys[dataIndex]
|
||||
if (!key) return ''
|
||||
const total = totalsByKey.get(key) ?? 0
|
||||
const rows = params
|
||||
.map((p) => {
|
||||
const count = counts.get(`${key}|${p.seriesName}`) ?? 0
|
||||
const percent = total > 0 ? (count / total) * 100 : 0
|
||||
return { html: `${p.marker}${p.seriesName}: <strong>${percent.toFixed(1)}%</strong> (${count})`, percent }
|
||||
})
|
||||
.sort((a, b) => b.percent - a.percent)
|
||||
.map((r) => r.html)
|
||||
return `${labels[dataIndex] ?? key}<br/>${rows.join('<br/>')}`
|
||||
},
|
||||
},
|
||||
legend: { type: 'scroll', top: 0 },
|
||||
grid: { left: '3%', right: '3%', bottom: '12%', top: '16%', containLabel: true },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: labels,
|
||||
axisTick: { show: false },
|
||||
axisLabel: { fontSize: 11, interval: Math.max(0, Math.floor(labels.length / 10) - 1) },
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
min: 0,
|
||||
max: 100,
|
||||
axisLabel: { fontSize: 11, formatter: '{value}%' },
|
||||
},
|
||||
series: formats.map((format) => ({
|
||||
name: format,
|
||||
type: 'line',
|
||||
stack: 'formats',
|
||||
smooth: true,
|
||||
showSymbol: false,
|
||||
areaStyle: { opacity: 0.8 },
|
||||
emphasis: { focus: 'series' },
|
||||
data: keys.map((k) => {
|
||||
const count = counts.get(`${k}|${format}`) ?? 0
|
||||
const total = totalsByKey.get(k) ?? 0
|
||||
return total > 0 ? Number(((count / total) * 100).toFixed(2)) : 0
|
||||
}),
|
||||
})),
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ChartCard title="Format Share Over Time" :icon="TrendingUp" :color-index="3" :loading :error :empty="!data.items.length">
|
||||
<VChart :option autoresize style="height: 100%" />
|
||||
</ChartCard>
|
||||
</template>
|
||||
@@ -0,0 +1,121 @@
|
||||
<script setup lang="ts">
|
||||
import { shallowRef, watchEffect } from 'vue'
|
||||
import VChart from 'vue-echarts'
|
||||
import { Tag } from 'lucide-vue-next'
|
||||
|
||||
import { useGenreRankOverTime } from '../composables/useGenreRankOverTime'
|
||||
import ChartCard from './ChartCard.vue'
|
||||
|
||||
const { data, loading, error } = useGenreRankOverTime()
|
||||
const option = shallowRef({})
|
||||
|
||||
watchEffect(() => {
|
||||
if (!data.value.items.length) return
|
||||
|
||||
const years = [...new Set(data.value.items.map((item) => item.year))].sort((a, b) => a - b)
|
||||
const genres = [...new Set(data.value.items.map((item) => item.genre))]
|
||||
const isSingleYear = years.length === 1
|
||||
|
||||
const rankByGenreYear = new Map(data.value.items.map((item) => [`${item.genre}|${item.year}`, item.rank]))
|
||||
const countByGenreYear = new Map(data.value.items.map((item) => [`${item.genre}|${item.year}`, item.count]))
|
||||
|
||||
if (isSingleYear) {
|
||||
const year = years[0]
|
||||
const rows = genres
|
||||
.map((genre) => ({
|
||||
genre,
|
||||
rank: rankByGenreYear.get(`${genre}|${year}`) ?? Number.MAX_SAFE_INTEGER,
|
||||
count: countByGenreYear.get(`${genre}|${year}`) ?? 0,
|
||||
}))
|
||||
.sort((a, b) => a.rank - b.rank)
|
||||
|
||||
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: '12%', top: '8%', containLabel: true },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: rows.map((r) => r.genre),
|
||||
axisTick: { show: false },
|
||||
axisLabel: { fontSize: 11, rotate: 35, interval: 0 },
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
minInterval: 1,
|
||||
axisLabel: { fontSize: 11 },
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: 'bar',
|
||||
data: rows.map((r) => r.count),
|
||||
barMaxWidth: 38,
|
||||
itemStyle: { borderRadius: [3, 3, 0, 0] },
|
||||
emphasis: { disabled: true },
|
||||
},
|
||||
],
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
option.value = {
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
confine: true,
|
||||
enterable: false,
|
||||
formatter: (params: { seriesName: string; data: number; axisValue: number }[]) => {
|
||||
const rows = params
|
||||
.filter((p) => Number.isFinite(p.data))
|
||||
.sort((a, b) => a.data - b.data)
|
||||
.map((p) => {
|
||||
const count = countByGenreYear.get(`${p.seriesName}|${p.axisValue}`) ?? 0
|
||||
return `${p.seriesName}: #${p.data} (${count})`
|
||||
})
|
||||
return `${params[0]?.axisValue ?? ''}<br/>${rows.join('<br/>')}`
|
||||
},
|
||||
},
|
||||
legend: { type: 'scroll', top: 0 },
|
||||
grid: { left: '3%', right: '3%', bottom: '10%', top: '16%', containLabel: true },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: years,
|
||||
axisTick: { show: false },
|
||||
axisLabel: { fontSize: 11 },
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
inverse: true,
|
||||
min: 1,
|
||||
max: genres.length,
|
||||
interval: 1,
|
||||
axisLabel: { formatter: '#{value}', fontSize: 11 },
|
||||
},
|
||||
series: genres.map((genre) => ({
|
||||
name: genre,
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
showSymbol: true,
|
||||
symbolSize: 6,
|
||||
endLabel: {
|
||||
show: true,
|
||||
formatter: genre,
|
||||
},
|
||||
data: years.map((year) => rankByGenreYear.get(`${genre}|${year}`) ?? null),
|
||||
emphasis: { focus: 'series' },
|
||||
})),
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ChartCard title="Genre Rank Over Time" :icon="Tag" :color-index="8" :loading :error :empty="!data.items.length">
|
||||
<VChart :option autoresize style="height: 100%" />
|
||||
</ChartCard>
|
||||
</template>
|
||||
@@ -0,0 +1,86 @@
|
||||
<script setup lang="ts">
|
||||
import { shallowRef, watchEffect } from 'vue'
|
||||
import VChart from 'vue-echarts'
|
||||
import { ListChecks } from 'lucide-vue-next'
|
||||
|
||||
import { useLibraryMetadataCompleteness } from '../composables/useLibraryMetadataCompleteness'
|
||||
import ChartCard from './ChartCard.vue'
|
||||
|
||||
const FIELD_ORDER = ['Cover', 'Author', 'Description', 'Publisher', 'Year', 'Language', 'Page Count', 'Rating', 'Series', 'ISBN']
|
||||
|
||||
const { data, loading, error } = useLibraryMetadataCompleteness()
|
||||
const option = shallowRef({})
|
||||
|
||||
watchEffect(() => {
|
||||
if (!data.value.items.length) return
|
||||
|
||||
const libraries = [...new Set(data.value.items.map((item) => item.libraryName))]
|
||||
const fields = FIELD_ORDER.filter((field) => data.value.items.some((item) => item.field === field))
|
||||
|
||||
const points = data.value.items.map((item) => {
|
||||
const x = fields.indexOf(item.field)
|
||||
const y = libraries.indexOf(item.libraryName)
|
||||
return [x, y, item.percent, item.presentCount, item.totalCount]
|
||||
})
|
||||
|
||||
option.value = {
|
||||
tooltip: {
|
||||
confine: true,
|
||||
enterable: false,
|
||||
formatter: (params: { value: [number, number, number, number, number] }) => {
|
||||
const [x, y, pct, present, total] = params.value
|
||||
return `${libraries[y]}<br/>${fields[x]}: <strong>${pct}%</strong> (${present}/${total})`
|
||||
},
|
||||
},
|
||||
grid: { left: '3%', right: '8%', bottom: '6%', top: '6%', containLabel: true },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: fields,
|
||||
axisTick: { show: false },
|
||||
axisLabel: { fontSize: 11, rotate: 35, interval: 0 },
|
||||
},
|
||||
yAxis: {
|
||||
type: 'category',
|
||||
data: libraries,
|
||||
axisTick: { show: false },
|
||||
axisLabel: { fontSize: 11 },
|
||||
},
|
||||
visualMap: {
|
||||
dimension: 2,
|
||||
min: 0,
|
||||
max: 100,
|
||||
orient: 'vertical',
|
||||
right: 0,
|
||||
top: 'middle',
|
||||
calculable: false,
|
||||
inRange: { color: ['#334155', '#475569', '#64748b', '#93c5fd', '#2563eb'] },
|
||||
text: ['100%', '0%'],
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: 'heatmap',
|
||||
data: points,
|
||||
encode: { x: 0, y: 1, value: 2, tooltip: [2, 3, 4] },
|
||||
label: {
|
||||
show: true,
|
||||
formatter: (params: { value: [number, number, number] }) => `${params.value[2]}%`,
|
||||
color: '#f8fafc',
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
},
|
||||
itemStyle: {
|
||||
borderColor: 'rgba(15, 23, 42, 0.7)',
|
||||
borderWidth: 1,
|
||||
},
|
||||
emphasis: { disabled: true },
|
||||
},
|
||||
],
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ChartCard title="Library Metadata Completeness" :icon="ListChecks" :color-index="7" :loading :error :empty="!data.items.length" tall>
|
||||
<VChart :option autoresize style="height: 100%" />
|
||||
</ChartCard>
|
||||
</template>
|
||||
@@ -0,0 +1,86 @@
|
||||
<script setup lang="ts">
|
||||
import { shallowRef, watchEffect } from 'vue'
|
||||
import VChart from 'vue-echarts'
|
||||
import { BarChart3 } from 'lucide-vue-next'
|
||||
|
||||
import { useMetadataScoreDistribution } from '../composables/useMetadataScoreDistribution'
|
||||
import ChartCard from './ChartCard.vue'
|
||||
|
||||
const { data, loading, error } = useMetadataScoreDistribution()
|
||||
const option = shallowRef({})
|
||||
|
||||
function percentileToIndex(value: number | null): number | null {
|
||||
if (value == null) return null
|
||||
return Math.max(0, Math.min(9, Math.floor(value / 10)))
|
||||
}
|
||||
|
||||
watchEffect(() => {
|
||||
if (!data.value.bins.length) return
|
||||
|
||||
const p25Index = percentileToIndex(data.value.percentile25)
|
||||
const p50Index = percentileToIndex(data.value.percentile50)
|
||||
const p75Index = percentileToIndex(data.value.percentile75)
|
||||
const p90Index = percentileToIndex(data.value.percentile90)
|
||||
|
||||
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: '12%', top: '10%', containLabel: true },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: data.value.bins.map((b) => `${b.minScore}-${b.maxScore}`),
|
||||
axisTick: { show: false },
|
||||
axisLabel: { fontSize: 11 },
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
minInterval: 1,
|
||||
axisLabel: { fontSize: 11 },
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: 'bar',
|
||||
data: data.value.bins.map((b) => b.count),
|
||||
barMaxWidth: 44,
|
||||
itemStyle: { borderRadius: [3, 3, 0, 0] },
|
||||
emphasis: { disabled: true },
|
||||
markArea:
|
||||
p25Index != null && p75Index != null
|
||||
? {
|
||||
itemStyle: { color: 'color-mix(in oklch, var(--chart-2) 18%, transparent)' },
|
||||
data: [[{ xAxis: p25Index }, { xAxis: p75Index }]],
|
||||
}
|
||||
: undefined,
|
||||
markLine: {
|
||||
symbol: ['none', 'none'],
|
||||
lineStyle: { type: 'dashed' },
|
||||
label: { show: true, formatter: '{b}', position: 'insideEndTop' },
|
||||
data: [...(p50Index != null ? [{ xAxis: p50Index, name: 'P50' }] : []), ...(p90Index != null ? [{ xAxis: p90Index, name: 'P90' }] : [])],
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ChartCard
|
||||
title="Metadata Score Distribution"
|
||||
:icon="BarChart3"
|
||||
:color-index="2"
|
||||
:loading
|
||||
:error
|
||||
:empty="!data.bins.length"
|
||||
:unknown-count="data.unknownCount"
|
||||
>
|
||||
<VChart :option autoresize style="height: 100%" />
|
||||
</ChartCard>
|
||||
</template>
|
||||
@@ -0,0 +1,67 @@
|
||||
<script setup lang="ts">
|
||||
import { shallowRef, watchEffect } from 'vue'
|
||||
import VChart from 'vue-echarts'
|
||||
import { BookOpen } from 'lucide-vue-next'
|
||||
|
||||
import { usePageCountDistribution } from '../composables/usePageCountDistribution'
|
||||
import ChartCard from './ChartCard.vue'
|
||||
|
||||
const { data, loading, error } = usePageCountDistribution()
|
||||
const option = shallowRef({})
|
||||
|
||||
watchEffect(() => {
|
||||
if (!data.value.items.length) return
|
||||
|
||||
option.value = {
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
confine: true,
|
||||
enterable: false,
|
||||
formatter: (params: { dataIndex: number }) => {
|
||||
const row = data.value.items[params.dataIndex]
|
||||
if (!row) return ''
|
||||
return [
|
||||
`${row.format} (${row.count} books)`,
|
||||
`Min: ${row.min}`,
|
||||
`Q1: ${Math.round(row.q1)}`,
|
||||
`Median: ${Math.round(row.median)}`,
|
||||
`Q3: ${Math.round(row.q3)}`,
|
||||
`Max: ${row.max}`,
|
||||
].join('<br/>')
|
||||
},
|
||||
},
|
||||
grid: { left: '3%', right: '3%', bottom: '18%', top: '8%', containLabel: true },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: data.value.items.map((item) => item.format),
|
||||
axisTick: { show: false },
|
||||
axisLabel: { fontSize: 11, rotate: 35, interval: 0 },
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
axisLabel: { fontSize: 11 },
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: 'boxplot',
|
||||
data: data.value.items.map((item) => [item.min, item.q1, item.median, item.q3, item.max]),
|
||||
emphasis: { disabled: true },
|
||||
},
|
||||
],
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ChartCard
|
||||
title="Page Count Distribution"
|
||||
:icon="BookOpen"
|
||||
:color-index="1"
|
||||
:loading
|
||||
:error
|
||||
:empty="!data.items.length"
|
||||
:unknown-count="data.unknownCount"
|
||||
>
|
||||
<VChart :option autoresize style="height: 100%" />
|
||||
</ChartCard>
|
||||
</template>
|
||||
@@ -53,6 +53,31 @@ const CHART_REGISTRY: Record<StatisticsChartId, ChartRegistryEntry> = {
|
||||
label: 'Genre Distribution',
|
||||
wide: true,
|
||||
},
|
||||
'metadata-score-distribution': {
|
||||
component: defineAsyncComponent(() => import('./MetadataScoreDistributionChart.vue')),
|
||||
label: 'Metadata Score Distribution',
|
||||
wide: false,
|
||||
},
|
||||
'library-metadata-completeness': {
|
||||
component: defineAsyncComponent(() => import('./LibraryMetadataCompletenessHeatmapChart.vue')),
|
||||
label: 'Library Metadata Completeness',
|
||||
wide: true,
|
||||
},
|
||||
'format-share-over-time': {
|
||||
component: defineAsyncComponent(() => import('./FormatShareOverTimeChart.vue')),
|
||||
label: 'Format Share Over Time',
|
||||
wide: true,
|
||||
},
|
||||
'genre-rank-over-time': {
|
||||
component: defineAsyncComponent(() => import('./GenreRankOverTimeChart.vue')),
|
||||
label: 'Genre Rank Over Time',
|
||||
wide: true,
|
||||
},
|
||||
'page-count-distribution': {
|
||||
component: defineAsyncComponent(() => import('./PageCountDistributionChart.vue')),
|
||||
label: 'Page Count Distribution',
|
||||
wide: false,
|
||||
},
|
||||
}
|
||||
|
||||
const { visibleCharts, reorder } = useStatisticsConfig()
|
||||
@@ -65,7 +90,7 @@ function handleReorder(newList: ChartConfigEntry[]) {
|
||||
<template>
|
||||
<VueDraggable
|
||||
:model-value="visibleCharts"
|
||||
class="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4"
|
||||
class="grid grid-flow-row-dense 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"
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import {
|
||||
BarChart3,
|
||||
BookOpen,
|
||||
CalendarDays,
|
||||
Check,
|
||||
ChevronDown,
|
||||
@@ -34,6 +36,11 @@ const CHART_LABELS: Record<StatisticsChartId, string> = {
|
||||
'top-authors': 'Top 25 Authors',
|
||||
'metadata-completeness': 'Metadata Completeness',
|
||||
'genre-distribution': 'Genre Distribution',
|
||||
'metadata-score-distribution': 'Metadata Score Distribution',
|
||||
'library-metadata-completeness': 'Library Metadata Completeness',
|
||||
'format-share-over-time': 'Format Share Over Time',
|
||||
'genre-rank-over-time': 'Genre Rank Over Time',
|
||||
'page-count-distribution': 'Page Count Distribution',
|
||||
}
|
||||
|
||||
const CHART_ICONS: Record<StatisticsChartId, unknown> = {
|
||||
@@ -45,6 +52,11 @@ const CHART_ICONS: Record<StatisticsChartId, unknown> = {
|
||||
'top-authors': Users,
|
||||
'metadata-completeness': ListChecks,
|
||||
'genre-distribution': Tag,
|
||||
'metadata-score-distribution': BarChart3,
|
||||
'library-metadata-completeness': ListChecks,
|
||||
'format-share-over-time': TrendingUp,
|
||||
'genre-rank-over-time': Tag,
|
||||
'page-count-distribution': BookOpen,
|
||||
}
|
||||
|
||||
const { orderedCharts, filters, init, toggleVisibility, reorder, resetToDefaults, setLibraryFilter } = useStatisticsConfig()
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { onMounted, ref, watch } from 'vue'
|
||||
|
||||
import type { FormatShareOverTimeItem, StatisticsResult } from '@projectx/types'
|
||||
import { fetchFormatShareOverTime } from '../api/statistics.api'
|
||||
import { useStatisticsConfig } from './useStatisticsConfig'
|
||||
|
||||
export function useFormatShareOverTime() {
|
||||
const data = ref<StatisticsResult<FormatShareOverTimeItem>>({ 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 fetchFormatShareOverTime(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 { GenreRankOverTimeItem, StatisticsResult } from '@projectx/types'
|
||||
import { fetchGenreRankOverTime } from '../api/statistics.api'
|
||||
import { useStatisticsConfig } from './useStatisticsConfig'
|
||||
|
||||
export function useGenreRankOverTime() {
|
||||
const data = ref<StatisticsResult<GenreRankOverTimeItem>>({ 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 fetchGenreRankOverTime(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 { LibraryMetadataCompletenessItem, StatisticsResult } from '@projectx/types'
|
||||
import { fetchLibraryMetadataCompleteness } from '../api/statistics.api'
|
||||
import { useStatisticsConfig } from './useStatisticsConfig'
|
||||
|
||||
export function useLibraryMetadataCompleteness() {
|
||||
const data = ref<StatisticsResult<LibraryMetadataCompletenessItem>>({ 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 fetchLibraryMetadataCompleteness(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,39 @@
|
||||
import { onMounted, ref, watch } from 'vue'
|
||||
|
||||
import type { MetadataScoreDistribution } from '@projectx/types'
|
||||
import { fetchMetadataScoreDistribution } from '../api/statistics.api'
|
||||
import { useStatisticsConfig } from './useStatisticsConfig'
|
||||
|
||||
const EMPTY: MetadataScoreDistribution = {
|
||||
bins: [],
|
||||
unknownCount: 0,
|
||||
totalCount: 0,
|
||||
percentile25: null,
|
||||
percentile50: null,
|
||||
percentile75: null,
|
||||
percentile90: null,
|
||||
}
|
||||
|
||||
export function useMetadataScoreDistribution() {
|
||||
const data = ref<MetadataScoreDistribution>(EMPTY)
|
||||
const loading = ref(true)
|
||||
const error = ref(false)
|
||||
|
||||
const { filters } = useStatisticsConfig()
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = false
|
||||
try {
|
||||
data.value = await fetchMetadataScoreDistribution(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 { PageCountDistributionItem, StatisticsResult } from '@projectx/types'
|
||||
import { fetchPageCountDistribution } from '../api/statistics.api'
|
||||
import { useStatisticsConfig } from './useStatisticsConfig'
|
||||
|
||||
export function usePageCountDistribution() {
|
||||
const data = ref<StatisticsResult<PageCountDistributionItem>>({ 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 fetchPageCountDistribution(filters.value)
|
||||
} catch {
|
||||
error.value = true
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => filters.value.libraryIds.join(','), load)
|
||||
onMounted(load)
|
||||
return { data, loading, error }
|
||||
}
|
||||
@@ -1,33 +1,26 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { toast } from 'vue-sonner'
|
||||
|
||||
import type {
|
||||
ChartConfigEntry,
|
||||
StatisticsChartId,
|
||||
StatisticsDateRange,
|
||||
StatisticsFilterConfig,
|
||||
StatisticsGranularity,
|
||||
StatisticsSettings,
|
||||
import {
|
||||
DEFAULT_STATISTICS_CHART_ORDER,
|
||||
DEFAULT_STATISTICS_FILTERS,
|
||||
type ChartConfigEntry,
|
||||
type StatisticsChartId,
|
||||
type StatisticsDateRange,
|
||||
type StatisticsFilterConfig,
|
||||
type StatisticsGranularity,
|
||||
type 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 KNOWN_CHART_IDS: StatisticsChartId[] = [...DEFAULT_STATISTICS_CHART_ORDER]
|
||||
|
||||
const DEFAULT_FILTERS: StatisticsFilterConfig = {
|
||||
libraryIds: [],
|
||||
booksOverTimeGranularity: 'monthly',
|
||||
booksOverTimeRange: 'all-time',
|
||||
libraryIds: [...DEFAULT_STATISTICS_FILTERS.libraryIds],
|
||||
booksOverTimeGranularity: DEFAULT_STATISTICS_FILTERS.booksOverTimeGranularity,
|
||||
booksOverTimeRange: DEFAULT_STATISTICS_FILTERS.booksOverTimeRange,
|
||||
}
|
||||
|
||||
const config = ref<ChartConfigEntry[]>([])
|
||||
|
||||
@@ -1,12 +1,37 @@
|
||||
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'
|
||||
import { BarChart, BoxplotChart, HeatmapChart, LineChart, PieChart, SunburstChart } from 'echarts/charts'
|
||||
import {
|
||||
CalendarComponent,
|
||||
GridComponent,
|
||||
LegendComponent,
|
||||
MarkAreaComponent,
|
||||
MarkLineComponent,
|
||||
TitleComponent,
|
||||
TooltipComponent,
|
||||
VisualMapComponent,
|
||||
} 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])
|
||||
use([
|
||||
SVGRenderer,
|
||||
PieChart,
|
||||
BarChart,
|
||||
LineChart,
|
||||
HeatmapChart,
|
||||
BoxplotChart,
|
||||
SunburstChart,
|
||||
TitleComponent,
|
||||
TooltipComponent,
|
||||
LegendComponent,
|
||||
GridComponent,
|
||||
CalendarComponent,
|
||||
VisualMapComponent,
|
||||
MarkLineComponent,
|
||||
MarkAreaComponent,
|
||||
])
|
||||
|
||||
function readCssColor(varName: string): string {
|
||||
const el = document.createElement('span')
|
||||
|
||||
@@ -6,7 +6,12 @@ export type StatisticsChartId =
|
||||
| 'publication-decade'
|
||||
| 'top-authors'
|
||||
| 'metadata-completeness'
|
||||
| 'genre-distribution';
|
||||
| 'genre-distribution'
|
||||
| 'metadata-score-distribution'
|
||||
| 'library-metadata-completeness'
|
||||
| 'format-share-over-time'
|
||||
| 'genre-rank-over-time'
|
||||
| 'page-count-distribution';
|
||||
|
||||
export type StatisticsGranularity = 'monthly' | 'yearly';
|
||||
export type StatisticsDateRange = 'last-year' | 'last-5-years' | 'all-time';
|
||||
@@ -30,6 +35,39 @@ export interface StatisticsSettings {
|
||||
filters: StatisticsFilterConfig;
|
||||
}
|
||||
|
||||
export const DEFAULT_STATISTICS_CHART_ORDER: StatisticsChartId[] = [
|
||||
'format-distribution',
|
||||
'language-distribution',
|
||||
'storage-by-format',
|
||||
'publication-decade',
|
||||
'books-added-over-time',
|
||||
'format-share-over-time',
|
||||
'metadata-completeness',
|
||||
'metadata-score-distribution',
|
||||
'page-count-distribution',
|
||||
'top-authors',
|
||||
'genre-distribution',
|
||||
'genre-rank-over-time',
|
||||
'library-metadata-completeness',
|
||||
];
|
||||
|
||||
export const DEFAULT_STATISTICS_FILTERS: StatisticsFilterConfig = {
|
||||
libraryIds: [],
|
||||
booksOverTimeRange: 'all-time',
|
||||
booksOverTimeGranularity: 'monthly',
|
||||
};
|
||||
|
||||
export function createDefaultStatisticsSettings(): StatisticsSettings {
|
||||
return {
|
||||
charts: DEFAULT_STATISTICS_CHART_ORDER.map((id, order) => ({ id, order, visible: true })),
|
||||
filters: {
|
||||
libraryIds: [],
|
||||
booksOverTimeRange: DEFAULT_STATISTICS_FILTERS.booksOverTimeRange,
|
||||
booksOverTimeGranularity: DEFAULT_STATISTICS_FILTERS.booksOverTimeGranularity,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// 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).
|
||||
@@ -80,6 +118,55 @@ export interface GenreDistributionItem {
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface MetadataScoreDistributionBin {
|
||||
minScore: number;
|
||||
maxScore: number;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface MetadataScoreDistribution {
|
||||
bins: MetadataScoreDistributionBin[];
|
||||
unknownCount: number;
|
||||
totalCount: number;
|
||||
percentile25: number | null;
|
||||
percentile50: number | null;
|
||||
percentile75: number | null;
|
||||
percentile90: number | null;
|
||||
}
|
||||
|
||||
export interface LibraryMetadataCompletenessItem {
|
||||
libraryId: number;
|
||||
libraryName: string;
|
||||
field: string;
|
||||
presentCount: number;
|
||||
totalCount: number;
|
||||
percent: number;
|
||||
}
|
||||
|
||||
export interface FormatShareOverTimeItem {
|
||||
year: number;
|
||||
month: number;
|
||||
format: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface GenreRankOverTimeItem {
|
||||
year: number;
|
||||
genre: string;
|
||||
rank: number;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface PageCountDistributionItem {
|
||||
format: string;
|
||||
count: number;
|
||||
min: number;
|
||||
q1: number;
|
||||
median: number;
|
||||
q3: number;
|
||||
max: number;
|
||||
}
|
||||
|
||||
export interface StatisticsSummary {
|
||||
totalBooks: number;
|
||||
totalAuthors: number;
|
||||
|
||||
@@ -636,4 +636,4 @@ CREATE INDEX "oidc_sessions_subject_issuer_idx" ON "oidc_sessions" USING btree (
|
||||
CREATE INDEX "oidc_sessions_sid_idx" ON "oidc_sessions" USING btree ("oidc_session_id");--> statement-breakpoint
|
||||
CREATE INDEX "staging_files_status_idx" ON "staging_files" USING btree ("status");--> statement-breakpoint
|
||||
CREATE INDEX "fwl_book_id_idx" ON "file_write_log" USING btree ("book_id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "email_templates_system_name_unique" ON "email_templates" USING btree ("name") WHERE "email_templates"."user_id" is null;
|
||||
CREATE UNIQUE INDEX "email_templates_system_name_unique" ON "email_templates" USING btree ("name") WHERE "email_templates"."user_id" is null;
|
||||
|
||||
@@ -30,6 +30,31 @@ export class StatisticsController {
|
||||
return this.statisticsService.getStorageByFormat(user, query);
|
||||
}
|
||||
|
||||
@Get('metadata-score-distribution')
|
||||
getMetadataScoreDistribution(@CurrentUser() user: RequestUser, @Query() query: StatisticsFilterQueryDto) {
|
||||
return this.statisticsService.getMetadataScoreDistribution(user, query);
|
||||
}
|
||||
|
||||
@Get('library-metadata-completeness')
|
||||
getLibraryMetadataCompleteness(@CurrentUser() user: RequestUser, @Query() query: StatisticsFilterQueryDto) {
|
||||
return this.statisticsService.getLibraryMetadataCompleteness(user, query);
|
||||
}
|
||||
|
||||
@Get('format-share-over-time')
|
||||
getFormatShareOverTime(@CurrentUser() user: RequestUser, @Query() query: StatisticsFilterQueryDto) {
|
||||
return this.statisticsService.getFormatShareOverTime(user, query);
|
||||
}
|
||||
|
||||
@Get('genre-rank-over-time')
|
||||
getGenreRankOverTime(@CurrentUser() user: RequestUser, @Query() query: StatisticsFilterQueryDto) {
|
||||
return this.statisticsService.getGenreRankOverTime(user, query);
|
||||
}
|
||||
|
||||
@Get('page-count-distribution')
|
||||
getPageCountDistribution(@CurrentUser() user: RequestUser, @Query() query: StatisticsFilterQueryDto) {
|
||||
return this.statisticsService.getPageCountDistribution(user, query);
|
||||
}
|
||||
|
||||
@Get('publication-decade')
|
||||
getPublicationDecade(@CurrentUser() user: RequestUser, @Query() query: StatisticsFilterQueryDto) {
|
||||
return this.statisticsService.getPublicationDecade(user, query);
|
||||
|
||||
@@ -6,7 +6,7 @@ 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';
|
||||
import { authors, bookAuthors, bookFiles, bookGenres, bookMetadata, books, genres, libraries, userLibraryAccess } from '../../db/schema';
|
||||
|
||||
type Db = NodePgDatabase<typeof schema>;
|
||||
|
||||
@@ -114,6 +114,155 @@ export class StatisticsRepository {
|
||||
.orderBy(sql`extract(year from ${books.addedAt})`, sql`extract(month from ${books.addedAt})`);
|
||||
}
|
||||
|
||||
async metadataScoreDistribution(userId: number, isSuperuser: boolean, filterLibraryIds?: number[]) {
|
||||
const accessible = await this.getAccessibleLibraryIds(userId, isSuperuser);
|
||||
const filter = this.libraryFilter(this.intersectLibraryIds(accessible, filterLibraryIds));
|
||||
|
||||
const bucketExpr = sql`(least(floor(${bookMetadata.metadataScore} / 10.0), 9) * 10)`;
|
||||
|
||||
const [bins, [unknown], [percentiles]] = await Promise.all([
|
||||
this.db
|
||||
.select({
|
||||
minScore: sql<number>`${bucketExpr}::int`,
|
||||
count: sql<number>`count(*)::int`,
|
||||
})
|
||||
.from(books)
|
||||
.innerJoin(bookMetadata, eq(bookMetadata.bookId, books.id))
|
||||
.where(and(isNotNull(bookMetadata.metadataScore), filter))
|
||||
.groupBy(bucketExpr)
|
||||
.orderBy(bucketExpr),
|
||||
this.db
|
||||
.select({
|
||||
unknownCount: sql<number>`count(*)::int`,
|
||||
})
|
||||
.from(books)
|
||||
.leftJoin(bookMetadata, eq(bookMetadata.bookId, books.id))
|
||||
.where(and(isNull(bookMetadata.metadataScore), filter)),
|
||||
this.db
|
||||
.select({
|
||||
totalCount: sql<number>`count(*)::int`,
|
||||
percentile25: sql<number | null>`percentile_cont(0.25) within group (order by ${bookMetadata.metadataScore})::float`,
|
||||
percentile50: sql<number | null>`percentile_cont(0.5) within group (order by ${bookMetadata.metadataScore})::float`,
|
||||
percentile75: sql<number | null>`percentile_cont(0.75) within group (order by ${bookMetadata.metadataScore})::float`,
|
||||
percentile90: sql<number | null>`percentile_cont(0.9) within group (order by ${bookMetadata.metadataScore})::float`,
|
||||
})
|
||||
.from(books)
|
||||
.innerJoin(bookMetadata, eq(bookMetadata.bookId, books.id))
|
||||
.where(and(isNotNull(bookMetadata.metadataScore), filter)),
|
||||
]);
|
||||
|
||||
return {
|
||||
bins,
|
||||
unknownCount: unknown?.unknownCount ?? 0,
|
||||
totalCount: percentiles?.totalCount ?? 0,
|
||||
percentile25: percentiles?.percentile25 ?? null,
|
||||
percentile50: percentiles?.percentile50 ?? null,
|
||||
percentile75: percentiles?.percentile75 ?? null,
|
||||
percentile90: percentiles?.percentile90 ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
async libraryMetadataCompleteness(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({
|
||||
libraryId: books.libraryId,
|
||||
libraryName: libraries.name,
|
||||
total: sql<number>`count(*)::int`,
|
||||
hasCover: sql<number>`count(${bookMetadata.coverSource})::int`,
|
||||
hasAuthor: sql<number>`count(distinct ba.book_id)::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`,
|
||||
})
|
||||
.from(books)
|
||||
.innerJoin(libraries, eq(libraries.id, books.libraryId))
|
||||
.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)
|
||||
.groupBy(books.libraryId, libraries.name)
|
||||
.orderBy(libraries.name);
|
||||
}
|
||||
|
||||
async formatShareOverTime(userId: number, isSuperuser: boolean, filterLibraryIds?: number[]) {
|
||||
const accessible = await this.getAccessibleLibraryIds(userId, isSuperuser);
|
||||
const filter = this.libraryFilter(this.intersectLibraryIds(accessible, filterLibraryIds));
|
||||
const yearExpr = sql`extract(year from ${books.addedAt})`;
|
||||
const monthExpr = sql`extract(month from ${books.addedAt})`;
|
||||
|
||||
return this.db
|
||||
.select({
|
||||
year: sql<number>`${yearExpr}::int`,
|
||||
month: sql<number>`${monthExpr}::int`,
|
||||
format: bookFiles.format,
|
||||
count: sql<number>`count(distinct ${bookFiles.bookId})::int`,
|
||||
})
|
||||
.from(bookFiles)
|
||||
.innerJoin(books, eq(bookFiles.bookId, books.id))
|
||||
.where(and(eq(bookFiles.role, 'primary'), isNotNull(bookFiles.format), filter))
|
||||
.groupBy(yearExpr, monthExpr, bookFiles.format)
|
||||
.orderBy(yearExpr, monthExpr, bookFiles.format);
|
||||
}
|
||||
|
||||
async genreCountsByYear(userId: number, isSuperuser: boolean, filterLibraryIds?: number[]) {
|
||||
const accessible = await this.getAccessibleLibraryIds(userId, isSuperuser);
|
||||
const filter = this.libraryFilter(this.intersectLibraryIds(accessible, filterLibraryIds));
|
||||
const yearExpr = sql`extract(year from ${books.addedAt})`;
|
||||
|
||||
return this.db
|
||||
.select({
|
||||
year: sql<number>`${yearExpr}::int`,
|
||||
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(yearExpr, genres.name)
|
||||
.orderBy(yearExpr, desc(sql<number>`count(distinct ${bookGenres.bookId})`), genres.name);
|
||||
}
|
||||
|
||||
async pageCountDistributionByFormat(userId: number, isSuperuser: boolean, filterLibraryIds?: number[]) {
|
||||
const accessible = await this.getAccessibleLibraryIds(userId, isSuperuser);
|
||||
const filter = this.libraryFilter(this.intersectLibraryIds(accessible, filterLibraryIds));
|
||||
|
||||
const [items, [unknown]] = await Promise.all([
|
||||
this.db
|
||||
.select({
|
||||
format: bookFiles.format,
|
||||
count: sql<number>`count(*)::int`,
|
||||
min: sql<number>`min(${bookMetadata.pageCount})::int`,
|
||||
q1: sql<number>`percentile_cont(0.25) within group (order by ${bookMetadata.pageCount})::float`,
|
||||
median: sql<number>`percentile_cont(0.5) within group (order by ${bookMetadata.pageCount})::float`,
|
||||
q3: sql<number>`percentile_cont(0.75) within group (order by ${bookMetadata.pageCount})::float`,
|
||||
max: sql<number>`max(${bookMetadata.pageCount})::int`,
|
||||
})
|
||||
.from(books)
|
||||
.innerJoin(bookMetadata, eq(bookMetadata.bookId, books.id))
|
||||
.innerJoin(bookFiles, and(eq(bookFiles.bookId, books.id), eq(bookFiles.role, 'primary')))
|
||||
.where(and(isNotNull(bookMetadata.pageCount), isNotNull(bookFiles.format), filter))
|
||||
.groupBy(bookFiles.format)
|
||||
.orderBy(desc(sql<number>`count(*)`)),
|
||||
this.db
|
||||
.select({
|
||||
unknownCount: sql<number>`count(*)::int`,
|
||||
})
|
||||
.from(books)
|
||||
.leftJoin(bookMetadata, eq(bookMetadata.bookId, books.id))
|
||||
.where(and(isNull(bookMetadata.pageCount), filter)),
|
||||
]);
|
||||
|
||||
return { items, unknownCount: unknown?.unknownCount ?? 0 };
|
||||
}
|
||||
|
||||
async storageByFormat(userId: number, isSuperuser: boolean, filterLibraryIds?: number[]) {
|
||||
const accessible = await this.getAccessibleLibraryIds(userId, isSuperuser);
|
||||
const filter = this.libraryFilter(this.intersectLibraryIds(accessible, filterLibraryIds));
|
||||
|
||||
@@ -2,11 +2,16 @@ import { Injectable } from '@nestjs/common';
|
||||
|
||||
import type {
|
||||
BooksAddedDataPoint,
|
||||
FormatShareOverTimeItem,
|
||||
StatisticsSummary,
|
||||
GenreRankOverTimeItem,
|
||||
FormatDistributionItem,
|
||||
GenreDistributionItem,
|
||||
LibraryMetadataCompletenessItem,
|
||||
LanguageDistributionItem,
|
||||
MetadataScoreDistribution,
|
||||
MetadataCompletenessItem,
|
||||
PageCountDistributionItem,
|
||||
PublicationDecadeItem,
|
||||
StatisticsResult,
|
||||
StorageByFormatItem,
|
||||
@@ -19,6 +24,8 @@ import type { StatisticsFilterQueryDto } from './dto/statistics-filter-query.dto
|
||||
import { StatisticsRepository } from './statistics.repository';
|
||||
|
||||
const STATISTICS_TOP_N = 10;
|
||||
const STREAM_TOP_FORMATS = 8;
|
||||
const BUMP_TOP_GENRES = 8;
|
||||
|
||||
@Injectable()
|
||||
export class StatisticsService {
|
||||
@@ -41,6 +48,149 @@ export class StatisticsService {
|
||||
return { items, unknownCount: 0 };
|
||||
}
|
||||
|
||||
async getMetadataScoreDistribution(user: RequestUser, query: StatisticsFilterQueryDto): Promise<MetadataScoreDistribution> {
|
||||
const raw = await this.repo.metadataScoreDistribution(user.id, user.isSuperuser, query.libraryIds);
|
||||
const byMin = new Map(raw.bins.map((b) => [b.minScore, b.count]));
|
||||
const bins = Array.from({ length: 10 }, (_, i) => {
|
||||
const minScore = i * 10;
|
||||
const maxScore = i === 9 ? 100 : minScore + 9;
|
||||
return {
|
||||
minScore,
|
||||
maxScore,
|
||||
count: byMin.get(minScore) ?? 0,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
bins,
|
||||
unknownCount: raw.unknownCount,
|
||||
totalCount: raw.totalCount,
|
||||
percentile25: raw.percentile25,
|
||||
percentile50: raw.percentile50,
|
||||
percentile75: raw.percentile75,
|
||||
percentile90: raw.percentile90,
|
||||
};
|
||||
}
|
||||
|
||||
async getLibraryMetadataCompleteness(
|
||||
user: RequestUser,
|
||||
query: StatisticsFilterQueryDto,
|
||||
): Promise<StatisticsResult<LibraryMetadataCompletenessItem>> {
|
||||
const rows = await this.repo.libraryMetadataCompleteness(user.id, user.isSuperuser, query.libraryIds);
|
||||
const fieldDefs: Array<{ field: string; key: keyof (typeof rows)[number] }> = [
|
||||
{ field: 'Cover', key: 'hasCover' },
|
||||
{ field: 'Author', key: 'hasAuthor' },
|
||||
{ field: 'Description', key: 'hasDescription' },
|
||||
{ field: 'Publisher', key: 'hasPublisher' },
|
||||
{ field: 'Year', key: 'hasYear' },
|
||||
{ field: 'Language', key: 'hasLanguage' },
|
||||
{ field: 'Page Count', key: 'hasPageCount' },
|
||||
{ field: 'Rating', key: 'hasRating' },
|
||||
{ field: 'Series', key: 'hasSeries' },
|
||||
{ field: 'ISBN', key: 'hasIsbn' },
|
||||
];
|
||||
|
||||
const items: LibraryMetadataCompletenessItem[] = rows.flatMap((row) =>
|
||||
fieldDefs.map((f) => {
|
||||
const presentCount = Number(row[f.key] ?? 0);
|
||||
const totalCount = row.total ?? 0;
|
||||
return {
|
||||
libraryId: row.libraryId,
|
||||
libraryName: row.libraryName,
|
||||
field: f.field,
|
||||
presentCount,
|
||||
totalCount,
|
||||
percent: totalCount > 0 ? Math.round((presentCount / totalCount) * 100) : 0,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
return { items, unknownCount: 0 };
|
||||
}
|
||||
|
||||
async getFormatShareOverTime(user: RequestUser, query: StatisticsFilterQueryDto): Promise<StatisticsResult<FormatShareOverTimeItem>> {
|
||||
const raw = await this.repo.formatShareOverTime(user.id, user.isSuperuser, query.libraryIds);
|
||||
const totals = new Map<string, number>();
|
||||
for (const row of raw) {
|
||||
const format = (row.format ?? 'unknown').toUpperCase();
|
||||
totals.set(format, (totals.get(format) ?? 0) + row.count);
|
||||
}
|
||||
|
||||
const topFormats = new Set(
|
||||
[...totals.entries()]
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, STREAM_TOP_FORMATS)
|
||||
.map(([f]) => f),
|
||||
);
|
||||
|
||||
const grouped = new Map<string, FormatShareOverTimeItem>();
|
||||
for (const row of raw) {
|
||||
const normalizedFormat = (row.format ?? 'unknown').toUpperCase();
|
||||
const format = topFormats.has(normalizedFormat) ? normalizedFormat : 'OTHER';
|
||||
const key = `${row.year}-${row.month}-${format}`;
|
||||
const existing = grouped.get(key);
|
||||
if (existing) {
|
||||
existing.count += row.count;
|
||||
continue;
|
||||
}
|
||||
grouped.set(key, { year: row.year, month: row.month, format, count: row.count });
|
||||
}
|
||||
|
||||
const items = [...grouped.values()].sort((a, b) => a.year - b.year || a.month - b.month || a.format.localeCompare(b.format));
|
||||
return { items, unknownCount: 0 };
|
||||
}
|
||||
|
||||
async getGenreRankOverTime(user: RequestUser, query: StatisticsFilterQueryDto): Promise<StatisticsResult<GenreRankOverTimeItem>> {
|
||||
const raw = await this.repo.genreCountsByYear(user.id, user.isSuperuser, query.libraryIds);
|
||||
if (raw.length === 0) return { items: [], unknownCount: 0 };
|
||||
|
||||
const genreTotals = new Map<string, number>();
|
||||
for (const row of raw) {
|
||||
genreTotals.set(row.genre, (genreTotals.get(row.genre) ?? 0) + row.count);
|
||||
}
|
||||
|
||||
const trackedGenres = [...genreTotals.entries()]
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, BUMP_TOP_GENRES)
|
||||
.map(([genre]) => genre);
|
||||
|
||||
const trackedSet = new Set(trackedGenres);
|
||||
const years = [...new Set(raw.map((r) => r.year))].sort((a, b) => a - b);
|
||||
const countsByYearGenre = new Map<string, number>();
|
||||
for (const row of raw) {
|
||||
if (!trackedSet.has(row.genre)) continue;
|
||||
countsByYearGenre.set(`${row.year}|${row.genre}`, row.count);
|
||||
}
|
||||
|
||||
const items: GenreRankOverTimeItem[] = [];
|
||||
for (const year of years) {
|
||||
const entries = trackedGenres.map((genre) => ({
|
||||
genre,
|
||||
count: countsByYearGenre.get(`${year}|${genre}`) ?? 0,
|
||||
}));
|
||||
entries.sort((a, b) => b.count - a.count || a.genre.localeCompare(b.genre));
|
||||
entries.forEach((entry, i) => {
|
||||
items.push({ year, genre: entry.genre, rank: i + 1, count: entry.count });
|
||||
});
|
||||
}
|
||||
|
||||
return { items, unknownCount: 0 };
|
||||
}
|
||||
|
||||
async getPageCountDistribution(user: RequestUser, query: StatisticsFilterQueryDto): Promise<StatisticsResult<PageCountDistributionItem>> {
|
||||
const { items: raw, unknownCount } = await this.repo.pageCountDistributionByFormat(user.id, user.isSuperuser, query.libraryIds);
|
||||
const items = raw.map((row) => ({
|
||||
format: row.format!.toUpperCase(),
|
||||
count: row.count,
|
||||
min: row.min,
|
||||
q1: Number(row.q1),
|
||||
median: Number(row.median),
|
||||
q3: Number(row.q3),
|
||||
max: row.max,
|
||||
}));
|
||||
return { items, unknownCount };
|
||||
}
|
||||
|
||||
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) }));
|
||||
|
||||
Reference in New Issue
Block a user