feat(stats): add advanced analytics charts and defaults

This commit is contained in:
neon
2026-03-19 08:11:17 -06:00
parent 6557a2b9e1
commit d65a4815bc
21 changed files with 1154 additions and 28 deletions
+28 -1
View File
@@ -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[]>([])
+28 -3
View File
@@ -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')
+88 -1
View File
@@ -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;
+1 -1
View File
@@ -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) }));