feat(stats): add top series and library insight charts with backend

This commit is contained in:
neon
2026-03-27 13:16:51 -06:00
parent 10a4a2045a
commit 6daeca7ab2
27 changed files with 1488 additions and 292 deletions
@@ -1,7 +1,10 @@
import { api } from '@/lib/api'
import type {
AcquisitionLagPoint,
BooksAddedDataPoint,
ChordDiagramData,
LibraryIntegrityGauge,
MetadataFreshnessGauge,
UserCompletionLatencyDistribution,
FormatShareOverTimeItem,
UserGenreReadingTimeItem,
@@ -18,18 +21,20 @@ import type {
UserSessionArchetypePoint,
UserStatisticsSummary,
FormatDistributionItem,
GenreRankOverTimeItem,
GenreDistributionItem,
LibraryMetadataCompletenessItem,
LanguageDistributionItem,
LargestBookItem,
MetadataScoreDistribution,
MetadataCompletenessItem,
PageCountDistributionItem,
PublicationDecadeItem,
PublicationYearPoint,
StatisticsFilterConfig,
StatisticsResult,
StorageByFormatItem,
TopAuthorItem,
TopSeriesItem,
} from '@projectx/types'
async function parseResult<T>(res: Response): Promise<StatisticsResult<T>> {
@@ -79,6 +84,10 @@ export async function fetchPublicationDecade(filters: StatisticsFilterConfig): P
return parseResult(await api(`/api/v1/statistics/publication-decade${buildParams(filters)}`))
}
export async function fetchPublicationYearTimeline(filters: StatisticsFilterConfig): Promise<StatisticsResult<PublicationYearPoint>> {
return parseResult(await api(`/api/v1/statistics/publication-year-timeline${buildParams(filters)}`))
}
export async function fetchTopAuthors(filters: StatisticsFilterConfig): Promise<StatisticsResult<TopAuthorItem>> {
return parseResult(await api(`/api/v1/statistics/top-authors${buildParams(filters)}`))
}
@@ -105,10 +114,6 @@ export async function fetchFormatShareOverTime(filters: StatisticsFilterConfig):
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)}`))
}
@@ -211,8 +216,32 @@ export async function fetchGenreCooccurrence(filters: StatisticsFilterConfig): P
return res.json() as Promise<ChordDiagramData>
}
export async function fetchMetadataFreshnessGauge(filters: StatisticsFilterConfig): Promise<MetadataFreshnessGauge> {
const res = await api(`/api/v1/statistics/metadata-freshness-gauge${buildParams(filters)}`)
if (!res.ok) throw new Error(`Metadata freshness gauge request failed: ${res.status}`)
return res.json() as Promise<MetadataFreshnessGauge>
}
export async function fetchLibraryIntegrityGauge(filters: StatisticsFilterConfig): Promise<LibraryIntegrityGauge> {
const res = await api(`/api/v1/statistics/library-integrity-gauge${buildParams(filters)}`)
if (!res.ok) throw new Error(`Library integrity gauge request failed: ${res.status}`)
return res.json() as Promise<LibraryIntegrityGauge>
}
export async function fetchAcquisitionLagScatter(filters: StatisticsFilterConfig): Promise<StatisticsResult<AcquisitionLagPoint>> {
return parseResult(await api(`/api/v1/statistics/acquisition-lag-scatter${buildParams(filters)}`))
}
export async function fetchUserSessionArchetypes(filters: StatisticsFilterConfig): Promise<UserSessionArchetypePoint[]> {
const res = await api(`/api/v1/user-statistics/session-archetypes${buildParams(filters, { days: '365' })}`)
if (!res.ok) throw new Error(`User session archetypes request failed: ${res.status}`)
return res.json() as Promise<UserSessionArchetypePoint[]>
}
export async function fetchLargestBooks(filters: StatisticsFilterConfig): Promise<StatisticsResult<LargestBookItem>> {
return parseResult(await api(`/api/v1/statistics/largest-books${buildParams(filters)}`))
}
export async function fetchTopSeries(filters: StatisticsFilterConfig): Promise<StatisticsResult<TopSeriesItem>> {
return parseResult(await api(`/api/v1/statistics/top-series${buildParams(filters)}`))
}
@@ -21,8 +21,13 @@ const CHART_COMPONENTS: Record<StatisticsChartId, Component> = {
'metadata-score-distribution': defineAsyncComponent(() => import('./library/MetadataScoreDistributionChart.vue')),
'library-metadata-completeness': defineAsyncComponent(() => import('./library/LibraryMetadataCompletenessHeatmapChart.vue')),
'format-share-over-time': defineAsyncComponent(() => import('./library/FormatShareOverTimeChart.vue')),
'genre-rank-over-time': defineAsyncComponent(() => import('./library/GenreRankOverTimeChart.vue')),
'page-count-distribution': defineAsyncComponent(() => import('./library/PageCountDistributionChart.vue')),
'metadata-freshness-gauge': defineAsyncComponent(() => import('./library/MetadataFreshnessGaugeChart.vue')),
'library-integrity-gauge': defineAsyncComponent(() => import('./library/LibraryIntegrityGaugeChart.vue')),
'acquisition-lag-scatter': defineAsyncComponent(() => import('./library/AcquisitionLagScatterChart.vue')),
'largest-books': defineAsyncComponent(() => import('./library/LargestBooksChart.vue')),
'top-series': defineAsyncComponent(() => import('./library/TopSeriesChart.vue')),
'publication-year-timeline': defineAsyncComponent(() => import('./library/PublicationYearTimelineChart.vue')),
'reading-heatmap': defineAsyncComponent(() => import('./user/ReadingHeatmapChart.vue')),
'peak-reading-hours': defineAsyncComponent(() => import('./user/PeakReadingHoursChart.vue')),
'favorite-reading-days': defineAsyncComponent(() => import('./user/FavoriteReadingDaysChart.vue')),
@@ -49,6 +54,7 @@ function tileClass(size: StatisticsChartSize): string {
if (size === '2x2') return 'md:col-span-2 md:row-span-2'
if (size === '1x2') return 'md:col-span-1 md:row-span-2'
if (size === '3x1') return 'md:col-span-2 xl:col-span-3 md:row-span-1'
if (size === '4x1') return 'md:col-span-2 xl:col-span-4 md:row-span-1'
return 'md:col-span-1 md:row-span-1'
}
</script>
@@ -0,0 +1,92 @@
<script setup lang="ts">
import { shallowRef, watchEffect } from 'vue'
import VChart from 'vue-echarts'
import { CalendarRange } from 'lucide-vue-next'
import type { AcquisitionLagPoint } from '@projectx/types'
import { useAcquisitionLagScatter } from '../../composables/useAcquisitionLagScatter'
import ChartCard from '../ChartCard.vue'
const { data, loading, error } = useAcquisitionLagScatter()
const option = shallowRef({})
watchEffect(() => {
if (!data.value.items.length) {
option.value = {}
return
}
const maxCount = Math.max(...data.value.items.map((item) => item.count))
const publishedYears = data.value.items.map((item) => item.addedYear - item.lagYears)
const minYear = Math.min(...publishedYears)
const maxYear = Math.max(...publishedYears)
const minLag = Math.min(...data.value.items.map((item) => item.lagYears))
const maxLag = Math.max(...data.value.items.map((item) => item.lagYears))
const points = data.value.items.map((item: AcquisitionLagPoint) => [item.addedYear - item.lagYears, item.lagYears, item.count, item.addedYear])
option.value = {
tooltip: {
formatter: (params: { value: [number, number, number, number] }) => {
const [publishedYear, lagYears, count, addedYear] = params.value
return `Published ${publishedYear}<br/>Added ${addedYear}<br/>Lag: <strong>${lagYears}</strong> years<br/>Books: <strong>${count}</strong>`
},
},
grid: { left: '5%', right: '4%', top: '10%', bottom: '16%', containLabel: true },
xAxis: {
type: 'value',
min: minYear,
max: maxYear,
axisLabel: { fontSize: 10, formatter: (value: number) => String(Math.round(value)) },
splitNumber: 4,
name: 'Published',
nameLocation: 'middle',
nameGap: 24,
nameTextStyle: { fontSize: 10 },
},
yAxis: {
type: 'value',
min: Math.min(-5, minLag),
max: Math.max(30, maxLag),
axisLabel: { fontSize: 10 },
name: 'Lag (y)',
nameLocation: 'middle',
nameGap: 34,
nameTextStyle: { fontSize: 10 },
},
visualMap: {
show: false,
min: minLag,
max: maxLag,
dimension: 1,
inRange: { color: ['#22c55e', '#eab308', '#f97316', '#ef4444'] },
},
series: [
{
type: 'scatter',
data: points,
symbolSize: (value: number[]) => {
const count = value[2] ?? 1
const ratio = maxCount > 0 ? count / maxCount : 0
return 6 + ratio * 14
},
itemStyle: { opacity: 0.82 },
},
],
}
})
</script>
<template>
<ChartCard
title="Acquisition Lag"
:icon="CalendarRange"
:color-index="6"
:loading
:error
:empty="!data.items.length"
:unknown-count="data.unknownCount"
>
<VChart :option="option" autoresize style="height: 100%" />
</ChartCard>
</template>
@@ -1,49 +1,69 @@
<script setup lang="ts">
import { shallowRef, watchEffect } from 'vue'
import { computed, shallowRef, watchEffect } from 'vue'
import VChart from 'vue-echarts'
import { Tag } from 'lucide-vue-next'
import { useThemeStore } from '@/stores/theme'
import { getThemePalette, readCssColor } from '@/lib/echarts'
import { useGenreDistribution } from '../../composables/useGenreDistribution'
import ChartCard from '../ChartCard.vue'
const themeStore = useThemeStore()
const { data, loading, error } = useGenreDistribution()
const total = computed(() => data.value.items.reduce((s, d) => s + d.count, 0))
const option = shallowRef({})
watchEffect(() => {
if (!data.value.items.length) return
const palette = getThemePalette(themeStore.theme, themeStore.accent, 0.85, 0.85)
const background = readCssColor('--background')
option.value = {
tooltip: {
trigger: 'axis',
formatter: (params: { name: string; value: number }[]) => {
const p = params[0]
if (!p) return ''
return `${p.name}: <strong>${p.value}</strong> books`
trigger: 'item',
appendToBody: true,
formatter: (params: { name: string; value: number }) => {
const pct = total.value > 0 ? ((params.value / total.value) * 100).toFixed(1) : '0'
return `<strong>${params.name}</strong><br/>${params.value} books &nbsp;&nbsp; ${pct}% of library`
},
},
grid: { left: '3%', right: '3%', bottom: '28%', top: '8%', containLabel: false },
xAxis: {
type: 'category',
data: data.value.items.map((d) => d.genre),
axisTick: { show: false },
axisLabel: {
fontSize: 11,
rotate: 45,
interval: 0,
overflow: 'truncate',
width: 80,
},
},
yAxis: {
type: 'value',
minInterval: 1,
axisLabel: { fontSize: 11 },
},
series: [
{
type: 'bar',
data: data.value.items.map((d) => d.count),
itemStyle: { borderRadius: [3, 3, 0, 0] },
barMaxWidth: 32,
type: 'treemap',
roam: false,
nodeClick: false,
breadcrumb: { show: false },
top: 10,
bottom: 10,
left: 10,
right: 10,
data: [...data.value.items]
.sort((a, b) => b.count - a.count)
.map((item, i) => ({
name: item.genre,
value: item.count,
itemStyle: {
color: palette[i % palette.length],
borderWidth: 2,
borderColor: background,
},
})),
label: {
show: true,
fontSize: 12,
fontWeight: 500,
overflow: 'truncate',
color: '#fff',
formatter: (p: { name: string; value: number }) => {
const pct = total.value > 0 ? ((p.value / total.value) * 100).toFixed(1) : '0'
return `${p.name}\n${p.value} (${pct}%)`
},
},
emphasis: { disabled: true },
upperLabel: { show: false },
levels: [{ itemStyle: { borderWidth: 0, gapWidth: 3 } }],
},
],
}
@@ -1,117 +0,0 @@
<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',
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: '8%', 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] },
},
],
}
return
}
option.value = {
tooltip: {
trigger: 'axis',
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: '15%', bottom: '6%', top: '16%', containLabel: true },
xAxis: {
type: 'category',
data: years,
boundaryGap: false,
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,91 @@
<script setup lang="ts">
import { shallowRef, watchEffect } from 'vue'
import VChart from 'vue-echarts'
import { HardDrive } from 'lucide-vue-next'
import { formatBytes } from '@/lib/formatting'
import { getFormatColor } from '@/features/book/lib/format-colors'
import { useLargestBooks } from '../../composables/useLargestBooks'
import ChartCard from '../ChartCard.vue'
const { data, loading, error } = useLargestBooks()
const option = shallowRef({})
watchEffect(() => {
if (!data.value.items.length) return
// Sort by size ascending for horizontal bar chart (top is largest)
const sortedItems = [...data.value.items].sort((a, b) => a.sizeBytes - b.sizeBytes)
option.value = {
tooltip: {
trigger: 'axis',
appendToBody: true,
textStyle: { fontSize: 12 },
formatter: (params: { name: string; value: number }[]) => {
const p = params[0]
if (!p) return ''
return `${p.name}: <strong>${formatBytes(p.value)}</strong>`
},
},
grid: { left: '3%', right: '10%', bottom: '5%', top: '3%', containLabel: true },
dataZoom: [
{
type: 'inside',
yAxisIndex: 0,
startValue: Math.max(0, sortedItems.length - 10),
endValue: sortedItems.length - 1,
zoomOnMouseWheel: false,
moveOnMouseMove: true,
moveOnMouseWheel: true,
},
{
type: 'slider',
yAxisIndex: 0,
right: '2%',
width: 15,
borderColor: 'transparent',
fillerColor: 'rgba(150, 150, 150, 0.2)',
handleSize: 0,
showDetail: false,
brushSelect: false,
},
],
xAxis: {
type: 'value',
axisLabel: {
fontSize: 11,
formatter: (v: number) => formatBytes(v),
},
},
yAxis: {
type: 'category',
data: sortedItems.map((d) => d.title),
axisTick: { show: false },
axisLabel: {
fontSize: 11,
width: 200,
overflow: 'truncate',
},
},
series: [
{
type: 'bar',
data: sortedItems.map((d) => d.sizeBytes),
itemStyle: {
borderRadius: [0, 3, 3, 0],
color: (params: { dataIndex: number }) => getFormatColor(sortedItems[params.dataIndex]?.format),
},
barCategoryGap: '20%',
barMaxWidth: 32,
},
],
}
})
</script>
<template>
<ChartCard title="Top 50 Largest Books" :icon="HardDrive" :color-index="2" :loading :error :empty="!data.items.length">
<VChart :option autoresize style="height: 100%" />
</ChartCard>
</template>
@@ -0,0 +1,111 @@
<script setup lang="ts">
import { computed, shallowRef, watchEffect } from 'vue'
import VChart from 'vue-echarts'
import { ShieldCheck } from 'lucide-vue-next'
import { readCssColor } from '@/lib/echarts'
import { useLibraryIntegrityGauge } from '../../composables/useLibraryIntegrityGauge'
import ChartCard from '../ChartCard.vue'
const { data, loading, error } = useLibraryIntegrityGauge()
const option = shallowRef({})
const totalBooks = computed(() => data.value.totalBooks)
const presentPercent = computed(() => (data.value.totalBooks > 0 ? Math.round((data.value.presentCount / data.value.totalBooks) * 100) : 0))
const primaryPercent = computed(() => (data.value.totalBooks > 0 ? Math.round((data.value.primaryFileCount / data.value.totalBooks) * 100) : 0))
const metadataPercent = computed(() => (data.value.totalBooks > 0 ? Math.round((data.value.metadataCount / data.value.totalBooks) * 100) : 0))
const statCards = computed(() => [
{
label: 'Present',
percent: presentPercent.value,
ratio: `${data.value.presentCount}/${data.value.totalBooks}`,
},
{
label: 'Primary',
percent: primaryPercent.value,
ratio: `${data.value.primaryFileCount}/${data.value.totalBooks}`,
},
{
label: 'Metadata',
percent: metadataPercent.value,
ratio: `${data.value.metadataCount}/${data.value.totalBooks}`,
},
])
function scoreColor(score: number): string {
if (score < 20) return '#ef4444'
if (score < 40) return '#f97316'
if (score < 60) return '#eab308'
if (score < 80) return '#22c55e'
return '#3b82f6'
}
watchEffect(() => {
if (data.value.totalBooks === 0) {
option.value = {}
return
}
const foreground = readCssColor('--foreground')
const activeColor = scoreColor(data.value.integrityScore)
option.value = {
tooltip: {
formatter: () =>
[
`<strong>Integrity Score: ${data.value.integrityScore}%</strong>`,
`Present: ${data.value.presentCount} / ${data.value.totalBooks}`,
`Primary file: ${data.value.primaryFileCount} / ${data.value.totalBooks}`,
`Metadata row: ${data.value.metadataCount} / ${data.value.totalBooks}`,
].join('<br/>'),
},
series: [
{
type: 'gauge',
min: 0,
max: 100,
startAngle: 210,
endAngle: -30,
center: ['50%', '57%'],
radius: '95%',
splitNumber: 5,
axisLine: {
lineStyle: {
width: 14,
color: [
[0.2, '#ef4444'],
[0.4, '#f97316'],
[0.6, '#eab308'],
[0.8, '#22c55e'],
[1, '#3b82f6'],
],
},
},
progress: { show: false },
pointer: { show: true, width: 4, length: '70%', itemStyle: { color: activeColor } },
axisTick: { show: false },
splitLine: { distance: -16, length: 6 },
axisLabel: { distance: -24, fontSize: 10 },
detail: { valueAnimation: true, formatter: '{value}%', fontSize: 24, fontWeight: 700, color: activeColor, offsetCenter: [0, '22%'] },
title: { show: true, offsetCenter: [0, '44%'], fontSize: 11, color: foreground },
data: [{ value: data.value.integrityScore, name: 'Integrity' }],
},
],
}
})
</script>
<template>
<ChartCard title="Library Integrity" :icon="ShieldCheck" :color-index="4" :loading :error :empty="totalBooks === 0">
<div class="flex h-full flex-col">
<VChart :option="option" autoresize style="height: 76%" />
<div class="mt-0 grid grid-cols-3 gap-2 px-1">
<div v-for="card in statCards" :key="card.label" class="bg-muted/40 border-border/60 rounded-md border px-2 py-1 text-center">
<p class="text-muted-foreground text-[10px] leading-none">{{ card.label }}</p>
<p class="mt-1 text-sm leading-none font-semibold tabular-nums">{{ card.percent }}%</p>
<p class="text-muted-foreground mt-0.5 text-[10px] leading-none">{{ card.ratio }}</p>
</div>
</div>
</div>
</ChartCard>
</template>
@@ -0,0 +1,103 @@
<script setup lang="ts">
import { computed, shallowRef, watchEffect } from 'vue'
import VChart from 'vue-echarts'
import { Gauge } from 'lucide-vue-next'
import { readCssColor } from '@/lib/echarts'
import { useMetadataFreshnessGauge } from '../../composables/useMetadataFreshnessGauge'
import ChartCard from '../ChartCard.vue'
const { data, loading, error } = useMetadataFreshnessGauge()
const option = shallowRef({})
const totalBooks = computed(() => data.value.totalBooks)
const statCards = computed(() => {
const total = totalBooks.value
const percent = (count: number) => (total > 0 ? `${Math.round((count / total) * 100)}%` : '0%')
return [
{ label: 'Total', value: data.value.totalBooks, sub: 'Books' },
{ label: 'Fresh ≤30d', value: data.value.fresh30dCount, sub: percent(data.value.fresh30dCount) },
{ label: 'Never fetched', value: data.value.neverFetchedCount, sub: percent(data.value.neverFetchedCount) },
]
})
function scoreColor(score: number): string {
if (score < 20) return '#ef4444'
if (score < 40) return '#f97316'
if (score < 60) return '#eab308'
if (score < 80) return '#22c55e'
return '#3b82f6'
}
watchEffect(() => {
if (data.value.totalBooks === 0) {
option.value = {}
return
}
const foreground = readCssColor('--foreground')
const activeColor = scoreColor(data.value.freshnessScore)
option.value = {
tooltip: {
formatter: () =>
[
`<strong>Freshness Score: ${data.value.freshnessScore}%</strong>`,
`<=30d: ${data.value.fresh30dCount}`,
`31-90d: ${data.value.stale31To90dCount}`,
`91-180d: ${data.value.stale91To180dCount}`,
`>180d: ${data.value.staleOver180dCount}`,
`Never fetched: ${data.value.neverFetchedCount}`,
].join('<br/>'),
},
series: [
{
type: 'gauge',
min: 0,
max: 100,
startAngle: 210,
endAngle: -30,
center: ['50%', '58%'],
radius: '95%',
splitNumber: 5,
axisLine: {
lineStyle: {
width: 14,
color: [
[0.2, '#ef4444'],
[0.4, '#f97316'],
[0.6, '#eab308'],
[0.8, '#22c55e'],
[1, '#3b82f6'],
],
},
},
// Keep the 5-band ramp visible; progress overlay picks theme primary color.
progress: { show: false },
pointer: { show: true, width: 4, length: '70%', itemStyle: { color: activeColor } },
axisTick: { show: false },
splitLine: { distance: -16, length: 6 },
axisLabel: { distance: -24, fontSize: 10 },
detail: { valueAnimation: true, formatter: '{value}%', fontSize: 24, fontWeight: 700, color: activeColor, offsetCenter: [0, '22%'] },
title: { show: true, offsetCenter: [0, '44%'], fontSize: 11, color: foreground },
data: [{ value: data.value.freshnessScore, name: 'Freshness' }],
},
],
}
})
</script>
<template>
<ChartCard title="Metadata Freshness" :icon="Gauge" :color-index="2" :loading :error :empty="totalBooks === 0">
<div class="flex h-full flex-col">
<VChart :option="option" autoresize style="height: 76%" />
<div class="mt-0 grid grid-cols-3 gap-2 px-1">
<div v-for="card in statCards" :key="card.label" class="bg-muted/40 border-border/60 rounded-md border px-2 py-1 text-center">
<p class="text-muted-foreground text-[10px] leading-none">{{ card.label }}</p>
<p class="mt-1 text-sm leading-none font-semibold tabular-nums">{{ card.value }}</p>
<p class="text-muted-foreground text-[10px] leading-none">{{ card.sub }}</p>
</div>
</div>
</div>
</ChartCard>
</template>
@@ -0,0 +1,217 @@
<script setup lang="ts">
import { computed, shallowRef, watchEffect } from 'vue'
import VChart from 'vue-echarts'
import { TrendingUp } from 'lucide-vue-next'
import { usePublicationYearTimeline } from '../../composables/usePublicationYearTimeline'
import ChartCard from '../ChartCard.vue'
const { data, loading, error } = usePublicationYearTimeline()
const option = shallowRef({})
const currentYear = new Date().getFullYear()
const filled = computed(() => {
const pts = data.value.items
if (!pts.length) return []
const byYear = new Map(pts.map((p) => [p.year, p]))
const first = pts[0]
const last = pts[pts.length - 1]
if (!first || !last) return []
const min = first.year
const max = last.year
const result = []
for (let y = min; y <= max; y++) {
const p = byYear.get(y)
result.push({ year: y, count: p?.count ?? 0, topTitles: p?.topTitles ?? [] })
}
return result
})
const movingAvg = computed(() => {
const f = filled.value
return f.map((_, i) => {
const lo = Math.max(0, i - 2)
const hi = Math.min(f.length - 1, i + 2)
const sum = f.slice(lo, hi + 1).reduce((s, p) => s + p.count, 0)
return +(sum / (hi - lo + 1)).toFixed(2)
})
})
const goldenEra = computed(() => {
const f = filled.value
if (f.length < 2) return null
const first = f[0]
const last = f[f.length - 1]
if (!first || !last) return null
let best = { start: first.year, end: first.year, count: 0 }
for (let i = 0; i < f.length; i++) {
const current = f[i]
if (!current) continue
const windowEnd = current.year + 19
const count = f
.slice(i)
.filter((p) => p.year <= windowEnd)
.reduce((s, p) => s + p.count, 0)
if (count > best.count) {
best = { start: current.year, end: Math.min(windowEnd, last.year), count }
}
}
return best
})
const peakItem = computed(() => {
if (!filled.value.length) return null
const first = filled.value[0]
if (!first) return null
return filled.value.reduce((best, p) => (p.count > best.count ? p : best), first)
})
const statCards = computed(() => {
const items = data.value.items
if (!items.length) return null
const first = items[0]
const last = items[items.length - 1]
if (!first || !last) return null
const total = items.reduce((s, p) => s + p.count, 0)
const last10 = items.filter((p) => p.year >= currentYear - 10).reduce((s, p) => s + p.count, 0)
const classics = items.filter((p) => p.year < 1970).reduce((s, p) => s + p.count, 0)
const pct = (n: number) => (total > 0 ? `${Math.round((n / total) * 100)}%` : '0%')
return [
{ label: 'Peak year', value: peakItem.value?.year ?? 0, sub: `${peakItem.value?.count ?? 0} books` },
{ label: 'Last 10 years', value: pct(last10), sub: 'of library' },
{ label: 'Classics', value: pct(classics), sub: 'pre-1970' },
{ label: 'Unique years', value: items.length, sub: 'with books' },
{ label: 'Year span', value: last.year - first.year, sub: 'years covered' },
{ label: 'Avg per year', value: items.length > 0 ? +(total / items.length).toFixed(1) : 0, sub: 'books' },
]
})
watchEffect(() => {
option.value = {}
const f = filled.value
if (!f.length) return
const years = f.map((p) => String(p.year))
const counts = f.map((p) => p.count)
const avg = movingAvg.value
const peak = peakItem.value
const era = goldenEra.value
const markAreaData = era
? [
[
{
xAxis: String(era.start),
itemStyle: { color: 'rgba(128,128,128,0.07)' },
label: { show: true, position: 'insideTopLeft', formatter: 'Golden Era', fontSize: 10, opacity: 0.45 },
},
{ xAxis: String(era.end) },
],
]
: []
const markPointData = peak
? [
{
coord: [String(peak.year), peak.count],
name: 'Peak',
value: peak.year,
symbolSize: 30,
label: { fontSize: 9, fontWeight: 'bold' },
},
]
: []
option.value = {
tooltip: {
trigger: 'axis',
formatter: (params: Array<{ axisValue: string; seriesName: string; data: number }>) => {
const main = params.find((p) => p.seriesName === 'Books')
if (!main) return ''
const year = parseInt(main.axisValue)
const pt = data.value.items.find((p) => p.year === year)
const count = main.data
const bookLabel = count === 1 ? 'book' : 'books'
let html = `<strong>${main.axisValue}</strong><br/>${count} ${bookLabel}`
if (pt?.topTitles?.length) {
html += '<br/><span style="opacity:0.65;font-size:11px">' + pt.topTitles.map((t) => `- ${t}`).join('<br/>') + '</span>'
}
return html
},
},
grid: { left: '3%', right: '3%', bottom: 60, top: 16, containLabel: true },
dataZoom: [
{
type: 'slider',
bottom: 6,
height: 18,
start: 0,
end: 100,
borderColor: 'transparent',
fillerColor: 'rgba(128,128,128,0.15)',
},
{ type: 'inside' },
],
xAxis: {
type: 'category',
data: years,
boundaryGap: false,
axisTick: { show: false },
axisLabel: {
fontSize: 11,
interval: Math.max(0, Math.floor(years.length / 12) - 1),
},
},
yAxis: {
type: 'value',
minInterval: 1,
axisLabel: { fontSize: 11 },
},
series: [
{
name: 'Books',
type: 'line',
data: counts,
smooth: 0.3,
showSymbol: false,
areaStyle: { opacity: 0.15 },
lineStyle: { width: 2 },
markArea: {
silent: true,
data: markAreaData,
},
markPoint: {
data: markPointData,
label: { color: '#fff', fontSize: 9, fontWeight: 'bold' },
},
},
{
name: '5yr Avg',
type: 'line',
data: avg,
smooth: 0.4,
showSymbol: false,
lineStyle: { type: 'dashed', width: 1.5, opacity: 0.5 },
emphasis: { disabled: true },
},
],
}
})
</script>
<template>
<ChartCard title="Publication Year Timeline" :icon="TrendingUp" :color-index="4" :loading :error :empty="!data.items.length">
<div class="flex h-full flex-col">
<VChart :option autoresize style="flex: 1; min-height: 0" />
<div v-if="statCards" class="mt-2 grid grid-cols-6 gap-2 md:gap-8 px-1">
<div v-for="card in statCards" :key="card.label" class="bg-muted/40 border-border/60 rounded-md border px-2 py-1 text-center">
<p class="text-muted-foreground text-[10px] leading-none">{{ card.label }}</p>
<p class="mt-1 text-sm font-semibold leading-none tabular-nums">{{ card.value }}</p>
<p class="text-muted-foreground text-[10px] leading-none">{{ card.sub }}</p>
</div>
</div>
</div>
</ChartCard>
</template>
@@ -3,47 +3,119 @@ import { shallowRef, watchEffect } from 'vue'
import VChart from 'vue-echarts'
import { Users } from 'lucide-vue-next'
import { useThemeStore } from '@/stores/theme'
import { getThemePalette, readCssColor } from '@/lib/echarts'
import { useTopAuthors } from '../../composables/useTopAuthors'
import ChartCard from '../ChartCard.vue'
const themeStore = useThemeStore()
const { data, loading, error } = useTopAuthors()
const option = shallowRef({})
function withAlpha(hex: string, alpha: number): string {
const r = parseInt(hex.slice(1, 3), 16)
const g = parseInt(hex.slice(3, 5), 16)
const b = parseInt(hex.slice(5, 7), 16)
return `rgba(${r}, ${g}, ${b}, ${alpha})`
}
watchEffect(() => {
if (!data.value.items.length) return
const items = [...data.value.items].sort((a, b) => b.count - a.count)
const total = items.reduce((s, d) => s + d.count, 0)
let running = 0
const cumulativePct = items.map((d) => {
running += d.count
return parseFloat(((running / total) * 100).toFixed(1))
})
const palette = getThemePalette(themeStore.theme, themeStore.accent)
const primary = palette[0] ?? '#6b7280'
const mutedColor = readCssColor('--muted-foreground')
const barColors = items.map((_, i) => {
if (i < 3) return primary
if (i < 10) return withAlpha(primary, 0.65)
return withAlpha(primary, 0.35)
})
option.value = {
tooltip: {
trigger: 'axis',
formatter: (params: { name: string; value: number }[]) => {
const p = params[0]
if (!p) return ''
return `${p.name}: <strong>${p.value}</strong> books`
axisPointer: { type: 'none' },
formatter: (params: { seriesType: string; name: string; value: number }[]) => {
const bar = params.find((p) => p.seriesType === 'bar')
if (!bar) return ''
const pct = total > 0 ? ((bar.value / total) * 100).toFixed(1) : '0'
return `<strong>${bar.name}</strong><br/>${bar.value} books &nbsp;&nbsp; ${pct}% of top 25`
},
},
grid: { left: '3%', right: '3%', bottom: '28%', top: '8%', containLabel: false },
xAxis: {
legend: {
data: ['Books', 'Cumulative %'],
top: 0,
right: 0,
textStyle: { fontSize: 10 },
itemWidth: 12,
itemHeight: 8,
},
grid: { left: 2, right: 55, bottom: 6, top: 26, containLabel: true },
xAxis: [
{
type: 'value',
minInterval: 1,
axisLabel: { fontSize: 11 },
splitLine: { lineStyle: { opacity: 0.4 } },
},
{
type: 'value',
position: 'top',
min: 0,
max: 100,
axisLabel: { fontSize: 10, formatter: (v: number) => `${v}%` },
splitLine: { show: false },
axisLine: { show: false },
axisTick: { show: false },
},
],
yAxis: {
type: 'category',
data: data.value.items.map((d) => d.name),
data: items.map((d) => d.name),
inverse: true,
axisTick: { show: false },
axisLabel: {
fontSize: 11,
rotate: 45,
interval: 0,
overflow: 'truncate',
width: 80,
width: 130,
},
},
yAxis: {
type: 'value',
minInterval: 1,
axisLabel: { fontSize: 11 },
},
series: [
{
name: 'Books',
type: 'bar',
data: data.value.items.map((d) => d.count),
itemStyle: { borderRadius: [3, 3, 0, 0] },
barMaxWidth: 32,
xAxisIndex: 0,
data: items.map((d, i) => ({
value: d.count,
itemStyle: { color: barColors[i], borderRadius: [0, 3, 3, 0] },
})),
barMaxWidth: 22,
label: {
show: true,
position: 'right',
fontSize: 10,
formatter: (p: { value: number }) => `${p.value}`,
},
},
{
name: 'Cumulative %',
type: 'line',
xAxisIndex: 1,
data: cumulativePct,
symbol: 'circle',
symbolSize: 4,
lineStyle: { color: mutedColor, width: 1.5 },
itemStyle: { color: mutedColor },
},
],
}
@@ -0,0 +1,151 @@
<script setup lang="ts">
import { shallowRef, watchEffect } from 'vue'
import VChart from 'vue-echarts'
import { Layers } from 'lucide-vue-next'
import { useThemeStore } from '@/stores/theme'
import { getThemePalette, readCssColor } from '@/lib/echarts'
import { useTopSeries } from '../../composables/useTopSeries'
import ChartCard from '../ChartCard.vue'
const themeStore = useThemeStore()
const { data, loading, error } = useTopSeries()
const option = shallowRef({})
function withAlpha(hex: string, alpha: number): string {
const r = parseInt(hex.slice(1, 3), 16)
const g = parseInt(hex.slice(3, 5), 16)
const b = parseInt(hex.slice(5, 7), 16)
return `rgba(${r}, ${g}, ${b}, ${alpha})`
}
watchEffect(() => {
if (!data.value.items.length) return
const items = [...data.value.items].sort((a, b) => b.count - a.count)
const total = items.reduce((s, d) => s + d.count, 0)
let running = 0
const cumulativePct = items.map((d) => {
running += d.count
return parseFloat(((running / total) * 100).toFixed(1))
})
const palette = getThemePalette(themeStore.theme, themeStore.accent)
const primary = palette[0] ?? '#6b7280'
const mutedColor = readCssColor('--muted-foreground')
const barColors = items.map((_, i) => {
if (i < 3) return primary
if (i < 10) return withAlpha(primary, 0.65)
return withAlpha(primary, 0.35)
})
option.value = {
tooltip: {
trigger: 'axis',
axisPointer: { type: 'none' },
formatter: (params: { seriesType: string; name: string; value: number }[]) => {
const bar = params.find((p) => p.seriesType === 'bar')
if (!bar) return ''
const pct = total > 0 ? ((bar.value / total) * 100).toFixed(1) : '0'
return `<strong>${bar.name}</strong><br/>${bar.value} books &nbsp;&nbsp; ${pct}% of top 50`
},
},
legend: {
data: ['Books', 'Cumulative %'],
top: 0,
right: 0,
textStyle: { fontSize: 10 },
itemWidth: 12,
itemHeight: 8,
},
grid: { left: 2, right: 74, bottom: 6, top: 26, containLabel: true },
dataZoom: [
{
type: 'inside',
yAxisIndex: 0,
startValue: 0,
endValue: Math.min(19, items.length - 1),
zoomOnMouseWheel: false,
moveOnMouseMove: true,
moveOnMouseWheel: true,
},
{
type: 'slider',
yAxisIndex: 0,
right: 2,
width: 14,
borderColor: 'transparent',
fillerColor: 'rgba(150, 150, 150, 0.2)',
handleSize: 0,
showDetail: false,
brushSelect: false,
},
],
xAxis: [
{
type: 'value',
minInterval: 1,
axisLabel: { fontSize: 11 },
splitLine: { lineStyle: { opacity: 0.4 } },
},
{
type: 'value',
position: 'top',
min: 0,
max: 100,
axisLabel: { fontSize: 10, formatter: (v: number) => `${v}%` },
splitLine: { show: false },
axisLine: { show: false },
axisTick: { show: false },
},
],
yAxis: {
type: 'category',
data: items.map((d) => d.name),
inverse: true,
axisTick: { show: false },
axisLabel: {
fontSize: 11,
width: 150,
overflow: 'truncate',
},
},
series: [
{
name: 'Books',
type: 'bar',
xAxisIndex: 0,
data: items.map((d, i) => ({
value: d.count,
itemStyle: { color: barColors[i], borderRadius: [0, 3, 3, 0] },
})),
barMaxWidth: 22,
label: {
show: true,
position: 'right',
fontSize: 10,
formatter: (p: { value: number }) => `${p.value}`,
},
},
{
name: 'Cumulative %',
type: 'line',
xAxisIndex: 1,
data: cumulativePct,
symbol: 'circle',
symbolSize: 4,
lineStyle: { color: mutedColor, width: 1.5 },
itemStyle: { color: mutedColor },
},
],
}
})
</script>
<template>
<ChartCard title="Top 50 Series" :icon="Layers" :color-index="8" :loading :error :empty="!data.items.length">
<VChart :option autoresize style="height: 100%" />
</ChartCard>
</template>
@@ -15,6 +15,16 @@ const totalEvents = computed(() => data.value.reduce((sum, item) => sum + item.e
const isEmpty = computed(() => totalEvents.value === 0)
const lowConfidence = computed(() => totalEvents.value > 0 && totalEvents.value < MIN_EVENTS)
function formatDuration(seconds: number): string {
const total = Math.max(0, Math.floor(seconds))
const hours = Math.floor(total / 3600)
const mins = Math.floor((total % 3600) / 60)
const secs = total % 60
if (hours > 0) return `${hours}h ${mins}m ${secs}s`
if (mins > 0) return `${mins}m ${secs}s`
return `${secs}s`
}
watchEffect(() => {
option.value = {}
if (isEmpty.value || lowConfidence.value || !data.value.length) return
@@ -26,9 +36,9 @@ watchEffect(() => {
const point = params[0]
if (!point) return ''
const events = data.value[point.dataIndex]?.eventsCount ?? 0
const minuteLabel = point.data === 1 ? 'minute' : 'minutes'
const readingSeconds = data.value[point.dataIndex]?.readingSeconds ?? 0
const eventLabel = events === 1 ? 'event' : 'events'
return `${point.axisValue}<br/><strong>${point.data}</strong> ${minuteLabel}<br/>${events} ${eventLabel}`
return `${point.axisValue}<br/><strong>${formatDuration(readingSeconds)}</strong><br/>${events} ${eventLabel}`
},
},
grid: { left: '3%', right: '3%', bottom: '8%', top: '8%', containLabel: true },
@@ -48,7 +58,7 @@ watchEffect(() => {
series: [
{
type: 'bar',
data: data.value.map((item) => Math.round(item.readingSeconds / 60)),
data: data.value.map((item) => item.readingSeconds / 60),
barMaxWidth: 24,
itemStyle: { borderRadius: [4, 4, 0, 0] },
},
@@ -26,6 +26,16 @@ const totalEvents = computed(() => data.value.reduce((s, d) => s + d.eventsCount
const isEmpty = computed(() => totalEvents.value === 0)
const lowConfidence = computed(() => totalEvents.value > 0 && totalEvents.value < MIN_EVENTS)
function formatDuration(seconds: number): string {
const total = Math.max(0, Math.floor(seconds))
const hours = Math.floor(total / 3600)
const mins = Math.floor((total % 3600) / 60)
const secs = total % 60
if (hours > 0) return `${hours}h ${mins}m ${secs}s`
if (mins > 0) return `${mins}m ${secs}s`
return `${secs}s`
}
const peakHour = computed(() => {
if (!data.value.length) return null
return data.value.reduce((best, d) => (d.readingSeconds > best.readingSeconds ? d : best))
@@ -42,7 +52,7 @@ watchEffect(() => {
const series = formats.map((fmt, i) => ({
type: 'bar',
name: fmt,
data: data.value.map((d) => Math.round((d.byFormat[fmt] ?? 0) / 60)),
data: data.value.map((d) => (d.byFormat[fmt] ?? 0) / 60),
coordinateSystem: 'polar',
stack: 'clock',
itemStyle: { color: palette[i % palette.length] },
@@ -75,28 +85,28 @@ watchEffect(() => {
const idx = params[0]!.dataIndex
const from = HOUR_LABELS[idx]
const to = HOUR_LABELS[(idx + 1) % 24]
const total = data.value[idx]?.readingSeconds ?? 0
const totalSeconds = data.value[idx]?.readingSeconds ?? 0
const events = data.value[idx]?.eventsCount ?? 0
const eventLabel = events === 1 ? 'session' : 'sessions'
const fmtTime = (mins: number) => (mins >= 60 ? `${Math.floor(mins / 60)}h ${mins % 60}m` : `${mins}m`)
const totalMins = Math.round(total / 60)
const active = params.filter((p) => p.data > 0)
const formatRows =
active.length > 1
? `<div style="border-top:1px solid rgba(128,128,128,0.2);margin-top:5px;padding-top:5px">${active
.map(
(p) =>
.map((p) => {
const formatSeconds = data.value[idx]?.byFormat[p.seriesName] ?? 0
return (
`<div style="display:flex;align-items:center;gap:6px;margin-top:2px">` +
`<span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:${p.color};flex-shrink:0"></span>` +
`<span style="color:rgba(180,180,180,0.9)">${p.seriesName}</span>` +
`<span style="margin-left:auto;padding-left:12px">${fmtTime(p.data)}</span>` +
`</div>`,
)
`<span style="margin-left:auto;padding-left:12px">${formatDuration(formatSeconds)}</span>` +
`</div>`
)
})
.join('')}</div>`
: ''
return (
`<div style="font-size:12px;line-height:1.5">` +
`<div><span style="font-weight:600">${from} – ${to}</span> <span style="color:rgba(180,180,180,0.9);margin-left:4px">${fmtTime(totalMins)} · ${events} ${eventLabel}</span></div>` +
`<div><span style="font-weight:600">${from} – ${to}</span> <span style="color:rgba(180,180,180,0.9);margin-left:4px">${formatDuration(totalSeconds)} · ${events} ${eventLabel}</span></div>` +
formatRows +
`</div>`
)
@@ -0,0 +1,13 @@
import type { AcquisitionLagPoint, StatisticsResult } from '@projectx/types'
import { fetchAcquisitionLagScatter } from '../api/statistics.api'
import { useStatisticsQuery } from './useStatisticsQuery'
const EMPTY: StatisticsResult<AcquisitionLagPoint> = { items: [], unknownCount: 0 }
export function useAcquisitionLagScatter() {
return useStatisticsQuery({
initialData: EMPTY,
fetcher: fetchAcquisitionLagScatter,
})
}
@@ -1,13 +0,0 @@
import type { GenreRankOverTimeItem, StatisticsResult } from '@projectx/types'
import { fetchGenreRankOverTime } from '../api/statistics.api'
import { useStatisticsQuery } from './useStatisticsQuery'
const EMPTY: StatisticsResult<GenreRankOverTimeItem> = { items: [], unknownCount: 0 }
export function useGenreRankOverTime() {
return useStatisticsQuery({
initialData: EMPTY,
fetcher: fetchGenreRankOverTime,
})
}
@@ -0,0 +1,13 @@
import type { LargestBookItem, StatisticsResult } from '@projectx/types'
import { fetchLargestBooks } from '../api/statistics.api'
import { useStatisticsQuery } from './useStatisticsQuery'
const EMPTY: StatisticsResult<LargestBookItem> = { items: [], unknownCount: 0 }
export function useLargestBooks() {
return useStatisticsQuery({
initialData: EMPTY,
fetcher: fetchLargestBooks,
})
}
@@ -0,0 +1,19 @@
import type { LibraryIntegrityGauge } from '@projectx/types'
import { fetchLibraryIntegrityGauge } from '../api/statistics.api'
import { useStatisticsQuery } from './useStatisticsQuery'
const EMPTY: LibraryIntegrityGauge = {
totalBooks: 0,
presentCount: 0,
primaryFileCount: 0,
metadataCount: 0,
integrityScore: 0,
}
export function useLibraryIntegrityGauge() {
return useStatisticsQuery({
initialData: EMPTY,
fetcher: fetchLibraryIntegrityGauge,
})
}
@@ -0,0 +1,21 @@
import type { MetadataFreshnessGauge } from '@projectx/types'
import { fetchMetadataFreshnessGauge } from '../api/statistics.api'
import { useStatisticsQuery } from './useStatisticsQuery'
const EMPTY: MetadataFreshnessGauge = {
totalBooks: 0,
neverFetchedCount: 0,
fresh30dCount: 0,
stale31To90dCount: 0,
stale91To180dCount: 0,
staleOver180dCount: 0,
freshnessScore: 0,
}
export function useMetadataFreshnessGauge() {
return useStatisticsQuery({
initialData: EMPTY,
fetcher: fetchMetadataFreshnessGauge,
})
}
@@ -0,0 +1,13 @@
import type { PublicationYearPoint, StatisticsResult } from '@projectx/types'
import { fetchPublicationYearTimeline } from '../api/statistics.api'
import { useStatisticsQuery } from './useStatisticsQuery'
const EMPTY: StatisticsResult<PublicationYearPoint> = { items: [], unknownCount: 0 }
export function usePublicationYearTimeline() {
return useStatisticsQuery({
initialData: EMPTY,
fetcher: fetchPublicationYearTimeline,
})
}
@@ -0,0 +1,13 @@
import type { StatisticsResult, TopSeriesItem } from '@projectx/types'
import { fetchTopSeries } from '../api/statistics.api'
import { useStatisticsQuery } from './useStatisticsQuery'
const EMPTY: StatisticsResult<TopSeriesItem> = { items: [], unknownCount: 0 }
export function useTopSeries() {
return useStatisticsQuery({
initialData: EMPTY,
fetcher: fetchTopSeries,
})
}
@@ -11,11 +11,13 @@ import {
GitMerge,
Goal,
Globe,
Gauge,
HardDrive,
Layers,
ListChecks,
PieChart,
Rabbit,
ShieldCheck,
Tag,
TrendingUp,
Users,
@@ -25,7 +27,7 @@ import {
import { DEFAULT_LIBRARY_CHART_ORDER, DEFAULT_USER_CHART_ORDER, type StatisticsChartId } from '@projectx/types'
export type StatisticsChartSize = '1x1' | '2x1' | '2x2' | '1x2' | '3x1'
export type StatisticsChartSize = '1x1' | '2x1' | '2x2' | '1x2' | '3x1' | '4x1'
export type StatisticsChartCategory = 'library' | 'user'
export interface StatisticsChartMetaEntry {
@@ -108,18 +110,48 @@ export const STATISTICS_CHART_META: Record<StatisticsChartId, StatisticsChartMet
size: '2x1',
category: 'library',
},
'genre-rank-over-time': {
label: 'Genre Rank Over Time',
icon: Tag,
size: '2x1',
category: 'library',
},
'page-count-distribution': {
label: 'Page Count Distribution',
icon: BookOpen,
size: '1x1',
category: 'library',
},
'metadata-freshness-gauge': {
label: 'Metadata Freshness',
icon: Gauge,
size: '1x1',
category: 'library',
},
'library-integrity-gauge': {
label: 'Library Integrity',
icon: ShieldCheck,
size: '1x1',
category: 'library',
},
'acquisition-lag-scatter': {
label: 'Acquisition Lag',
icon: CalendarRange,
size: '1x1',
category: 'library',
},
'largest-books': {
label: 'Top 50 Largest Books',
icon: HardDrive,
size: '2x1',
category: 'library',
},
'top-series': {
label: 'Top 50 Series',
icon: Layers,
size: '2x1',
category: 'library',
},
'publication-year-timeline': {
label: 'Publication Year Timeline',
icon: TrendingUp,
size: '4x1',
category: 'library',
},
'reading-heatmap': {
label: 'Reading Heatmap',
icon: Calendar,
+8
View File
@@ -6,6 +6,7 @@ import {
ChordChart,
CustomChart,
FunnelChart,
GaugeChart,
HeatmapChart,
LineChart,
PieChart,
@@ -15,10 +16,13 @@ import {
} from 'echarts/charts'
import {
CalendarComponent,
DataZoomInsideComponent,
DataZoomSliderComponent,
GridComponent,
LegendComponent,
MarkAreaComponent,
MarkLineComponent,
MarkPointComponent,
PolarComponent,
TitleComponent,
TooltipComponent,
@@ -36,6 +40,7 @@ use([
CustomChart,
LineChart,
FunnelChart,
GaugeChart,
HeatmapChart,
BoxplotChart,
SunburstChart,
@@ -46,9 +51,12 @@ use([
LegendComponent,
GridComponent,
CalendarComponent,
DataZoomInsideComponent,
DataZoomSliderComponent,
VisualMapComponent,
MarkLineComponent,
MarkAreaComponent,
MarkPointComponent,
PolarComponent,
])
+66 -21
View File
@@ -10,7 +10,6 @@ export type StatisticsChartId =
| "metadata-score-distribution"
| "library-metadata-completeness"
| "format-share-over-time"
| "genre-rank-over-time"
| "page-count-distribution"
| "reading-heatmap"
| "peak-reading-hours"
@@ -25,7 +24,13 @@ export type StatisticsChartId =
| "reading-clock"
| "reading-session-timeline"
| "session-archetypes"
| "genre-cooccurrence";
| "genre-cooccurrence"
| "metadata-freshness-gauge"
| "library-integrity-gauge"
| "acquisition-lag-scatter"
| "largest-books"
| "top-series"
| "publication-year-timeline";
export type StatisticsGranularity = "monthly" | "yearly";
export type StatisticsDateRange = "last-year" | "last-5-years" | "all-time";
@@ -50,20 +55,25 @@ export interface StatisticsSettings {
}
export const DEFAULT_LIBRARY_CHART_ORDER: StatisticsChartId[] = [
"library-integrity-gauge",
"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",
"metadata-freshness-gauge",
"largest-books",
"genre-distribution",
"genre-rank-over-time",
"format-share-over-time",
"top-authors",
"metadata-completeness",
"acquisition-lag-scatter",
"library-metadata-completeness",
"storage-by-format",
"language-distribution",
"page-count-distribution",
"publication-decade",
"genre-cooccurrence",
"top-series",
"books-added-over-time",
"publication-year-timeline",
];
export const DEFAULT_USER_CHART_ORDER: StatisticsChartId[] = [
@@ -85,8 +95,8 @@ export const DEFAULT_USER_CHART_ORDER: StatisticsChartId[] = [
export const DEFAULT_STATISTICS_CHART_ORDER: StatisticsChartId[] = [...DEFAULT_LIBRARY_CHART_ORDER, ...DEFAULT_USER_CHART_ORDER];
export const DEFAULT_STATISTICS_FILTERS: StatisticsFilterConfig = {
libraryIds: [],
booksOverTimeRange: "all-time",
libraryIds: [3, 4],
booksOverTimeRange: "last-5-years",
booksOverTimeGranularity: "monthly",
};
@@ -94,7 +104,7 @@ export function createDefaultStatisticsSettings(): StatisticsSettings {
return {
charts: DEFAULT_STATISTICS_CHART_ORDER.map((id, order) => ({ id, order, visible: true })),
filters: {
libraryIds: [],
libraryIds: [...DEFAULT_STATISTICS_FILTERS.libraryIds],
booksOverTimeRange: DEFAULT_STATISTICS_FILTERS.booksOverTimeRange,
booksOverTimeGranularity: DEFAULT_STATISTICS_FILTERS.booksOverTimeGranularity,
},
@@ -150,6 +160,12 @@ export interface PublicationDecadeItem {
count: number;
}
export interface PublicationYearPoint {
year: number;
count: number;
topTitles: string[];
}
export interface TopAuthorItem {
name: string;
count: number;
@@ -198,13 +214,6 @@ export interface FormatShareOverTimeItem {
count: number;
}
export interface GenreRankOverTimeItem {
year: number;
genre: string;
rank: number;
count: number;
}
export interface PageCountDistributionItem {
format: string;
count: number;
@@ -215,6 +224,42 @@ export interface PageCountDistributionItem {
max: number;
}
export interface MetadataFreshnessGauge {
totalBooks: number;
neverFetchedCount: number;
fresh30dCount: number;
stale31To90dCount: number;
stale91To180dCount: number;
staleOver180dCount: number;
freshnessScore: number;
}
export interface LibraryIntegrityGauge {
totalBooks: number;
presentCount: number;
primaryFileCount: number;
metadataCount: number;
integrityScore: number;
}
export interface AcquisitionLagPoint {
addedYear: number;
lagYears: number;
count: number;
}
export interface LargestBookItem {
id: number;
title: string;
sizeBytes: number;
format: string;
}
export interface TopSeriesItem {
name: string;
count: number;
}
export interface StatisticsSummary {
totalBooks: number;
totalAuthors: number;
+4
View File
@@ -615,6 +615,10 @@ export class BookService {
detail = await this.updateMetadata(id, dto, user);
}
// Mark successful non-preview provider refreshes so freshness analytics are accurate,
// even when no scalar field changed after reconciliation.
await this.bookRepo.updateMetadataFields(id, { lastMetadataFetchAt: new Date(), updatedAt: new Date() });
let coverDownloaded = false;
if (resolved.coverUrl) {
await this.metadataService.downloadAndSaveCover(resolved.coverUrl, id);
@@ -45,11 +45,6 @@ export class StatisticsController {
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);
@@ -60,6 +55,11 @@ export class StatisticsController {
return this.statisticsService.getPublicationDecade(user, query);
}
@Get('publication-year-timeline')
getPublicationYearTimeline(@CurrentUser() user: RequestUser, @Query() query: StatisticsFilterQueryDto) {
return this.statisticsService.getPublicationYearTimeline(user, query);
}
@Get('top-authors')
getTopAuthors(@CurrentUser() user: RequestUser, @Query() query: StatisticsFilterQueryDto) {
return this.statisticsService.getTopAuthors(user, query);
@@ -84,4 +84,29 @@ export class StatisticsController {
getGenreCooccurrence(@CurrentUser() user: RequestUser, @Query() query: StatisticsFilterQueryDto) {
return this.statisticsService.getGenreCooccurrence(user, query);
}
@Get('metadata-freshness-gauge')
getMetadataFreshnessGauge(@CurrentUser() user: RequestUser, @Query() query: StatisticsFilterQueryDto) {
return this.statisticsService.getMetadataFreshnessGauge(user, query);
}
@Get('library-integrity-gauge')
getLibraryIntegrityGauge(@CurrentUser() user: RequestUser, @Query() query: StatisticsFilterQueryDto) {
return this.statisticsService.getLibraryIntegrityGauge(user, query);
}
@Get('acquisition-lag-scatter')
getAcquisitionLagScatter(@CurrentUser() user: RequestUser, @Query() query: StatisticsFilterQueryDto) {
return this.statisticsService.getAcquisitionLagScatter(user, query);
}
@Get('largest-books')
getLargestBooks(@CurrentUser() user: RequestUser, @Query() query: StatisticsFilterQueryDto) {
return this.statisticsService.getLargestBooks(user, query);
}
@Get('top-series')
getTopSeries(@CurrentUser() user: RequestUser, @Query() query: StatisticsFilterQueryDto) {
return this.statisticsService.getTopSeries(user, query);
}
}
@@ -3,6 +3,7 @@ import { and, desc, eq, gte, inArray, isNotNull, isNull, sql } from 'drizzle-orm
import type { NodePgDatabase } from 'drizzle-orm/node-postgres';
import type { ChordDiagramData, StatisticsDateRange, StatisticsGranularity } from '@projectx/types';
import { DEFAULT_FORMAT_PRIORITY } from '@projectx/types';
import { DB } from '../../db';
import * as schema from '../../db/schema';
@@ -43,7 +44,7 @@ export class StatisticsRepository {
})
.from(bookFiles)
.innerJoin(books, eq(bookFiles.bookId, books.id))
.where(and(isNotNull(bookFiles.format), filter))
.where(and(isNotNull(bookFiles.format), inArray(bookFiles.format, [...DEFAULT_FORMAT_PRIORITY]), filter))
.groupBy(bookFiles.format)
.orderBy(desc(sql<number>`count(distinct ${bookFiles.bookId})`));
}
@@ -211,30 +212,11 @@ export class StatisticsRepository {
})
.from(books)
.innerJoin(bookFiles, eq(bookFiles.id, books.primaryFileId))
.where(and(isNotNull(bookFiles.format), filter))
.where(and(isNotNull(bookFiles.format), inArray(bookFiles.format, [...DEFAULT_FORMAT_PRIORITY]), 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));
@@ -253,7 +235,7 @@ export class StatisticsRepository {
.from(books)
.innerJoin(bookMetadata, eq(bookMetadata.bookId, books.id))
.innerJoin(bookFiles, eq(bookFiles.id, books.primaryFileId))
.where(and(isNotNull(bookMetadata.pageCount), isNotNull(bookFiles.format), filter))
.where(and(isNotNull(bookMetadata.pageCount), isNotNull(bookFiles.format), inArray(bookFiles.format, [...DEFAULT_FORMAT_PRIORITY]), filter))
.groupBy(bookFiles.format)
.orderBy(desc(sql<number>`count(*)`)),
this.db
@@ -278,11 +260,63 @@ export class StatisticsRepository {
})
.from(bookFiles)
.innerJoin(books, eq(bookFiles.bookId, books.id))
.where(and(isNotNull(bookFiles.format), isNotNull(bookFiles.sizeBytes), filter))
.where(and(isNotNull(bookFiles.format), isNotNull(bookFiles.sizeBytes), inArray(bookFiles.format, [...DEFAULT_FORMAT_PRIORITY]), filter))
.groupBy(bookFiles.format)
.orderBy(desc(sql<number>`sum(${bookFiles.sizeBytes})`));
}
async publicationYearTimeline(userId: number, isSuperuser: boolean, filterLibraryIds?: number[]) {
const accessible = await this.getAccessibleLibraryIds(userId, isSuperuser);
const filter = this.libraryFilter(this.intersectLibraryIds(accessible, filterLibraryIds));
const [counts, titleRows, [{ unknownCount }]] = await Promise.all([
this.db
.select({
year: bookMetadata.publishedYear,
count: sql<number>`count(*)::int`,
})
.from(books)
.innerJoin(bookMetadata, eq(bookMetadata.bookId, books.id))
.where(and(isNotNull(bookMetadata.publishedYear), filter))
.groupBy(bookMetadata.publishedYear)
.orderBy(bookMetadata.publishedYear),
this.db
.select({
year: bookMetadata.publishedYear,
title: bookMetadata.title,
rn: sql<number>`row_number() over (partition by ${bookMetadata.publishedYear} order by ${bookMetadata.bookId})`,
})
.from(books)
.innerJoin(bookMetadata, eq(bookMetadata.bookId, books.id))
.where(and(isNotNull(bookMetadata.publishedYear), isNotNull(bookMetadata.title), filter))
.orderBy(bookMetadata.publishedYear),
this.db
.select({ unknownCount: sql<number>`count(*)::int` })
.from(books)
.leftJoin(bookMetadata, eq(bookMetadata.bookId, books.id))
.where(and(isNull(bookMetadata.publishedYear), filter)),
]);
const titlesMap = new Map<number, string[]>();
for (const row of titleRows) {
if (row.rn > 3 || !row.year || !row.title) continue;
const arr = titlesMap.get(row.year) ?? [];
arr.push(row.title);
titlesMap.set(row.year, arr);
}
return {
items: counts.map((r) => ({
year: r.year!,
count: r.count,
topTitles: titlesMap.get(r.year!) ?? [],
})),
unknownCount,
};
}
async publicationDecade(userId: number, isSuperuser: boolean, filterLibraryIds?: number[]) {
const accessible = await this.getAccessibleLibraryIds(userId, isSuperuser);
const filter = this.libraryFilter(this.intersectLibraryIds(accessible, filterLibraryIds));
@@ -348,7 +382,16 @@ export class StatisticsRepository {
.leftJoin(sql`(select distinct book_id from book_authors) ba`, sql`ba.book_id = ${books.id}`)
.where(filter);
return row;
return (
row ?? {
totalBooks: 0,
neverFetchedCount: 0,
fresh30dCount: 0,
stale31To90dCount: 0,
stale91To180dCount: 0,
staleOver180dCount: 0,
}
);
}
async genreDistribution(userId: number, isSuperuser: boolean, filterLibraryIds?: number[]) {
@@ -476,7 +519,7 @@ export class StatisticsRepository {
inner join book_genres bg2 on bg1.book_id = bg2.book_id and bg1.genre_id < bg2.genre_id
inner join genres g1 on g1.id = bg1.genre_id
inner join genres g2 on g2.id = bg2.genre_id
inner join books b on b.id = bg1.book_id
inner join books on books.id = bg1.book_id
where bg1.genre_id in (${topGenreIdList})
and bg2.genre_id in (${topGenreIdList})
${filter ? sql`and ${filter}` : sql``}
@@ -490,4 +533,136 @@ export class StatisticsRepository {
links: pairRows.rows.map((r) => ({ source: r.source, target: r.target, value: r.value })),
};
}
async metadataFreshnessGauge(userId: number, isSuperuser: boolean, filterLibraryIds?: number[]) {
const accessible = await this.getAccessibleLibraryIds(userId, isSuperuser);
const filter = this.libraryFilter(this.intersectLibraryIds(accessible, filterLibraryIds));
const hasProviderIdExpr = sql`(
${bookMetadata.googleBooksId} is not null or
${bookMetadata.goodreadsId} is not null or
${bookMetadata.amazonId} is not null or
${bookMetadata.hardcoverId} is not null or
${bookMetadata.openLibraryId} is not null or
${bookMetadata.itunesId} is not null or
${bookMetadata.audibleId} is not null or
${bookMetadata.comicvineId} is not null
)`;
const [row] = await this.db
.select({
totalBooks: sql<number>`count(*)::int`,
neverFetchedCount: sql<number>`count(case when ${bookMetadata.lastMetadataFetchAt} is null and not ${hasProviderIdExpr} then 1 end)::int`,
fresh30dCount: sql<number>`count(case when ${bookMetadata.lastMetadataFetchAt} >= now() - interval '30 days' then 1 end)::int`,
stale31To90dCount: sql<number>`count(case when ${bookMetadata.lastMetadataFetchAt} < now() - interval '30 days' and ${bookMetadata.lastMetadataFetchAt} >= now() - interval '90 days' then 1 end)::int`,
stale91To180dCount: sql<number>`count(case when ${bookMetadata.lastMetadataFetchAt} < now() - interval '90 days' and ${bookMetadata.lastMetadataFetchAt} >= now() - interval '180 days' then 1 end)::int`,
staleOver180dCount: sql<number>`count(case when ${bookMetadata.lastMetadataFetchAt} < now() - interval '180 days' or (${bookMetadata.lastMetadataFetchAt} is null and ${hasProviderIdExpr}) then 1 end)::int`,
})
.from(books)
.leftJoin(bookMetadata, eq(bookMetadata.bookId, books.id))
.where(filter);
return (
row ?? {
totalBooks: 0,
neverFetchedCount: 0,
fresh30dCount: 0,
stale31To90dCount: 0,
stale91To180dCount: 0,
staleOver180dCount: 0,
}
);
}
async libraryIntegrityGauge(userId: number, isSuperuser: boolean, filterLibraryIds?: number[]) {
const accessible = await this.getAccessibleLibraryIds(userId, isSuperuser);
const filter = this.libraryFilter(this.intersectLibraryIds(accessible, filterLibraryIds));
const [row] = await this.db
.select({
totalBooks: sql<number>`count(*)::int`,
presentCount: sql<number>`count(case when ${books.status} = 'present' then 1 end)::int`,
primaryFileCount: sql<number>`count(case when ${books.primaryFileId} is not null then 1 end)::int`,
metadataCount: sql<number>`count(case when ${bookMetadata.bookId} is not null then 1 end)::int`,
})
.from(books)
.leftJoin(bookMetadata, eq(bookMetadata.bookId, books.id))
.where(filter);
return (
row ?? {
totalBooks: 0,
presentCount: 0,
primaryFileCount: 0,
metadataCount: 0,
}
);
}
async acquisitionLagScatter(userId: number, isSuperuser: boolean, filterLibraryIds?: number[]) {
const accessible = await this.getAccessibleLibraryIds(userId, isSuperuser);
const filter = this.libraryFilter(this.intersectLibraryIds(accessible, filterLibraryIds));
const addedYearExpr = sql`extract(year from ${books.addedAt})::int`;
const rawLagExpr = sql`${addedYearExpr} - ${bookMetadata.publishedYear}`;
const lagBucketExpr = sql`greatest(-5, least(120, ${rawLagExpr}))::int`;
const [items, [unknown]] = await Promise.all([
this.db
.select({
addedYear: sql<number>`${addedYearExpr}`,
lagYears: sql<number>`${lagBucketExpr}`,
count: sql<number>`count(*)::int`,
})
.from(books)
.innerJoin(bookMetadata, eq(bookMetadata.bookId, books.id))
.where(and(isNotNull(bookMetadata.publishedYear), filter))
.groupBy(addedYearExpr, lagBucketExpr)
.orderBy(addedYearExpr, lagBucketExpr),
this.db
.select({
unknownCount: sql<number>`count(*)::int`,
})
.from(books)
.leftJoin(bookMetadata, eq(bookMetadata.bookId, books.id))
.where(and(isNull(bookMetadata.publishedYear), filter)),
]);
return { items, unknownCount: unknown?.unknownCount ?? 0 };
}
async largestBooks(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({
id: books.id,
title: bookMetadata.title,
sizeBytes: sql<number>`${bookFiles.sizeBytes}::bigint`,
format: bookFiles.format,
})
.from(books)
.innerJoin(bookFiles, eq(bookFiles.id, books.primaryFileId))
.innerJoin(bookMetadata, eq(bookMetadata.bookId, books.id))
.where(and(isNotNull(bookFiles.sizeBytes), filter))
.orderBy(desc(bookFiles.sizeBytes))
.limit(50);
}
async topSeries(userId: number, isSuperuser: boolean, filterLibraryIds?: number[]) {
const accessible = await this.getAccessibleLibraryIds(userId, isSuperuser);
const filter = this.libraryFilter(this.intersectLibraryIds(accessible, filterLibraryIds));
return this.db
.select({
name: bookMetadata.seriesName,
count: sql<number>`count(*)::int`,
})
.from(books)
.innerJoin(bookMetadata, eq(bookMetadata.bookId, books.id))
.where(and(isNotNull(bookMetadata.seriesName), filter))
.groupBy(bookMetadata.seriesName)
.orderBy(desc(sql`count(*)`))
.limit(50);
}
}
@@ -1,22 +1,27 @@
import { Injectable } from '@nestjs/common';
import type {
AcquisitionLagPoint,
BooksAddedDataPoint,
ChordDiagramData,
FormatShareOverTimeItem,
LibraryIntegrityGauge,
MetadataFreshnessGauge,
StatisticsSummary,
GenreRankOverTimeItem,
FormatDistributionItem,
GenreDistributionItem,
LibraryMetadataCompletenessItem,
LanguageDistributionItem,
LargestBookItem,
MetadataScoreDistribution,
MetadataCompletenessItem,
PageCountDistributionItem,
PublicationDecadeItem,
PublicationYearPoint,
StatisticsResult,
StorageByFormatItem,
TopAuthorItem,
TopSeriesItem,
} from '@projectx/types';
import type { RequestUser } from '../../common/types/request-user';
@@ -26,7 +31,6 @@ import { StatisticsRepository } from './statistics.repository';
const STATISTICS_TOP_N = 10;
const STREAM_TOP_FORMATS = 8;
const BUMP_TOP_GENRES = 8;
@Injectable()
export class StatisticsService {
@@ -144,43 +148,6 @@ export class StatisticsService {
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) => ({
@@ -206,6 +173,11 @@ export class StatisticsService {
return { items, unknownCount };
}
async getPublicationYearTimeline(user: RequestUser, query: StatisticsFilterQueryDto): Promise<StatisticsResult<PublicationYearPoint>> {
const { items, unknownCount } = await this.repo.publicationYearTimeline(user.id, user.isSuperuser, query.libraryIds);
return { items, unknownCount };
}
async getTopAuthors(user: RequestUser, query: StatisticsFilterQueryDto): Promise<StatisticsResult<TopAuthorItem>> {
const raw = await this.repo.topAuthors(user.id, user.isSuperuser, query.libraryIds);
const items = raw.slice(0, 25).map((r) => ({ name: r.name, count: r.count }));
@@ -239,6 +211,55 @@ export class StatisticsService {
return { items, unknownCount };
}
async getMetadataFreshnessGauge(user: RequestUser, query: StatisticsFilterQueryDto): Promise<MetadataFreshnessGauge> {
const row = await this.repo.metadataFreshnessGauge(user.id, user.isSuperuser, query.libraryIds);
const totalBooks = row.totalBooks ?? 0;
const fresh30dCount = row.fresh30dCount ?? 0;
const stale31To90dCount = row.stale31To90dCount ?? 0;
const stale91To180dCount = row.stale91To180dCount ?? 0;
const staleOver180dCount = row.staleOver180dCount ?? 0;
const neverFetchedCount = row.neverFetchedCount ?? 0;
const weightedFreshness = fresh30dCount + stale31To90dCount * 0.7 + stale91To180dCount * 0.4 + staleOver180dCount * 0.15;
const freshnessScore = totalBooks > 0 ? Math.round((weightedFreshness / totalBooks) * 100) : 0;
return {
totalBooks,
neverFetchedCount,
fresh30dCount,
stale31To90dCount,
stale91To180dCount,
staleOver180dCount,
freshnessScore,
};
}
async getLibraryIntegrityGauge(user: RequestUser, query: StatisticsFilterQueryDto): Promise<LibraryIntegrityGauge> {
const row = await this.repo.libraryIntegrityGauge(user.id, user.isSuperuser, query.libraryIds);
const totalBooks = row.totalBooks ?? 0;
const presentCount = row.presentCount ?? 0;
const primaryFileCount = row.primaryFileCount ?? 0;
const metadataCount = row.metadataCount ?? 0;
const presentRatio = totalBooks > 0 ? presentCount / totalBooks : 0;
const primaryFileRatio = totalBooks > 0 ? primaryFileCount / totalBooks : 0;
const metadataRatio = totalBooks > 0 ? metadataCount / totalBooks : 0;
const integrityScore = Math.round(((presentRatio + primaryFileRatio + metadataRatio) / 3) * 100);
return {
totalBooks,
presentCount,
primaryFileCount,
metadataCount,
integrityScore,
};
}
async getAcquisitionLagScatter(user: RequestUser, query: StatisticsFilterQueryDto): Promise<StatisticsResult<AcquisitionLagPoint>> {
const { items, unknownCount } = await this.repo.acquisitionLagScatter(user.id, user.isSuperuser, query.libraryIds);
return { items, unknownCount };
}
private clipToTopN<T extends { count: number }>(items: T[], labelKey: keyof T, n = STATISTICS_TOP_N): T[] {
if (items.length <= n) return items;
const top = items.slice(0, n);
@@ -260,4 +281,16 @@ export class StatisticsService {
async getGenreCooccurrence(user: RequestUser, query: StatisticsFilterQueryDto): Promise<ChordDiagramData> {
return this.repo.getGenreCooccurrence(user.id, user.isSuperuser, query.libraryIds);
}
async getLargestBooks(user: RequestUser, query: StatisticsFilterQueryDto): Promise<StatisticsResult<LargestBookItem>> {
const raw = await this.repo.largestBooks(user.id, user.isSuperuser, query.libraryIds);
const items = raw.map((r) => ({ id: r.id, title: r.title!, sizeBytes: Number(r.sizeBytes), format: r.format! }));
return { items, unknownCount: 0 };
}
async getTopSeries(user: RequestUser, query: StatisticsFilterQueryDto): Promise<StatisticsResult<TopSeriesItem>> {
const raw = await this.repo.topSeries(user.id, user.isSuperuser, query.libraryIds);
const items = raw.map((r) => ({ name: r.name!, count: r.count }));
return { items, unknownCount: 0 };
}
}