feat(stats): introduce statistics with charts, KPI summary, and filter

This commit is contained in:
neon
2026-03-17 23:38:29 -06:00
parent 3c27802be2
commit e61486fae9
40 changed files with 2168 additions and 12 deletions
+3 -1
View File
@@ -29,6 +29,7 @@
"@vueuse/core": "^14.2.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"echarts": "^6.0.0",
"lucide-vue-next": "^0.577.0",
"pdfjs-dist": "^5.5.207",
"pinia": "^3.0.4",
@@ -38,6 +39,7 @@
"tailwindcss": "^4.2.1",
"vue": "^3.5.30",
"vue-draggable-plus": "^0.6.1",
"vue-echarts": "^8.0.1",
"vue-router": "^5.0.3",
"vue-sonner": "^2.0.9",
"vue-virtual-scroller": "^1.1.2"
@@ -66,7 +68,7 @@
"vite": "^8.0.0",
"vite-plugin-vue-devtools": "^8.1.0",
"vitest": "^4.1.0",
"vue-tsc": "^3.2.5"
"vue-tsc": "^3.2.6"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
+22 -1
View File
@@ -1,12 +1,33 @@
<script setup lang="ts">
import { computed, onMounted, provide, ref, watch } from 'vue'
import { INIT_OPTIONS_KEY, THEME_KEY } from 'vue-echarts'
import { useThemeStore } from '@/stores/theme'
import { useChangePasswordDialog } from '@/composables/useChangePasswordDialog'
import { initChartThemes } from '@/lib/echarts'
import ChangePasswordDialog from '@/features/auth/ChangePasswordDialog.vue'
import { Toaster } from '@/components/ui/sonner'
import { TooltipProvider } from '@/components/ui/tooltip'
useThemeStore()
const themeStore = useThemeStore()
const { isOpen } = useChangePasswordDialog()
const themeVersion = ref(0)
provide(INIT_OPTIONS_KEY, { renderer: 'svg' })
provide(
THEME_KEY,
computed(() => `projectx-v${themeVersion.value}`),
)
onMounted(() => {
initChartThemes(0)
})
watch([() => themeStore.theme, () => themeStore.accent], () => {
const v = themeVersion.value + 1
initChartThemes(v)
themeVersion.value = v
})
</script>
<template>
+37 -10
View File
@@ -52,11 +52,25 @@
--sidebar-border: oklch(0.878 var(--tint-c-surface) var(--tint-h));
--sidebar-ring: oklch(0.65 var(--tint-c-surface) var(--tint-h));
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--chart-1: oklch(0.62 0.16 var(--tint-h));
--chart-2: oklch(0.6 0.15 calc(var(--tint-h) + 72));
--chart-3: oklch(0.58 0.14 calc(var(--tint-h) + 144));
--chart-4: oklch(0.64 0.15 calc(var(--tint-h) + 216));
--chart-5: oklch(0.62 0.16 calc(var(--tint-h) + 288));
--chart-6: oklch(0.44 0.14 calc(var(--tint-h) + 36));
--chart-7: oklch(0.42 0.13 calc(var(--tint-h) + 108));
--chart-8: oklch(0.4 0.12 calc(var(--tint-h) + 180));
--chart-9: oklch(0.46 0.13 calc(var(--tint-h) + 252));
--chart-10: oklch(0.43 0.14 calc(var(--tint-h) + 324));
--chart-icon-1: oklch(0.58 0.17 var(--tint-h));
--chart-icon-2: oklch(0.58 0.17 calc(var(--tint-h) + 45));
--chart-icon-3: oklch(0.58 0.17 calc(var(--tint-h) + 90));
--chart-icon-4: oklch(0.58 0.17 calc(var(--tint-h) + 135));
--chart-icon-5: oklch(0.58 0.17 calc(var(--tint-h) + 180));
--chart-icon-6: oklch(0.58 0.17 calc(var(--tint-h) + 225));
--chart-icon-7: oklch(0.58 0.17 calc(var(--tint-h) + 270));
--chart-icon-8: oklch(0.58 0.17 calc(var(--tint-h) + 315));
--chart-icon-9: oklch(0.58 0.17 calc(var(--tint-h) + 337));
/* Elevation surfaces */
--surface-1: oklch(0.975 var(--tint-c-surface) var(--tint-h));
@@ -120,11 +134,24 @@
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.52 0.008 var(--tint-h));
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--chart-1: oklch(0.7 0.16 var(--tint-h));
--chart-2: oklch(0.68 0.15 calc(var(--tint-h) + 72));
--chart-3: oklch(0.66 0.14 calc(var(--tint-h) + 144));
--chart-4: oklch(0.72 0.15 calc(var(--tint-h) + 216));
--chart-5: oklch(0.7 0.16 calc(var(--tint-h) + 288));
--chart-6: oklch(0.52 0.14 calc(var(--tint-h) + 36));
--chart-7: oklch(0.5 0.13 calc(var(--tint-h) + 108));
--chart-8: oklch(0.48 0.12 calc(var(--tint-h) + 180));
--chart-9: oklch(0.54 0.13 calc(var(--tint-h) + 252));
--chart-10: oklch(0.51 0.14 calc(var(--tint-h) + 324));
--chart-icon-1: oklch(0.7 0.17 var(--tint-h));
--chart-icon-2: oklch(0.7 0.17 calc(var(--tint-h) + 45));
--chart-icon-3: oklch(0.7 0.17 calc(var(--tint-h) + 90));
--chart-icon-4: oklch(0.7 0.17 calc(var(--tint-h) + 135));
--chart-icon-5: oklch(0.7 0.17 calc(var(--tint-h) + 180));
--chart-icon-6: oklch(0.7 0.17 calc(var(--tint-h) + 225));
--chart-icon-7: oklch(0.7 0.17 calc(var(--tint-h) + 270));
--chart-icon-8: oklch(0.7 0.17 calc(var(--tint-h) + 315));
/* Elevation surfaces */
--surface-1: oklch(calc(0.205 + var(--bg-lift)) var(--tint-c-surface) var(--tint-h));
+8
View File
@@ -86,6 +86,7 @@ const {
const isDashboardActive = computed(() => route.name === 'dashboard')
const isStagingActive = computed(() => route.name === 'staging')
const isStatisticsActive = computed(() => route.name === 'statistics')
const isAuthorsActive = computed(() => route.name === 'authors' || route.name === 'author-detail')
const activeLibraryId = computed(() => {
@@ -222,6 +223,13 @@ onUnmounted(() => stopUploadCompletedListener())
label="Authors"
@click="router.push({ name: 'authors' })"
/>
<SidebarNavItem
:is-active="isStatisticsActive"
tooltip="Statistics"
:icon="Icons.BarChart3"
label="Statistics"
@click="router.push({ name: 'statistics' })"
/>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
@@ -0,0 +1,72 @@
import { api } from '@/lib/api'
import type {
BooksAddedDataPoint,
StatisticsSummary,
FormatDistributionItem,
GenreDistributionItem,
LanguageDistributionItem,
MetadataCompletenessItem,
PublicationDecadeItem,
StatisticsFilterConfig,
StatisticsResult,
StorageByFormatItem,
TopAuthorItem,
} from '@projectx/types'
async function parseResult<T>(res: Response): Promise<StatisticsResult<T>> {
if (!res.ok) throw new Error(`Statistics request failed: ${res.status}`)
return res.json() as Promise<StatisticsResult<T>>
}
function buildParams(filters: StatisticsFilterConfig, extra?: Record<string, string>): string {
const params = new URLSearchParams()
filters.libraryIds.forEach((id) => params.append('libraryIds', String(id)))
if (extra) Object.entries(extra).forEach(([k, v]) => params.set(k, v))
const str = params.toString()
return str ? `?${str}` : ''
}
export async function fetchFormatDistribution(filters: StatisticsFilterConfig): Promise<StatisticsResult<FormatDistributionItem>> {
return parseResult(await api(`/api/v1/statistics/format-distribution${buildParams(filters)}`))
}
export async function fetchLanguageDistribution(filters: StatisticsFilterConfig): Promise<StatisticsResult<LanguageDistributionItem>> {
return parseResult(await api(`/api/v1/statistics/language-distribution${buildParams(filters)}`))
}
export async function fetchBooksAddedOverTime(filters: StatisticsFilterConfig): Promise<StatisticsResult<BooksAddedDataPoint>> {
return parseResult(
await api(
`/api/v1/statistics/books-added-over-time${buildParams(filters, {
granularity: filters.booksOverTimeGranularity,
range: filters.booksOverTimeRange,
})}`,
),
)
}
export async function fetchStorageByFormat(filters: StatisticsFilterConfig): Promise<StatisticsResult<StorageByFormatItem>> {
return parseResult(await api(`/api/v1/statistics/storage-by-format${buildParams(filters)}`))
}
export async function fetchPublicationDecade(filters: StatisticsFilterConfig): Promise<StatisticsResult<PublicationDecadeItem>> {
return parseResult(await api(`/api/v1/statistics/publication-decade${buildParams(filters)}`))
}
export async function fetchTopAuthors(filters: StatisticsFilterConfig): Promise<StatisticsResult<TopAuthorItem>> {
return parseResult(await api(`/api/v1/statistics/top-authors${buildParams(filters)}`))
}
export async function fetchMetadataCompleteness(filters: StatisticsFilterConfig): Promise<StatisticsResult<MetadataCompletenessItem>> {
return parseResult(await api(`/api/v1/statistics/metadata-completeness${buildParams(filters)}`))
}
export async function fetchGenreDistribution(filters: StatisticsFilterConfig): Promise<StatisticsResult<GenreDistributionItem>> {
return parseResult(await api(`/api/v1/statistics/genre-distribution${buildParams(filters)}`))
}
export async function fetchStatisticsSummary(filters: StatisticsFilterConfig): Promise<StatisticsSummary> {
const res = await api(`/api/v1/statistics/summary${buildParams(filters)}`)
if (!res.ok) throw new Error(`Statistics summary request failed: ${res.status}`)
return res.json() as Promise<StatisticsSummary>
}
@@ -0,0 +1,133 @@
<script setup lang="ts">
import { shallowRef, watchEffect } from 'vue'
import VChart from 'vue-echarts'
import { TrendingUp } from 'lucide-vue-next'
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
import { useBooksAddedOverTime } from '../composables/useBooksAddedOverTime'
import { useStatisticsConfig } from '../composables/useStatisticsConfig'
import ChartCard from './ChartCard.vue'
const MONTH_NAMES = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
const DATE_RANGE_LABELS = {
'all-time': 'All Time',
'last-5-years': 'Last 5 Years',
'last-year': 'Last Year',
} as const
const { data, loading, error } = useBooksAddedOverTime()
const { filters, setGranularity, setDateRange } = useStatisticsConfig()
const option = shallowRef({})
watchEffect(() => {
if (!data.value.items.length) return
const isYearly = filters.value.booksOverTimeGranularity === 'yearly'
const labels = data.value.items.map((d) => (isYearly ? String(d.year) : `${MONTH_NAMES[d.month - 1]} ${d.year}`))
option.value = {
tooltip: {
trigger: 'axis',
confine: true,
enterable: false,
formatter: (params: { name: string; value: number }[]) => {
const p = params[0]
if (!p) return ''
return `${p.name}: <strong>${p.value}</strong> books`
},
},
grid: { left: '3%', right: '4%', bottom: '12%', top: '8%', containLabel: true },
xAxis: {
type: 'category',
data: labels,
axisTick: { show: false },
axisLabel: {
fontSize: 11,
rotate: 45,
interval: Math.max(0, Math.floor(data.value.items.length / 8) - 1),
},
},
yAxis: {
type: 'value',
minInterval: 1,
axisLabel: { fontSize: 11 },
},
series: [
{
type: 'bar',
data: data.value.items.map((d) => d.count),
cursor: 'default',
itemStyle: { borderRadius: [3, 3, 0, 0] },
barMaxWidth: 40,
emphasis: { disabled: true },
},
],
}
})
function handleMonthly() {
setGranularity('monthly')
}
function handleYearly() {
setGranularity('yearly')
}
function handleAllTime() {
setDateRange('all-time')
}
function handleLast5Years() {
setDateRange('last-5-years')
}
function handleLastYear() {
setDateRange('last-year')
}
</script>
<template>
<ChartCard title="Books Added Over Time" :icon="TrendingUp" :color-index="3" :loading :error :empty="!data.items.length">
<template #controls>
<div class="flex items-center gap-1">
<div class="border-border flex rounded-md border text-xs">
<button
:class="[
'rounded-l-md px-2 py-1 transition-colors',
filters.booksOverTimeGranularity === 'monthly' ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:text-foreground',
]"
@click="handleMonthly"
>
Monthly
</button>
<button
:class="[
'rounded-r-md px-2 py-1 transition-colors',
filters.booksOverTimeGranularity === 'yearly' ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:text-foreground',
]"
@click="handleYearly"
>
Yearly
</button>
</div>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<button class="border-border text-muted-foreground hover:text-foreground rounded-md border px-2 py-1 text-xs transition-colors">
{{ DATE_RANGE_LABELS[filters.booksOverTimeRange] }}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" class="text-xs">
<DropdownMenuItem @click="handleAllTime">All Time</DropdownMenuItem>
<DropdownMenuItem @click="handleLast5Years">Last 5 Years</DropdownMenuItem>
<DropdownMenuItem @click="handleLastYear">Last Year</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</template>
<VChart :option autoresize style="height: 100%" />
</ChartCard>
</template>
@@ -0,0 +1,72 @@
<script setup lang="ts">
import type { Component } from 'vue'
import { AlertCircle, GripVertical } from 'lucide-vue-next'
import { Skeleton } from '@/components/ui/skeleton'
defineProps<{
title: string
icon: Component
colorIndex: number
loading: boolean
empty: boolean
unknownCount?: number
error?: boolean
tall?: boolean
short?: boolean
}>()
</script>
<template>
<div
:class="[
'bg-card text-card-foreground flex flex-col overflow-hidden rounded-xl border shadow-sm transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md',
tall ? 'h-[560px] md:h-[640px]' : short ? 'h-[240px] md:h-[280px]' : 'h-[320px] md:h-[360px]',
]"
>
<div class="flex min-h-0 flex-1 flex-col p-4">
<div class="mb-3 flex items-center justify-between gap-2 border-b pb-3">
<div class="flex items-center gap-2.5">
<div
class="shrink-0 rounded-md p-2"
:style="{
backgroundColor: `color-mix(in oklch, var(--chart-icon-${colorIndex}) 15%, transparent)`,
color: `var(--chart-icon-${colorIndex})`,
}"
>
<component :is="icon" class="size-4" />
</div>
<p class="text-foreground text-sm font-semibold">{{ title }}</p>
</div>
<div class="flex items-center gap-2">
<slot name="controls" />
<GripVertical class="drag-handle text-muted-foreground/50 hover:text-muted-foreground size-4 cursor-grab active:cursor-grabbing" />
</div>
</div>
<div class="min-h-0 flex-1">
<div v-if="loading" class="flex h-full flex-col gap-2">
<Skeleton class="h-full w-full rounded-lg" />
</div>
<div v-else-if="error" class="text-muted-foreground flex h-full flex-col items-center justify-center gap-2">
<AlertCircle class="size-6" />
<p class="text-sm">Failed to load data</p>
</div>
<div v-else-if="empty" class="text-muted-foreground flex h-full flex-col items-center justify-center gap-3">
<component :is="icon" class="size-9 opacity-20" />
<div class="flex flex-col items-center gap-1">
<p class="text-sm font-medium">No data yet</p>
<p class="text-xs opacity-60">No data available for this chart</p>
</div>
</div>
<slot v-else />
</div>
<p v-if="!loading && !error && unknownCount && unknownCount > 0" class="text-muted-foreground mt-2 text-xs">
{{ unknownCount }} {{ unknownCount === 1 ? 'book has' : 'books have' }} no data for this field
</p>
</div>
</div>
</template>
@@ -0,0 +1,52 @@
<script setup lang="ts">
import { shallowRef, watchEffect } from 'vue'
import VChart from 'vue-echarts'
import { PieChart } from 'lucide-vue-next'
import { breakpointsTailwind, useBreakpoints } from '@vueuse/core'
import { useFormatDistribution } from '../composables/useFormatDistribution'
import ChartCard from './ChartCard.vue'
const { data, loading, error } = useFormatDistribution()
const { md } = useBreakpoints(breakpointsTailwind)
const option = shallowRef({})
watchEffect(() => {
if (!data.value.items.length) return
option.value = {
tooltip: { trigger: 'item', confine: true, enterable: false },
legend: {
orient: md.value ? 'vertical' : 'horizontal',
right: md.value ? '2%' : 'auto',
bottom: md.value ? 'auto' : 0,
top: md.value ? 'middle' : 'auto',
},
series: [
{
type: 'pie',
radius: ['40%', '70%'],
center: md.value ? ['38%', '50%'] : ['50%', '44%'],
data: data.value.items.map((item) => ({ name: item.format.toUpperCase(), value: item.count })),
cursor: 'default',
label: { show: false },
emphasis: { disabled: true },
},
],
}
})
</script>
<template>
<ChartCard
title="Format Distribution"
:icon="PieChart"
:color-index="1"
:loading
:error
:empty="!data.items.length"
:unknown-count="data.unknownCount"
>
<VChart :option autoresize style="height: 100%" />
</ChartCard>
</template>
@@ -0,0 +1,61 @@
<script setup lang="ts">
import { shallowRef, watchEffect } from 'vue'
import VChart from 'vue-echarts'
import { Tag } from 'lucide-vue-next'
import { useGenreDistribution } from '../composables/useGenreDistribution'
import ChartCard from './ChartCard.vue'
const { data, loading, error } = useGenreDistribution()
const option = shallowRef({})
watchEffect(() => {
if (!data.value.items.length) return
option.value = {
tooltip: {
trigger: 'axis',
confine: true,
enterable: false,
formatter: (params: { name: string; value: number }[]) => {
const p = params[0]
if (!p) return ''
return `${p.name}: <strong>${p.value}</strong> books`
},
},
grid: { left: '3%', right: '3%', bottom: '28%', top: '8%', containLabel: false },
xAxis: {
type: 'category',
data: data.value.items.map((d) => d.genre),
axisTick: { show: false },
axisLabel: {
fontSize: 11,
rotate: 45,
interval: 0,
overflow: 'truncate',
width: 80,
},
},
yAxis: {
type: 'value',
minInterval: 1,
axisLabel: { fontSize: 11 },
},
series: [
{
type: 'bar',
data: data.value.items.map((d) => d.count),
cursor: 'default',
itemStyle: { borderRadius: [3, 3, 0, 0] },
barMaxWidth: 32,
emphasis: { disabled: true },
},
],
}
})
</script>
<template>
<ChartCard title="Genre Distribution" :icon="Tag" :color-index="8" :loading :error :empty="!data.items.length" :unknown-count="data.unknownCount">
<VChart :option autoresize style="height: 100%" />
</ChartCard>
</template>
@@ -0,0 +1,52 @@
<script setup lang="ts">
import { shallowRef, watchEffect } from 'vue'
import VChart from 'vue-echarts'
import { Globe } from 'lucide-vue-next'
import { breakpointsTailwind, useBreakpoints } from '@vueuse/core'
import { useLanguageDistribution } from '../composables/useLanguageDistribution'
import ChartCard from './ChartCard.vue'
const { data, loading, error } = useLanguageDistribution()
const { md } = useBreakpoints(breakpointsTailwind)
const option = shallowRef({})
watchEffect(() => {
if (!data.value.items.length) return
option.value = {
tooltip: { trigger: 'item', confine: true, enterable: false },
legend: {
orient: md.value ? 'vertical' : 'horizontal',
right: md.value ? '2%' : 'auto',
bottom: md.value ? 'auto' : 0,
top: md.value ? 'middle' : 'auto',
},
series: [
{
type: 'pie',
radius: ['40%', '70%'],
center: md.value ? ['38%', '50%'] : ['50%', '44%'],
data: data.value.items.map((item) => ({ name: item.language.toUpperCase(), value: item.count })),
cursor: 'default',
label: { show: false },
emphasis: { disabled: true },
},
],
}
})
</script>
<template>
<ChartCard
title="Language Distribution"
:icon="Globe"
:color-index="2"
:loading
:error
:empty="!data.items.length"
:unknown-count="data.unknownCount"
>
<VChart :option autoresize style="height: 100%" />
</ChartCard>
</template>
@@ -0,0 +1,62 @@
<script setup lang="ts">
import { shallowRef, watchEffect } from 'vue'
import VChart from 'vue-echarts'
import { ListChecks } from 'lucide-vue-next'
import { useMetadataCompleteness } from '../composables/useMetadataCompleteness'
import ChartCard from './ChartCard.vue'
const { data, loading, error } = useMetadataCompleteness()
const option = shallowRef({})
watchEffect(() => {
if (!data.value.items.length) return
option.value = {
tooltip: {
trigger: 'axis',
confine: true,
enterable: false,
formatter: (params: { name: string; value: number }[]) => {
const p = params[0]
if (!p) return ''
const item = data.value.items.find((i) => i.field === p.name)
if (!item) return ''
return `${p.name}: <strong>${p.value}%</strong> (${item.presentCount} / ${item.totalCount})`
},
},
grid: { left: '3%', right: '3%', bottom: '24%', top: '8%', containLabel: false },
xAxis: {
type: 'category',
data: [...data.value.items].reverse().map((d) => d.field),
axisTick: { show: false },
axisLabel: {
fontSize: 11,
rotate: 45,
interval: 0,
},
},
yAxis: {
type: 'value',
min: 0,
max: 100,
axisLabel: { fontSize: 11, formatter: '{value}%' },
},
series: [
{
type: 'bar',
data: [...data.value.items].reverse().map((d) => (d.totalCount > 0 ? Math.round((d.presentCount / d.totalCount) * 100) : 0)),
cursor: 'default',
itemStyle: { borderRadius: [3, 3, 0, 0] },
barMaxWidth: 40,
emphasis: { disabled: true },
},
],
}
})
</script>
<template>
<ChartCard title="Metadata Completeness" :icon="ListChecks" :color-index="7" :loading :error :empty="!data.items.length">
<VChart :option autoresize style="height: 100%" />
</ChartCard>
</template>
@@ -0,0 +1,63 @@
<script setup lang="ts">
import { shallowRef, watchEffect } from 'vue'
import VChart from 'vue-echarts'
import { CalendarDays } from 'lucide-vue-next'
import { usePublicationDecade } from '../composables/usePublicationDecade'
import ChartCard from './ChartCard.vue'
const { data, loading, error } = usePublicationDecade()
const option = shallowRef({})
watchEffect(() => {
if (!data.value.items.length) return
option.value = {
tooltip: {
trigger: 'axis',
confine: true,
enterable: false,
formatter: (params: { name: string; value: number }[]) => {
const p = params[0]
if (!p) return ''
return `${p.name}: <strong>${p.value}</strong> books`
},
},
grid: { left: '3%', right: '4%', bottom: '12%', top: '8%', containLabel: true },
xAxis: {
type: 'category',
data: data.value.items.map((d) => `${d.decade}s`),
axisTick: { show: false },
axisLabel: { fontSize: 11 },
},
yAxis: {
type: 'value',
minInterval: 1,
axisLabel: { fontSize: 11 },
},
series: [
{
type: 'bar',
data: data.value.items.map((d) => d.count),
cursor: 'default',
itemStyle: { borderRadius: [3, 3, 0, 0] },
barMaxWidth: 48,
emphasis: { disabled: true },
},
],
}
})
</script>
<template>
<ChartCard
title="Publication Decade"
:icon="CalendarDays"
:color-index="5"
:loading
:error
:empty="!data.items.length"
:unknown-count="data.unknownCount"
>
<VChart :option autoresize style="height: 100%" />
</ChartCard>
</template>
@@ -0,0 +1,83 @@
<script setup lang="ts">
import { type Component, defineAsyncComponent } from 'vue'
import { VueDraggable } from 'vue-draggable-plus'
import type { ChartConfigEntry, StatisticsChartId } from '@projectx/types'
import { useStatisticsConfig } from '../composables/useStatisticsConfig'
interface ChartRegistryEntry {
component: Component
label: string
wide: boolean
fullWidth?: boolean
}
const CHART_REGISTRY: Record<StatisticsChartId, ChartRegistryEntry> = {
'format-distribution': {
component: defineAsyncComponent(() => import('./FormatDistributionChart.vue')),
label: 'Format Distribution',
wide: false,
},
'language-distribution': {
component: defineAsyncComponent(() => import('./LanguageDistributionChart.vue')),
label: 'Language Distribution',
wide: false,
},
'books-added-over-time': {
component: defineAsyncComponent(() => import('./BooksAddedOverTimeChart.vue')),
label: 'Books Added Over Time',
wide: true,
},
'storage-by-format': {
component: defineAsyncComponent(() => import('./StorageByFormatChart.vue')),
label: 'Storage by Format',
wide: false,
},
'publication-decade': {
component: defineAsyncComponent(() => import('./PublicationDecadeChart.vue')),
label: 'Publication Decade',
wide: false,
},
'top-authors': {
component: defineAsyncComponent(() => import('./TopAuthorsChart.vue')),
label: 'Top Authors',
wide: true,
},
'metadata-completeness': {
component: defineAsyncComponent(() => import('./MetadataCompletenessChart.vue')),
label: 'Metadata Completeness',
wide: false,
},
'genre-distribution': {
component: defineAsyncComponent(() => import('./GenreDistributionChart.vue')),
label: 'Genre Distribution',
wide: true,
},
}
const { visibleCharts, reorder } = useStatisticsConfig()
function handleReorder(newList: ChartConfigEntry[]) {
reorder(newList)
}
</script>
<template>
<VueDraggable
:model-value="visibleCharts"
class="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4"
handle=".drag-handle"
:animation="200"
@update:model-value="handleReorder"
>
<div
v-for="chart in visibleCharts"
:key="chart.id"
:class="
CHART_REGISTRY[chart.id].fullWidth ? 'md:col-span-2 xl:col-span-3 2xl:col-span-4' : CHART_REGISTRY[chart.id].wide ? 'md:col-span-2' : ''
"
>
<component :is="CHART_REGISTRY[chart.id].component" />
</div>
</VueDraggable>
</template>
@@ -0,0 +1,220 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import {
CalendarDays,
Check,
ChevronDown,
Globe,
GripVertical,
HardDrive,
ListChecks,
PieChart,
Settings2,
Tag,
TrendingUp,
Users,
} from 'lucide-vue-next'
import { VueDraggable } from 'vue-draggable-plus'
import type { ChartConfigEntry, StatisticsChartId } from '@projectx/types'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle } from '@/components/ui/sheet'
import ToggleSwitch from '@/components/ui/ToggleSwitch.vue'
import { useLibraries } from '@/features/library/composables/useLibraries'
import { useStatisticsConfig } from '../composables/useStatisticsConfig'
import StatisticsGrid from './StatisticsGrid.vue'
import StatisticsSummaryCard from './StatisticsSummaryCard.vue'
const CHART_LABELS: Record<StatisticsChartId, string> = {
'format-distribution': 'Format Distribution',
'language-distribution': 'Language Distribution',
'books-added-over-time': 'Books Added Over Time',
'storage-by-format': 'Storage by Format',
'publication-decade': 'Publication Decade',
'top-authors': 'Top 25 Authors',
'metadata-completeness': 'Metadata Completeness',
'genre-distribution': 'Genre Distribution',
}
const CHART_ICONS: Record<StatisticsChartId, unknown> = {
'format-distribution': PieChart,
'language-distribution': Globe,
'books-added-over-time': TrendingUp,
'storage-by-format': HardDrive,
'publication-decade': CalendarDays,
'top-authors': Users,
'metadata-completeness': ListChecks,
'genre-distribution': Tag,
}
const { orderedCharts, filters, init, toggleVisibility, reorder, resetToDefaults, setLibraryFilter } = useStatisticsConfig()
const { libraries, fetchLibraries } = useLibraries()
const configOpen = ref(false)
// Called in setup (not onMounted) so filters are populated before chart children mount.
init()
onMounted(fetchLibraries)
const visibleCount = computed(() => orderedCharts.value.filter((c) => c.visible).length)
const totalCount = computed(() => orderedCharts.value.length)
const libraryLabel = computed(() => {
const count = filters.value.libraryIds.length
if (count === 0 || count === libraries.value.length) return 'All Libraries'
return `${count} of ${libraries.value.length} Libraries`
})
const isFiltered = computed(() => filters.value.libraryIds.length > 0 && filters.value.libraryIds.length < libraries.value.length)
function isLibrarySelected(id: number) {
return filters.value.libraryIds.length === 0 || filters.value.libraryIds.includes(id)
}
function handleToggleLibrary(id: number) {
const current = filters.value.libraryIds
if (current.length === 0) {
setLibraryFilter(libraries.value.map((l) => l.id).filter((lid) => lid !== id))
} else if (current.includes(id)) {
const next = current.filter((lid) => lid !== id)
setLibraryFilter(next.length === 0 ? [] : next)
} else {
const next = [...current, id]
setLibraryFilter(next.length === libraries.value.length ? [] : next)
}
}
function handleSelectAll() {
setLibraryFilter([])
}
function handleToggleChart(id: StatisticsChartId) {
toggleVisibility(id)
}
function handleReorder(newList: ChartConfigEntry[]) {
reorder(newList)
}
function handleReset() {
resetToDefaults()
}
function openConfig() {
configOpen.value = true
}
</script>
<template>
<div class="flex flex-col gap-6 pt-4">
<div class="flex items-center justify-between">
<div>
<h1 class="text-foreground text-[22px] font-semibold">Library Statistics</h1>
</div>
<div class="flex items-center gap-2">
<Popover v-if="libraries.length > 1">
<PopoverTrigger as-child>
<button
:class="[
'flex h-8 items-center gap-1.5 rounded-md border px-3 text-sm transition-colors',
isFiltered ? 'border-primary/40 bg-primary/10 text-primary hover:bg-primary/15' : 'border-border text-foreground hover:bg-accent',
]"
>
{{ libraryLabel }}
<ChevronDown class="size-3.5 opacity-60" />
</button>
</PopoverTrigger>
<PopoverContent align="end" class="w-56 p-1.5">
<button
class="text-muted-foreground hover:bg-accent hover:text-foreground flex w-full items-center gap-2 rounded px-2 py-1.5 text-sm"
@click="handleSelectAll"
>
<div :class="['flex size-4 items-center justify-center rounded border', !isFiltered ? 'border-primary bg-primary' : 'border-border']">
<Check v-if="!isFiltered" class="text-primary-foreground size-2.5" />
</div>
All Libraries
</button>
<div class="border-border my-1 border-t" />
<button
v-for="lib in libraries"
:key="lib.id"
class="hover:bg-accent flex w-full items-center gap-2 rounded px-2 py-1.5 text-sm"
@click="handleToggleLibrary(lib.id)"
>
<div
:class="[
'flex size-4 shrink-0 items-center justify-center rounded border',
isLibrarySelected(lib.id) ? 'border-primary bg-primary' : 'border-border',
]"
>
<Check v-if="isLibrarySelected(lib.id)" class="text-primary-foreground size-2.5" />
</div>
<span class="truncate">{{ lib.name }}</span>
</button>
</PopoverContent>
</Popover>
<button
class="bg-muted text-foreground hover:bg-muted/70 flex h-8 items-center gap-1.5 rounded-md px-2.5 text-sm font-medium transition-colors"
@click="openConfig"
>
<Settings2 class="size-3.5" />
Configure
</button>
</div>
</div>
<StatisticsSummaryCard />
<Sheet v-model:open="configOpen">
<SheetContent side="right" class="w-[420px] sm:max-w-[420px]">
<SheetHeader>
<div class="flex items-center justify-between pr-8">
<SheetTitle>Configure Charts</SheetTitle>
<span class="bg-muted text-muted-foreground rounded-full px-2 py-0.5 text-xs font-medium tabular-nums">
{{ visibleCount }} / {{ totalCount }}
</span>
</div>
<SheetDescription>Drag to reorder, toggle to show or hide.</SheetDescription>
</SheetHeader>
<div class="flex-1 overflow-y-auto px-4">
<VueDraggable
:model-value="orderedCharts"
class="flex flex-col gap-2"
handle=".drawer-drag-handle"
:animation="150"
@update:model-value="handleReorder"
>
<div
v-for="chart in orderedCharts"
:key="chart.id"
:class="[
'border-border/50 bg-muted/40 flex items-center gap-3 rounded-lg border px-3 py-2.5 transition-opacity',
!chart.visible && 'opacity-50',
]"
>
<GripVertical class="text-muted-foreground/60 drawer-drag-handle size-4 shrink-0 cursor-grab active:cursor-grabbing" />
<component :is="CHART_ICONS[chart.id]" :class="['size-4 shrink-0', chart.visible ? 'text-primary' : 'text-muted-foreground']" />
<span :class="['flex-1 text-sm', chart.visible ? 'text-foreground' : 'text-muted-foreground']">
{{ CHART_LABELS[chart.id] }}
</span>
<ToggleSwitch :model-value="chart.visible" @update:model-value="handleToggleChart(chart.id)" />
</div>
</VueDraggable>
</div>
<SheetFooter>
<button
class="text-muted-foreground hover:text-foreground w-full rounded-md py-2 text-sm transition-colors hover:bg-transparent"
@click="handleReset"
>
Reset to defaults
</button>
</SheetFooter>
</SheetContent>
</Sheet>
<StatisticsGrid />
</div>
</template>
@@ -0,0 +1,69 @@
<script setup lang="ts">
import { computed } from 'vue'
import { BarChart3, BookOpen, Building2, CalendarPlus, CalendarRange, Globe, Layers, Tags, Users } from 'lucide-vue-next'
import { useStatisticsSummary } from '../composables/useStatisticsSummary'
const { data, loading } = useStatisticsSummary()
function formatBytes(bytes: number): string {
if (bytes >= 1_073_741_824) return `${(bytes / 1_073_741_824).toFixed(1)} GB`
if (bytes >= 1_048_576) return `${(bytes / 1_048_576).toFixed(1)} MB`
return `${(bytes / 1024).toFixed(1)} KB`
}
function formatNumber(n: number): string {
return n.toLocaleString()
}
const publicationRange = computed(() => {
if (!data.value) return '-'
const { publicationYearMin: min, publicationYearMax: max } = data.value
if (!min && !max) return '-'
if (min === max) return String(min)
return `${min} - ${max}`
})
const kpis = computed(() => [
{ icon: BookOpen, label: 'Books', value: data.value ? formatNumber(data.value.totalBooks) : '-', colorIndex: 1 },
{ icon: Users, label: 'Authors', value: data.value ? formatNumber(data.value.totalAuthors) : '-', colorIndex: 2 },
{ icon: Layers, label: 'Series', value: data.value ? formatNumber(data.value.totalSeries) : '-', colorIndex: 3 },
{ icon: Building2, label: 'Publishers', value: data.value ? formatNumber(data.value.totalPublishers) : '-', colorIndex: 4 },
{ icon: BarChart3, label: 'Storage', value: data.value ? formatBytes(data.value.totalStorageBytes) : '-', colorIndex: 5 },
{ icon: Tags, label: 'Genres', value: data.value ? formatNumber(data.value.totalGenres) : '-', colorIndex: 6 },
{ icon: Globe, label: 'Languages', value: data.value ? formatNumber(data.value.totalLanguages) : '-', colorIndex: 7 },
{ icon: CalendarRange, label: 'Published', value: publicationRange.value, colorIndex: 8 },
{ icon: CalendarPlus, label: 'This Year', value: data.value ? formatNumber(data.value.booksAddedThisYear) : '-', colorIndex: 9 },
])
</script>
<template>
<div class="relative overflow-hidden rounded-xl border bg-card">
<div class="pointer-events-none absolute inset-0 bg-gradient-to-br from-primary/8 to-transparent" />
<BarChart3 class="pointer-events-none absolute -right-4 -top-2 opacity-[0.04]" :size="160" aria-hidden="true" />
<div class="relative p-4">
<div class="flex gap-3 overflow-x-auto pb-0.5 [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
<div
v-for="kpi in kpis"
:key="kpi.label"
:class="['border-border/60 bg-background/50 flex shrink-0 items-center gap-3 rounded-lg border px-4 py-2.5', loading ? 'opacity-60' : '']"
>
<div
class="shrink-0 rounded-md p-1.5"
:style="{
backgroundColor: `color-mix(in oklch, var(--chart-icon-${kpi.colorIndex}) 15%, transparent)`,
color: `var(--chart-icon-${kpi.colorIndex})`,
}"
>
<component :is="kpi.icon" class="size-4" />
</div>
<div>
<p class="text-foreground text-base font-semibold leading-tight tabular-nums">{{ kpi.value }}</p>
<p class="text-muted-foreground text-xs">{{ kpi.label }}</p>
</div>
</div>
</div>
</div>
</div>
</template>
@@ -0,0 +1,63 @@
<script setup lang="ts">
import { shallowRef, watchEffect } from 'vue'
import VChart from 'vue-echarts'
import { HardDrive } from 'lucide-vue-next'
import { breakpointsTailwind, useBreakpoints } from '@vueuse/core'
import { useStorageByFormat } from '../composables/useStorageByFormat'
import ChartCard from './ChartCard.vue'
const { data, loading, error } = useStorageByFormat()
const { md } = useBreakpoints(breakpointsTailwind)
const option = shallowRef({})
function formatBytes(bytes: number): string {
if (bytes >= 1_073_741_824) return `${(bytes / 1_073_741_824).toFixed(1)} GB`
if (bytes >= 1_048_576) return `${(bytes / 1_048_576).toFixed(1)} MB`
return `${(bytes / 1024).toFixed(1)} KB`
}
watchEffect(() => {
if (!data.value.items.length) return
option.value = {
tooltip: {
trigger: 'item',
confine: true,
enterable: false,
formatter: (params: { name: string; value: number; percent: number }) => `${params.name}: ${formatBytes(params.value)} (${params.percent}%)`,
},
legend: {
orient: md.value ? 'vertical' : 'horizontal',
right: md.value ? '2%' : 'auto',
bottom: md.value ? 'auto' : 0,
top: md.value ? 'middle' : 'auto',
},
series: [
{
type: 'pie',
radius: ['40%', '70%'],
center: md.value ? ['38%', '50%'] : ['50%', '44%'],
data: data.value.items.map((item) => ({ name: item.format.toUpperCase(), value: item.sizeBytes })),
cursor: 'default',
label: { show: false },
emphasis: { disabled: true },
},
],
}
})
</script>
<template>
<ChartCard
title="Storage by Format"
:icon="HardDrive"
:color-index="4"
:loading
:error
:empty="!data.items.length"
:unknown-count="data.unknownCount"
>
<VChart :option autoresize style="height: 100%" />
</ChartCard>
</template>
@@ -0,0 +1,61 @@
<script setup lang="ts">
import { shallowRef, watchEffect } from 'vue'
import VChart from 'vue-echarts'
import { Users } from 'lucide-vue-next'
import { useTopAuthors } from '../composables/useTopAuthors'
import ChartCard from './ChartCard.vue'
const { data, loading, error } = useTopAuthors()
const option = shallowRef({})
watchEffect(() => {
if (!data.value.items.length) return
option.value = {
tooltip: {
trigger: 'axis',
confine: true,
enterable: false,
formatter: (params: { name: string; value: number }[]) => {
const p = params[0]
if (!p) return ''
return `${p.name}: <strong>${p.value}</strong> books`
},
},
grid: { left: '3%', right: '3%', bottom: '28%', top: '8%', containLabel: false },
xAxis: {
type: 'category',
data: data.value.items.map((d) => d.name),
axisTick: { show: false },
axisLabel: {
fontSize: 11,
rotate: 45,
interval: 0,
overflow: 'truncate',
width: 80,
},
},
yAxis: {
type: 'value',
minInterval: 1,
axisLabel: { fontSize: 11 },
},
series: [
{
type: 'bar',
data: data.value.items.map((d) => d.count),
cursor: 'default',
itemStyle: { borderRadius: [3, 3, 0, 0] },
barMaxWidth: 32,
emphasis: { disabled: true },
},
],
}
})
</script>
<template>
<ChartCard title="Top 25 Authors" :icon="Users" :color-index="6" :loading :error :empty="!data.items.length">
<VChart :option autoresize style="height: 100%" />
</ChartCard>
</template>
@@ -0,0 +1,29 @@
import { onMounted, ref, watch } from 'vue'
import type { BooksAddedDataPoint, StatisticsResult } from '@projectx/types'
import { fetchBooksAddedOverTime } from '../api/statistics.api'
import { useStatisticsConfig } from './useStatisticsConfig'
export function useBooksAddedOverTime() {
const data = ref<StatisticsResult<BooksAddedDataPoint>>({ items: [], unknownCount: 0 })
const loading = ref(true)
const error = ref(false)
const { filters } = useStatisticsConfig()
async function load() {
loading.value = true
error.value = false
try {
data.value = await fetchBooksAddedOverTime(filters.value)
} catch {
error.value = true
} finally {
loading.value = false
}
}
watch([() => filters.value.libraryIds.join(','), () => filters.value.booksOverTimeGranularity, () => filters.value.booksOverTimeRange], load)
onMounted(load)
return { data, loading, error }
}
@@ -0,0 +1,29 @@
import { onMounted, ref, watch } from 'vue'
import type { FormatDistributionItem, StatisticsResult } from '@projectx/types'
import { fetchFormatDistribution } from '../api/statistics.api'
import { useStatisticsConfig } from './useStatisticsConfig'
export function useFormatDistribution() {
const data = ref<StatisticsResult<FormatDistributionItem>>({ items: [], unknownCount: 0 })
const loading = ref(true)
const error = ref(false)
const { filters } = useStatisticsConfig()
async function load() {
loading.value = true
error.value = false
try {
data.value = await fetchFormatDistribution(filters.value)
} catch {
error.value = true
} finally {
loading.value = false
}
}
watch(() => filters.value.libraryIds.join(','), load)
onMounted(load)
return { data, loading, error }
}
@@ -0,0 +1,29 @@
import { onMounted, ref, watch } from 'vue'
import type { GenreDistributionItem, StatisticsResult } from '@projectx/types'
import { fetchGenreDistribution } from '../api/statistics.api'
import { useStatisticsConfig } from './useStatisticsConfig'
export function useGenreDistribution() {
const data = ref<StatisticsResult<GenreDistributionItem>>({ items: [], unknownCount: 0 })
const loading = ref(true)
const error = ref(false)
const { filters } = useStatisticsConfig()
async function load() {
loading.value = true
error.value = false
try {
data.value = await fetchGenreDistribution(filters.value)
} catch {
error.value = true
} finally {
loading.value = false
}
}
watch(() => filters.value.libraryIds.join(','), load)
onMounted(load)
return { data, loading, error }
}
@@ -0,0 +1,29 @@
import { onMounted, ref, watch } from 'vue'
import type { LanguageDistributionItem, StatisticsResult } from '@projectx/types'
import { fetchLanguageDistribution } from '../api/statistics.api'
import { useStatisticsConfig } from './useStatisticsConfig'
export function useLanguageDistribution() {
const data = ref<StatisticsResult<LanguageDistributionItem>>({ items: [], unknownCount: 0 })
const loading = ref(true)
const error = ref(false)
const { filters } = useStatisticsConfig()
async function load() {
loading.value = true
error.value = false
try {
data.value = await fetchLanguageDistribution(filters.value)
} catch {
error.value = true
} finally {
loading.value = false
}
}
watch(() => filters.value.libraryIds.join(','), load)
onMounted(load)
return { data, loading, error }
}
@@ -0,0 +1,29 @@
import { onMounted, ref, watch } from 'vue'
import type { MetadataCompletenessItem, StatisticsResult } from '@projectx/types'
import { fetchMetadataCompleteness } from '../api/statistics.api'
import { useStatisticsConfig } from './useStatisticsConfig'
export function useMetadataCompleteness() {
const data = ref<StatisticsResult<MetadataCompletenessItem>>({ items: [], unknownCount: 0 })
const loading = ref(true)
const error = ref(false)
const { filters } = useStatisticsConfig()
async function load() {
loading.value = true
error.value = false
try {
data.value = await fetchMetadataCompleteness(filters.value)
} catch {
error.value = true
} finally {
loading.value = false
}
}
watch(() => filters.value.libraryIds.join(','), load)
onMounted(load)
return { data, loading, error }
}
@@ -0,0 +1,29 @@
import { onMounted, ref, watch } from 'vue'
import type { PublicationDecadeItem, StatisticsResult } from '@projectx/types'
import { fetchPublicationDecade } from '../api/statistics.api'
import { useStatisticsConfig } from './useStatisticsConfig'
export function usePublicationDecade() {
const data = ref<StatisticsResult<PublicationDecadeItem>>({ items: [], unknownCount: 0 })
const loading = ref(true)
const error = ref(false)
const { filters } = useStatisticsConfig()
async function load() {
loading.value = true
error.value = false
try {
data.value = await fetchPublicationDecade(filters.value)
} catch {
error.value = true
} finally {
loading.value = false
}
}
watch(() => filters.value.libraryIds.join(','), load)
onMounted(load)
return { data, loading, error }
}
@@ -0,0 +1,121 @@
import { computed, ref } from 'vue'
import { toast } from 'vue-sonner'
import type {
ChartConfigEntry,
StatisticsChartId,
StatisticsDateRange,
StatisticsFilterConfig,
StatisticsGranularity,
StatisticsSettings,
} from '@projectx/types'
import { api } from '@/lib/api'
import { useAuth } from '@/features/auth/composables/useAuth'
// Must stay in sync with CHART_REGISTRY in StatisticsGrid.vue.
const KNOWN_CHART_IDS: StatisticsChartId[] = [
'format-distribution',
'language-distribution',
'books-added-over-time',
'storage-by-format',
'publication-decade',
'top-authors',
'metadata-completeness',
'genre-distribution',
]
const DEFAULT_FILTERS: StatisticsFilterConfig = {
libraryIds: [],
booksOverTimeGranularity: 'monthly',
booksOverTimeRange: 'all-time',
}
const config = ref<ChartConfigEntry[]>([])
const filters = ref<StatisticsFilterConfig>({ ...DEFAULT_FILTERS })
function normalizeCharts(saved: ChartConfigEntry[] | undefined): ChartConfigEntry[] {
const knownSet = new Set<StatisticsChartId>(KNOWN_CHART_IDS)
const filtered = (saved ?? []).filter((e) => knownSet.has(e.id))
const savedIds = new Set(filtered.map((e) => e.id))
const newEntries: ChartConfigEntry[] = KNOWN_CHART_IDS.filter((id) => !savedIds.has(id)).map((id, i) => ({
id,
visible: true,
order: filtered.length + i,
}))
return [...filtered, ...newEntries]
}
function normalizeFilters(saved: StatisticsFilterConfig | undefined): StatisticsFilterConfig {
return {
libraryIds: saved?.libraryIds ?? DEFAULT_FILTERS.libraryIds,
booksOverTimeGranularity: saved?.booksOverTimeGranularity ?? DEFAULT_FILTERS.booksOverTimeGranularity,
booksOverTimeRange: saved?.booksOverTimeRange ?? DEFAULT_FILTERS.booksOverTimeRange,
}
}
async function persist() {
const payload: StatisticsSettings = { charts: config.value, filters: filters.value }
try {
await api('/api/v1/users/me', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ settings: { statisticsConfig: payload } }),
})
} catch {
toast.error('Failed to save chart configuration')
}
}
let saveTimer: ReturnType<typeof setTimeout> | null = null
function scheduleSave() {
if (saveTimer) clearTimeout(saveTimer)
saveTimer = setTimeout(persist, 600)
}
export function useStatisticsConfig() {
const { user } = useAuth()
function init() {
const saved = user.value?.settings?.statisticsConfig
config.value = normalizeCharts(saved?.charts)
filters.value = normalizeFilters(saved?.filters)
}
const orderedCharts = computed(() => [...config.value].sort((a, b) => a.order - b.order))
const visibleCharts = computed(() => orderedCharts.value.filter((c) => c.visible))
function toggleVisibility(id: StatisticsChartId) {
const entry = config.value.find((c) => c.id === id)
if (!entry) return
entry.visible = !entry.visible
scheduleSave()
}
function reorder(newOrder: ChartConfigEntry[]) {
config.value = newOrder.map((entry, i) => ({ ...entry, order: i }))
scheduleSave()
}
function setLibraryFilter(ids: number[]) {
filters.value = { ...filters.value, libraryIds: ids }
scheduleSave()
}
function setGranularity(g: StatisticsGranularity) {
filters.value = { ...filters.value, booksOverTimeGranularity: g }
scheduleSave()
}
function setDateRange(r: StatisticsDateRange) {
filters.value = { ...filters.value, booksOverTimeRange: r }
scheduleSave()
}
function resetToDefaults() {
config.value = KNOWN_CHART_IDS.map((id, i) => ({ id, visible: true, order: i }))
filters.value = { ...DEFAULT_FILTERS }
scheduleSave()
}
return { orderedCharts, visibleCharts, filters, init, toggleVisibility, reorder, resetToDefaults, setLibraryFilter, setGranularity, setDateRange }
}
@@ -0,0 +1,27 @@
import { ref, watch } from 'vue'
import type { StatisticsSummary } from '@projectx/types'
import { fetchStatisticsSummary } from '../api/statistics.api'
import { useStatisticsConfig } from './useStatisticsConfig'
export function useStatisticsSummary() {
const { filters } = useStatisticsConfig()
const data = ref<StatisticsSummary | null>(null)
const loading = ref(true)
async function load() {
loading.value = true
try {
data.value = await fetchStatisticsSummary(filters.value)
} catch {
// leave stale data on error — header degrades gracefully with dashes
} finally {
loading.value = false
}
}
watch(() => filters.value.libraryIds.join(','), load, { immediate: true })
return { data, loading }
}
@@ -0,0 +1,29 @@
import { onMounted, ref, watch } from 'vue'
import type { StorageByFormatItem, StatisticsResult } from '@projectx/types'
import { fetchStorageByFormat } from '../api/statistics.api'
import { useStatisticsConfig } from './useStatisticsConfig'
export function useStorageByFormat() {
const data = ref<StatisticsResult<StorageByFormatItem>>({ items: [], unknownCount: 0 })
const loading = ref(true)
const error = ref(false)
const { filters } = useStatisticsConfig()
async function load() {
loading.value = true
error.value = false
try {
data.value = await fetchStorageByFormat(filters.value)
} catch {
error.value = true
} finally {
loading.value = false
}
}
watch(() => filters.value.libraryIds.join(','), load)
onMounted(load)
return { data, loading, error }
}
@@ -0,0 +1,29 @@
import { onMounted, ref, watch } from 'vue'
import type { TopAuthorItem, StatisticsResult } from '@projectx/types'
import { fetchTopAuthors } from '../api/statistics.api'
import { useStatisticsConfig } from './useStatisticsConfig'
export function useTopAuthors() {
const data = ref<StatisticsResult<TopAuthorItem>>({ items: [], unknownCount: 0 })
const loading = ref(true)
const error = ref(false)
const { filters } = useStatisticsConfig()
async function load() {
loading.value = true
error.value = false
try {
data.value = await fetchTopAuthors(filters.value)
} catch {
error.value = true
} finally {
loading.value = false
}
}
watch(() => filters.value.libraryIds.join(','), load)
onMounted(load)
return { data, loading, error }
}
+59
View File
@@ -0,0 +1,59 @@
import { use, registerTheme } from 'echarts/core'
import { SVGRenderer } from 'echarts/renderers'
import { BarChart, LineChart, PieChart } from 'echarts/charts'
import { GridComponent, LegendComponent, TitleComponent, TooltipComponent } from 'echarts/components'
// SVG renderer: events fire on real DOM elements, not via canvas hit-test.
// This eliminates the cursor-flicker / hover-disappear bug that canvas
// hit-test coordinate mismatches cause with this layout.
use([SVGRenderer, PieChart, BarChart, LineChart, TitleComponent, TooltipComponent, LegendComponent, GridComponent])
function readCssColor(varName: string): string {
const el = document.createElement('span')
el.style.color = `var(${varName})`
document.body.appendChild(el)
const color = getComputedStyle(el).color
el.remove()
return color
}
function buildTheme() {
const border = readCssColor('--border')
const mutedFg = readCssColor('--muted-foreground')
const axisConfig = {
axisLine: { show: true, lineStyle: { color: border } },
axisTick: { show: false },
axisLabel: { show: true, color: mutedFg },
splitLine: { show: true, lineStyle: { color: [border] } },
splitArea: { show: false },
}
return {
color: [
readCssColor('--chart-1'),
readCssColor('--chart-2'),
readCssColor('--chart-3'),
readCssColor('--chart-4'),
readCssColor('--chart-5'),
readCssColor('--chart-6'),
readCssColor('--chart-7'),
readCssColor('--chart-8'),
readCssColor('--chart-9'),
readCssColor('--chart-10'),
],
backgroundColor: 'transparent',
legend: { textStyle: { color: readCssColor('--foreground') } },
tooltip: {
backgroundColor: readCssColor('--card'),
borderColor: border,
textStyle: { color: readCssColor('--card-foreground') },
},
categoryAxis: axisConfig,
valueAxis: axisConfig,
logAxis: axisConfig,
timeAxis: axisConfig,
}
}
export function initChartThemes(version: number = 0): void {
registerTheme(`projectx-v${version}`, buildTheme())
}
+1
View File
@@ -1,4 +1,5 @@
import './assets/main.css'
import './lib/echarts'
import { createApp } from 'vue'
import { createPinia } from 'pinia'
+5
View File
@@ -116,6 +116,11 @@ const router = createRouter({
name: 'staging',
component: () => import('@/views/StagingView.vue'),
},
{
path: '/statistics',
name: 'statistics',
component: () => import('@/features/statistics/components/StatisticsPage.vue'),
},
{
path: '/library/:id',
name: 'library',