mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -06:00
feat(stats): add top series and library insight charts with backend
This commit is contained in:
@@ -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 ${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 ${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 ${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,
|
||||
|
||||
@@ -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,
|
||||
])
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user