feat: add user-level statistics pipeline and dashboard charts

This commit is contained in:
neon
2026-03-19 18:05:22 -06:00
parent d65a4815bc
commit 069fa1488c
74 changed files with 3641 additions and 526 deletions
@@ -10,8 +10,13 @@ export function useReaderProgress(bookId: number, fileId: number) {
const sectionIndex = ref(0)
const totalSections = ref(0)
const fraction = ref(0)
const sessionId =
typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
? crypto.randomUUID()
: `session-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
let saveTimer: ReturnType<typeof setTimeout> | null = null
let eventSequence = 0
async function load() {
const res = await api(`/api/v1/books/files/${fileId}/progress`)
@@ -35,10 +40,17 @@ export function useReaderProgress(bookId: number, fileId: number) {
}
async function save() {
eventSequence += 1
await api(`/api/v1/books/files/${fileId}/progress`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ cfi: cfi.value, pageNumber: pageNumber.value, percentage: percentage.value }),
body: JSON.stringify({
cfi: cfi.value,
pageNumber: pageNumber.value,
percentage: percentage.value,
eventKey: `${sessionId}:${eventSequence}`,
source: 'reader-web',
}),
})
}
@@ -1,8 +1,16 @@
import { api } from '@/lib/api'
import type {
BooksAddedDataPoint,
UserCompletionLatencyDistribution,
FormatShareOverTimeItem,
UserGoalTrajectory,
StatisticsSummary,
UserCompletionTimelinePoint,
UserDailyReadingStat,
UserFavoriteDayStat,
UserProgressFunnelComparison,
UserPeakHourStat,
UserStatisticsSummary,
FormatDistributionItem,
GenreRankOverTimeItem,
GenreDistributionItem,
@@ -31,6 +39,13 @@ function buildParams(filters: StatisticsFilterConfig, extra?: Record<string, str
return str ? `?${str}` : ''
}
function getUtcDaysSinceYearStartInclusive(): number {
const now = new Date()
const todayUtc = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())
const yearStartUtc = Date.UTC(now.getUTCFullYear(), 0, 1)
return Math.floor((todayUtc - yearStartUtc) / 86_400_000) + 1
}
export async function fetchFormatDistribution(filters: StatisticsFilterConfig): Promise<StatisticsResult<FormatDistributionItem>> {
return parseResult(await api(`/api/v1/statistics/format-distribution${buildParams(filters)}`))
}
@@ -97,3 +112,52 @@ export async function fetchStatisticsSummary(filters: StatisticsFilterConfig): P
if (!res.ok) throw new Error(`Statistics summary request failed: ${res.status}`)
return res.json() as Promise<StatisticsSummary>
}
export async function fetchUserStatisticsSummary(filters: StatisticsFilterConfig): Promise<UserStatisticsSummary> {
const res = await api(`/api/v1/user-statistics/summary${buildParams(filters)}`)
if (!res.ok) throw new Error(`User statistics summary request failed: ${res.status}`)
return res.json() as Promise<UserStatisticsSummary>
}
export async function fetchUserReadingHeatmap(filters: StatisticsFilterConfig): Promise<UserDailyReadingStat[]> {
const days = String(getUtcDaysSinceYearStartInclusive())
const res = await api(`/api/v1/user-statistics/reading-heatmap${buildParams(filters, { days })}`)
if (!res.ok) throw new Error(`User reading heatmap request failed: ${res.status}`)
return res.json() as Promise<UserDailyReadingStat[]>
}
export async function fetchUserPeakReadingHours(filters: StatisticsFilterConfig): Promise<UserPeakHourStat[]> {
const res = await api(`/api/v1/user-statistics/peak-hours${buildParams(filters, { days: '365' })}`)
if (!res.ok) throw new Error(`User peak hours request failed: ${res.status}`)
return res.json() as Promise<UserPeakHourStat[]>
}
export async function fetchUserFavoriteReadingDays(filters: StatisticsFilterConfig): Promise<UserFavoriteDayStat[]> {
const res = await api(`/api/v1/user-statistics/favorite-days${buildParams(filters, { days: '365' })}`)
if (!res.ok) throw new Error(`User favorite days request failed: ${res.status}`)
return res.json() as Promise<UserFavoriteDayStat[]>
}
export async function fetchUserCompletionTimeline(filters: StatisticsFilterConfig): Promise<UserCompletionTimelinePoint[]> {
const res = await api(`/api/v1/user-statistics/completion-timeline${buildParams(filters, { days: '1825' })}`)
if (!res.ok) throw new Error(`User completion timeline request failed: ${res.status}`)
return res.json() as Promise<UserCompletionTimelinePoint[]>
}
export async function fetchUserGoalTrajectory(filters: StatisticsFilterConfig): Promise<UserGoalTrajectory> {
const res = await api(`/api/v1/user-statistics/goal-trajectory${buildParams(filters, { days: '365', goalBooks: '12' })}`)
if (!res.ok) throw new Error(`User goal trajectory request failed: ${res.status}`)
return res.json() as Promise<UserGoalTrajectory>
}
export async function fetchUserProgressFunnel(filters: StatisticsFilterConfig): Promise<UserProgressFunnelComparison> {
const res = await api(`/api/v1/user-statistics/progress-funnel${buildParams(filters, { days: '365', comparePrevious: 'true' })}`)
if (!res.ok) throw new Error(`User progress funnel request failed: ${res.status}`)
return res.json() as Promise<UserProgressFunnelComparison>
}
export async function fetchUserCompletionLatency(filters: StatisticsFilterConfig): Promise<UserCompletionLatencyDistribution> {
const res = await api(`/api/v1/user-statistics/completion-latency${buildParams(filters, { days: '1825' })}`)
if (!res.ok) throw new Error(`User completion latency request failed: ${res.status}`)
return res.json() as Promise<UserCompletionLatencyDistribution>
}
@@ -38,7 +38,7 @@ watchEffect(() => {
return `${p.name}: <strong>${p.value}</strong> books`
},
},
grid: { left: '3%', right: '4%', bottom: '12%', top: '8%', containLabel: true },
grid: { left: '3%', right: '4%', bottom: '8%', top: '8%', containLabel: true },
xAxis: {
type: 'category',
data: labels,
@@ -11,16 +11,13 @@ defineProps<{
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]',
'bg-card text-card-foreground flex h-full min-h-[320px] flex-col overflow-hidden rounded-xl border shadow-sm transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md md:min-h-0',
]"
>
<div class="flex min-h-0 flex-1 flex-col p-4">
@@ -0,0 +1,87 @@
<script setup lang="ts">
import { computed, shallowRef, watchEffect } from 'vue'
import VChart from 'vue-echarts'
import { Rabbit } from 'lucide-vue-next'
import { useUserCompletionLatency } from '../composables/useUserCompletionLatency'
import ChartCard from './ChartCard.vue'
const MIN_COMPLETIONS = 5
const { data, loading, error } = useUserCompletionLatency()
const option = shallowRef({})
const isEmpty = computed(() => data.value.totalCompletions === 0)
const lowConfidence = computed(() => data.value.totalCompletions > 0 && data.value.totalCompletions < MIN_COMPLETIONS)
watchEffect(() => {
option.value = {}
if (isEmpty.value || lowConfidence.value || !data.value.buckets.length) return
option.value = {
tooltip: {
trigger: 'axis',
confine: true,
enterable: false,
formatter: (params: Array<{ axisValue: string; data: number }>) => {
const point = params[0]
if (!point) return ''
const label = point.data === 1 ? 'book' : 'books'
return `${point.axisValue}: <strong>${point.data}</strong> ${label}`
},
},
grid: { left: '4%', right: '3%', bottom: '8%', top: '8%', containLabel: true },
xAxis: {
type: 'category',
data: data.value.buckets.map((bucket) => bucket.label),
axisTick: { show: false },
axisLabel: { fontSize: 11 },
},
yAxis: {
type: 'value',
min: 0,
minInterval: 1,
axisLabel: { fontSize: 11 },
},
series: [
{
type: 'bar',
data: data.value.buckets.map((bucket) => bucket.count),
barMaxWidth: 28,
itemStyle: { borderRadius: [4, 4, 0, 0] },
cursor: 'default',
emphasis: { disabled: true },
},
],
}
})
function metric(value: number | null): string {
return value == null ? '-' : `${value}d`
}
</script>
<template>
<ChartCard title="Completion Latency" :icon="Rabbit" :color-index="2" :loading :error :empty="isEmpty">
<div v-if="lowConfidence" class="text-muted-foreground flex h-full items-center justify-center text-sm">
Need at least {{ MIN_COMPLETIONS }} completions for reliable latency insights
</div>
<div v-else class="flex h-full flex-col gap-3">
<div class="text-muted-foreground grid grid-cols-3 gap-2 text-xs">
<div class="rounded-md border px-2 py-1">
Median
<div class="text-foreground text-sm font-semibold">{{ metric(data.medianDays) }}</div>
</div>
<div class="rounded-md border px-2 py-1">
P75
<div class="text-foreground text-sm font-semibold">{{ metric(data.percentile75Days) }}</div>
</div>
<div class="rounded-md border px-2 py-1">
P90
<div class="text-foreground text-sm font-semibold">{{ metric(data.percentile90Days) }}</div>
</div>
</div>
<VChart :option autoresize style="height: 100%" />
</div>
</ChartCard>
</template>
@@ -0,0 +1,78 @@
<script setup lang="ts">
import { computed, shallowRef, watchEffect } from 'vue'
import VChart from 'vue-echarts'
import { CalendarRange } from 'lucide-vue-next'
import { useUserCompletionTimeline } from '../composables/useUserCompletionTimeline'
import ChartCard from './ChartCard.vue'
const MONTH_NAMES = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
const MIN_COMPLETIONS = 3
const { data, loading, error } = useUserCompletionTimeline()
const option = shallowRef({})
const totalCompletions = computed(() => data.value.reduce((sum, item) => sum + item.count, 0))
const isEmpty = computed(() => totalCompletions.value === 0)
const lowConfidence = computed(() => totalCompletions.value > 0 && totalCompletions.value < MIN_COMPLETIONS)
watchEffect(() => {
option.value = {}
if (isEmpty.value || lowConfidence.value || !data.value.length) return
const labels = data.value.map((item) => `${MONTH_NAMES[item.month - 1]} ${item.year}`)
const values = data.value.map((item) => item.count)
option.value = {
tooltip: {
trigger: 'axis',
confine: true,
enterable: false,
formatter: (params: Array<{ axisValue: string; data: number }>) => {
const point = params[0]
if (!point) return ''
const bookLabel = point.data === 1 ? 'book' : 'books'
return `${point.axisValue}<br/><strong>${point.data}</strong> completed ${bookLabel}`
},
},
grid: { left: '3%', right: '3%', bottom: '8%', top: '6%', containLabel: true },
xAxis: {
type: 'category',
data: labels,
axisTick: { show: false },
axisLabel: {
fontSize: 11,
rotate: 40,
interval: Math.max(0, Math.floor(labels.length / 10) - 1),
},
},
yAxis: {
type: 'value',
min: 0,
minInterval: 1,
axisLabel: { fontSize: 11 },
},
series: [
{
type: 'line',
data: values,
smooth: 0.2,
showSymbol: false,
areaStyle: { opacity: 0.2 },
lineStyle: { width: 2 },
cursor: 'default',
emphasis: { disabled: true },
},
],
}
})
</script>
<template>
<ChartCard title="Completion Timeline" :icon="CalendarRange" :color-index="8" :loading :error :empty="isEmpty">
<div v-if="lowConfidence" class="text-muted-foreground flex h-full items-center justify-center text-sm">
Need at least {{ MIN_COMPLETIONS }} completed books for a reliable timeline
</div>
<VChart v-else :option autoresize style="height: 100%" />
</ChartCard>
</template>
@@ -0,0 +1,95 @@
<script setup lang="ts">
import { computed, shallowRef, watchEffect } from 'vue'
import VChart from 'vue-echarts'
import { CalendarDays } from 'lucide-vue-next'
import { useUserFavoriteReadingDays } from '../composables/useUserFavoriteReadingDays'
import ChartCard from './ChartCard.vue'
const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
const MIN_EVENTS = 14
const DAYS_WINDOW = 365
const { data, loading, error } = useUserFavoriteReadingDays()
const option = shallowRef({})
const totalEvents = computed(() => data.value.reduce((sum, item) => sum + item.eventsCount, 0))
const isEmpty = computed(() => totalEvents.value === 0)
const lowConfidence = computed(() => totalEvents.value > 0 && totalEvents.value < MIN_EVENTS)
function weekdayOccurrencesInWindow(days: number): number[] {
const counts = Array.from({ length: 7 }, () => 0)
const end = new Date()
const start = new Date(end)
start.setUTCDate(start.getUTCDate() - days + 1)
for (const cursor = new Date(start); cursor <= end; cursor.setUTCDate(cursor.getUTCDate() + 1)) {
const dayIndex = cursor.getUTCDay()
counts[dayIndex] = (counts[dayIndex] ?? 0) + 1
}
return counts
}
watchEffect(() => {
option.value = {}
if (isEmpty.value || lowConfidence.value || !data.value.length) return
const weekdayCounts = weekdayOccurrencesInWindow(DAYS_WINDOW)
const averageMinutesByDay = data.value.map((item, dayOfWeek) => {
const denominator = Math.max(1, weekdayCounts[dayOfWeek] ?? 1)
return Number((item.readingSeconds / 60 / denominator).toFixed(1))
})
option.value = {
tooltip: {
trigger: 'axis',
confine: true,
enterable: false,
formatter: (params: Array<{ axisValue: string; data: number; dataIndex: number }>) => {
const point = params[0]
if (!point) return ''
const row = data.value[point.dataIndex]
if (!row) return ''
const events = row.eventsCount
const avgMinuteLabel = point.data === 1 ? 'minute' : 'minutes'
const totalMinutes = Math.round(row.readingSeconds / 60)
const totalMinuteLabel = totalMinutes === 1 ? 'minute' : 'minutes'
const eventLabel = events === 1 ? 'event' : 'events'
return `${point.axisValue}<br/><strong>${point.data}</strong> avg ${avgMinuteLabel}<br/>${totalMinutes} total ${totalMinuteLabel}<br/>${events} ${eventLabel}`
},
},
grid: { left: '5%', right: '3%', bottom: '8%', top: '8%', containLabel: true },
xAxis: {
type: 'category',
data: WEEKDAYS,
axisTick: { show: false },
axisLabel: { fontSize: 11 },
},
yAxis: {
type: 'value',
min: 0,
axisLabel: { fontSize: 11, formatter: '{value}m' },
name: 'Avg min/day',
nameTextStyle: { fontSize: 11, color: 'var(--muted-foreground)' },
},
series: [
{
type: 'bar',
data: averageMinutesByDay,
barMaxWidth: 30,
itemStyle: { borderRadius: [4, 4, 0, 0] },
cursor: 'default',
emphasis: { disabled: true },
},
],
}
})
</script>
<template>
<ChartCard title="Favorite Reading Days" :icon="CalendarDays" :color-index="6" :loading :error :empty="isEmpty">
<div v-if="lowConfidence" class="text-muted-foreground flex h-full items-center justify-center text-sm">
Need at least {{ MIN_EVENTS }} reading events for a stable weekday pattern
</div>
<VChart v-else :option autoresize style="height: 100%" />
</ChartCard>
</template>
@@ -53,12 +53,12 @@ watchEffect(() => {
},
},
legend: { type: 'scroll', top: 0 },
grid: { left: '3%', right: '3%', bottom: '12%', top: '16%', containLabel: true },
grid: { left: '3%', right: '3%', bottom: '6%', top: '16%', containLabel: true },
xAxis: {
type: 'category',
data: labels,
axisTick: { show: false },
axisLabel: { fontSize: 11, interval: Math.max(0, Math.floor(labels.length / 10) - 1) },
axisLabel: { fontSize: 11, rotate: 45, interval: Math.max(0, Math.floor(labels.length / 10) - 1) },
},
yAxis: {
type: 'value',
@@ -40,7 +40,7 @@ watchEffect(() => {
return `${p.name}: <strong>${p.value}</strong> books`
},
},
grid: { left: '3%', right: '3%', bottom: '12%', top: '8%', containLabel: true },
grid: { left: '3%', right: '3%', bottom: '8%', top: '8%', containLabel: true },
xAxis: {
type: 'category',
data: rows.map((r) => r.genre),
@@ -82,10 +82,11 @@ watchEffect(() => {
},
},
legend: { type: 'scroll', top: 0 },
grid: { left: '3%', right: '3%', bottom: '10%', top: '16%', containLabel: true },
grid: { left: '3%', right: '15%', bottom: '6%', top: '16%', containLabel: true },
xAxis: {
type: 'category',
data: years,
boundaryGap: false,
axisTick: { show: false },
axisLabel: { fontSize: 11 },
},
@@ -0,0 +1,91 @@
<script setup lang="ts">
import { computed, shallowRef, watchEffect } from 'vue'
import VChart from 'vue-echarts'
import { Goal } from 'lucide-vue-next'
import { useUserGoalTrajectory } from '../composables/useUserGoalTrajectory'
import ChartCard from './ChartCard.vue'
const MONTH_NAMES = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
const MIN_COMPLETIONS = 2
const { data, loading, error } = useUserGoalTrajectory()
const option = shallowRef({})
const totalActual = computed(() => data.value.points[data.value.points.length - 1]?.actualCumulative ?? 0)
const isEmpty = computed(() => data.value.points.length === 0)
const lowConfidence = computed(() => totalActual.value > 0 && totalActual.value < MIN_COMPLETIONS)
const noCompletionsYet = computed(() => !isEmpty.value && totalActual.value === 0)
watchEffect(() => {
option.value = {}
if (isEmpty.value || lowConfidence.value || noCompletionsYet.value || !data.value.points.length) return
const labels = data.value.points.map((item) => `${MONTH_NAMES[item.month - 1]} ${item.year}`)
option.value = {
tooltip: {
trigger: 'axis',
confine: true,
enterable: false,
formatter: (params: Array<{ seriesName: string; data: number; axisValue: string }>) => {
const lines = params.map((item) => `${item.seriesName}: <strong>${item.data}</strong>`)
return `${params[0]?.axisValue ?? ''}<br/>${lines.join('<br/>')}`
},
},
legend: { top: 0 },
grid: { left: '3%', right: '3%', bottom: '8%', top: '16%', containLabel: true },
xAxis: {
type: 'category',
data: labels,
boundaryGap: false,
axisTick: { show: false },
axisLabel: { fontSize: 11, rotate: 35, interval: Math.max(0, Math.floor(labels.length / 10) - 1) },
},
yAxis: {
type: 'value',
min: 0,
minInterval: 1,
axisLabel: { fontSize: 11 },
},
series: [
{
name: 'Actual',
type: 'line',
data: data.value.points.map((item) => item.actualCumulative),
smooth: 0.2,
showSymbol: true,
symbolSize: 5,
lineStyle: { width: 2.5 },
areaStyle: { opacity: 0.15 },
cursor: 'default',
emphasis: { disabled: true },
},
{
name: `Goal (${data.value.goalBooks}/yr)`,
type: 'line',
data: data.value.points.map((item) => item.targetCumulative),
smooth: 0.2,
showSymbol: true,
symbolSize: 5,
lineStyle: { width: 2, type: 'dashed' },
cursor: 'default',
emphasis: { disabled: true },
},
],
}
})
</script>
<template>
<ChartCard title="Pace vs Goal" :icon="Goal" :color-index="9" :loading :error :empty="isEmpty">
<div v-if="noCompletionsYet" class="text-muted-foreground flex h-full flex-col items-center justify-center gap-1 text-sm">
<p>No completed books in this window yet</p>
<p class="text-xs opacity-80">This chart plots completed books against your yearly goal.</p>
</div>
<div v-if="lowConfidence" class="text-muted-foreground flex h-full items-center justify-center text-sm">
Need at least {{ MIN_COMPLETIONS }} completions to show a stable trajectory
</div>
<VChart v-else-if="!noCompletionsYet" :option autoresize style="height: 100%" />
</ChartCard>
</template>
@@ -1,15 +1,36 @@
<script setup lang="ts">
import { shallowRef, watchEffect } from 'vue'
import { computed, shallowRef, watchEffect } from 'vue'
import VChart from 'vue-echarts'
import { ListChecks } from 'lucide-vue-next'
import { useThemeStore } from '@/stores/theme'
import { buildHeatmapPalette } from '../heatmap-palette'
import { useLibraryMetadataCompleteness } from '../composables/useLibraryMetadataCompleteness'
import ChartCard from './ChartCard.vue'
const FIELD_ORDER = ['Cover', 'Author', 'Description', 'Publisher', 'Year', 'Language', 'Page Count', 'Rating', 'Series', 'ISBN']
const FIELD_ORDER = [
'Title',
'Cover',
'Author',
'Genres',
'Tags',
'Description',
'Publisher',
'Year',
'Language',
'Page Count',
'Rating',
'Series',
'ISBN',
]
const themeStore = useThemeStore()
const { data, loading, error } = useLibraryMetadataCompleteness()
const option = shallowRef({})
const heatmapPaletteState = computed(() => ({
accent: themeStore.accent,
palette: buildHeatmapPalette({ theme: themeStore.theme }),
}))
watchEffect(() => {
if (!data.value.items.length) return
@@ -22,39 +43,48 @@ watchEffect(() => {
const y = libraries.indexOf(item.libraryName)
return [x, y, item.percent, item.presentCount, item.totalCount]
})
const palette = heatmapPaletteState.value.palette
const labelColor = themeStore.theme === 'dark' ? '#f8fafc' : '#0f172a'
const pieces = [
{ value: 0, color: palette.scale[0] },
{ gt: 0, lte: 25, color: palette.scale[1] },
{ gt: 25, lte: 50, color: palette.scale[2] },
{ gt: 50, lte: 75, color: palette.scale[3] },
{ gt: 75, color: palette.scale[4] },
]
option.value = {
tooltip: {
confine: true,
enterable: false,
backgroundColor: palette.tooltipBackground,
borderColor: palette.tooltipBorder,
borderWidth: 1,
textStyle: { color: palette.tooltipText, fontSize: 12 },
formatter: (params: { value: [number, number, number, number, number] }) => {
const [x, y, pct, present, total] = params.value
return `${libraries[y]}<br/>${fields[x]}: <strong>${pct}%</strong> (${present}/${total})`
},
},
grid: { left: '3%', right: '8%', bottom: '6%', top: '6%', containLabel: true },
grid: { left: '3%', right: '3%', bottom: '6%', top: '6%', containLabel: true },
xAxis: {
type: 'category',
data: fields,
axisTick: { show: false },
axisLabel: { fontSize: 11, rotate: 35, interval: 0 },
axisLabel: { fontSize: 11, rotate: 35, interval: 0, color: palette.axisColor },
},
yAxis: {
type: 'category',
data: libraries,
axisTick: { show: false },
axisLabel: { fontSize: 11 },
axisLabel: { fontSize: 11, color: palette.axisColor },
},
visualMap: {
dimension: 2,
min: 0,
max: 100,
orient: 'vertical',
right: 0,
top: 'middle',
type: 'piecewise',
show: false,
calculable: false,
inRange: { color: ['#334155', '#475569', '#64748b', '#93c5fd', '#2563eb'] },
text: ['100%', '0%'],
dimension: 2,
pieces,
},
series: [
{
@@ -64,13 +94,13 @@ watchEffect(() => {
label: {
show: true,
formatter: (params: { value: [number, number, number] }) => `${params.value[2]}%`,
color: '#f8fafc',
color: labelColor,
fontSize: 11,
fontWeight: 600,
fontWeight: 500,
},
itemStyle: {
borderColor: 'rgba(15, 23, 42, 0.7)',
borderWidth: 1,
borderColor: palette.borderColor,
borderWidth: 0.8,
},
emphasis: { disabled: true },
},
@@ -80,7 +110,7 @@ watchEffect(() => {
</script>
<template>
<ChartCard title="Library Metadata Completeness" :icon="ListChecks" :color-index="7" :loading :error :empty="!data.items.length" tall>
<ChartCard title="Library Metadata Completeness" :icon="ListChecks" :color-index="7" :loading :error :empty="!data.items.length">
<VChart :option autoresize style="height: 100%" />
</ChartCard>
</template>
@@ -33,7 +33,7 @@ watchEffect(() => {
return `${p.name}: <strong>${p.value}</strong> books`
},
},
grid: { left: '3%', right: '3%', bottom: '12%', top: '10%', containLabel: true },
grid: { left: '3%', right: '3%', bottom: '8%', top: '6%', containLabel: true },
xAxis: {
type: 'category',
data: data.value.bins.map((b) => `${b.minScore}-${b.maxScore}`),
@@ -30,7 +30,7 @@ watchEffect(() => {
].join('<br/>')
},
},
grid: { left: '3%', right: '3%', bottom: '18%', top: '8%', containLabel: true },
grid: { left: '3%', right: '3%', bottom: '4%', top: '6%', containLabel: true },
xAxis: {
type: 'category',
data: data.value.items.map((item) => item.format),
@@ -0,0 +1,71 @@
<script setup lang="ts">
import { computed, shallowRef, watchEffect } from 'vue'
import VChart from 'vue-echarts'
import { Clock3 } from 'lucide-vue-next'
import { useUserPeakReadingHours } from '../composables/useUserPeakReadingHours'
import ChartCard from './ChartCard.vue'
const MIN_EVENTS = 20
const { data, loading, error } = useUserPeakReadingHours()
const option = shallowRef({})
const totalEvents = computed(() => data.value.reduce((sum, item) => sum + item.eventsCount, 0))
const isEmpty = computed(() => totalEvents.value === 0)
const lowConfidence = computed(() => totalEvents.value > 0 && totalEvents.value < MIN_EVENTS)
watchEffect(() => {
option.value = {}
if (isEmpty.value || lowConfidence.value || !data.value.length) return
option.value = {
tooltip: {
trigger: 'axis',
confine: true,
enterable: false,
formatter: (params: Array<{ axisValue: string; data: number; dataIndex: number }>) => {
const point = params[0]
if (!point) return ''
const events = data.value[point.dataIndex]?.eventsCount ?? 0
const minuteLabel = point.data === 1 ? 'minute' : 'minutes'
const eventLabel = events === 1 ? 'event' : 'events'
return `${point.axisValue}<br/><strong>${point.data}</strong> ${minuteLabel}<br/>${events} ${eventLabel}`
},
},
grid: { left: '3%', right: '3%', bottom: '8%', top: '8%', containLabel: true },
xAxis: {
type: 'category',
data: data.value.map((item) => `${String(item.hour).padStart(2, '0')}:00`),
axisTick: { show: false },
axisLabel: { fontSize: 11, interval: 1 },
},
yAxis: {
type: 'value',
min: 0,
axisLabel: { fontSize: 11, formatter: '{value}m' },
name: 'Minutes',
nameTextStyle: { fontSize: 11, color: 'var(--muted-foreground)' },
},
series: [
{
type: 'bar',
data: data.value.map((item) => Math.round(item.readingSeconds / 60)),
barMaxWidth: 24,
itemStyle: { borderRadius: [4, 4, 0, 0] },
cursor: 'default',
emphasis: { disabled: true },
},
],
}
})
</script>
<template>
<ChartCard title="Peak Reading Hours" :icon="Clock3" :color-index="5" :loading :error :empty="isEmpty">
<div v-if="lowConfidence" class="text-muted-foreground flex h-full items-center justify-center text-sm">
Need at least {{ MIN_EVENTS }} reading events for a stable hourly profile
</div>
<VChart v-else :option autoresize style="height: 100%" />
</ChartCard>
</template>
@@ -0,0 +1,233 @@
<script setup lang="ts">
import { computed, ref, shallowRef, watchEffect } from 'vue'
import VChart from 'vue-echarts'
import { Waypoints } from 'lucide-vue-next'
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
import { useUserProgressFunnel } from '../composables/useUserProgressFunnel'
import ChartCard from './ChartCard.vue'
type FunnelMode = 'percent' | 'counts' | 'dropoff'
const MODE_LABELS: Record<FunnelMode, string> = {
percent: 'Percent',
counts: 'Counts',
dropoff: 'Drop-off',
}
const MIN_STARTED = 10
const { data, loading, error } = useUserProgressFunnel()
const option = shallowRef({})
const mode = ref<FunnelMode>('percent')
const current = computed(() => data.value.current)
const previous = computed(() => data.value.previous)
const stages = computed(() => {
const raw = [
{ key: 'started', label: 'Started', count: current.value.started },
{ key: 'reached25', label: '25%', count: current.value.reached25 },
{ key: 'reached50', label: '50%', count: current.value.reached50 },
{ key: 'reached75', label: '75%', count: current.value.reached75 },
{ key: 'completed', label: 'Finished', count: current.value.completed },
] as const
return raw.map((stage, index) => {
const prevStage = index === 0 ? stage : (raw[index - 1] ?? stage)
const prevStageCount = prevStage.count
const conversionFromStart = current.value.started > 0 ? (stage.count / current.value.started) * 100 : 0
const conversionFromPrev = prevStageCount > 0 ? (stage.count / prevStageCount) * 100 : 0
const dropFromPrev = index === 0 ? 0 : Math.max(0, prevStageCount - stage.count)
return {
...stage,
conversionFromStart,
conversionFromPrev,
dropFromPrev,
}
})
})
const transitionMetrics = computed(() =>
stages.value.slice(1).map((stage, index) => ({
label: `${(stages.value[index] ?? stage).label} -> ${stage.label}`,
conversion: stage.conversionFromPrev,
drop: stage.dropFromPrev,
})),
)
const startedCount = computed(() => current.value.started)
const completionRate = computed(() => (startedCount.value > 0 ? (current.value.completed / startedCount.value) * 100 : 0))
const previousCompletionRate = computed(() => {
if (!previous.value || previous.value.started <= 0) return null
return (previous.value.completed / previous.value.started) * 100
})
const comparisonSummary = computed(() => {
if (!previous.value) return null
const startedDelta = current.value.started - previous.value.started
const currentRate = completionRate.value
const prevRate = previousCompletionRate.value ?? 0
const rateDelta = currentRate - prevRate
return { startedDelta, rateDelta }
})
const isEmpty = computed(() => startedCount.value === 0)
const lowConfidence = computed(() => startedCount.value > 0 && startedCount.value < MIN_STARTED)
const isFlatFunnel = computed(() => stages.value.every((stage) => stage.count === startedCount.value))
const showFlatState = computed(() => !isEmpty.value && !lowConfidence.value && isFlatFunnel.value)
const selectedModeLabel = computed(() => MODE_LABELS[mode.value])
watchEffect(() => {
option.value = {}
if (isEmpty.value || lowConfidence.value || showFlatState.value) return
if (mode.value === 'dropoff') {
option.value = {
tooltip: {
trigger: 'axis',
axisPointer: { type: 'shadow' },
confine: true,
enterable: false,
formatter: (params: Array<{ axisValue: string; data: number }>) => {
const point = params[0]
if (!point) return ''
const plural = point.data === 1 ? 'book' : 'books'
return `${point.axisValue}<br/>Drop-off: <strong>${point.data}</strong> ${plural}`
},
},
grid: { left: '10%', right: '8%', bottom: '12%', top: '14%', containLabel: true },
xAxis: {
type: 'value',
min: 0,
minInterval: 1,
axisLabel: { fontSize: 11 },
},
yAxis: {
type: 'category',
data: transitionMetrics.value.map((metric) => metric.label),
axisTick: { show: false },
axisLabel: { fontSize: 11 },
inverse: true,
},
series: [
{
type: 'bar',
data: transitionMetrics.value.map((metric) => metric.drop),
barWidth: 16,
itemStyle: { borderRadius: [0, 4, 4, 0] },
cursor: 'default',
emphasis: { disabled: true },
label: {
show: true,
position: 'right',
formatter: '{c}',
fontSize: 11,
},
},
],
}
return
}
const funnelValues = stages.value.map((stage) => ({
name: stage.label,
value: mode.value === 'counts' ? stage.count : Number(stage.conversionFromStart.toFixed(1)),
rawCount: stage.count,
pct: Number(stage.conversionFromStart.toFixed(1)),
}))
const maxValue = mode.value === 'counts' ? Math.max(...stages.value.map((stage) => stage.count), 1) : 100
option.value = {
tooltip: {
trigger: 'item',
confine: true,
enterable: false,
formatter: (params: { data: { name: string; rawCount: number; pct: number } }) => {
const point = params.data
const plural = point.rawCount === 1 ? 'book' : 'books'
return `${point.name}<br/><strong>${point.rawCount}</strong> ${plural} (${point.pct.toFixed(1)}%)`
},
},
series: [
{
type: 'funnel',
sort: 'none',
top: 18,
bottom: 16,
left: '16%',
width: '68%',
gap: 6,
min: 0,
max: maxValue,
minSize: '28%',
maxSize: '100%',
label: {
show: true,
position: 'inside',
fontSize: 11,
formatter: (params: { data: { name: string; rawCount: number; pct: number } }) =>
mode.value === 'counts' ? `${params.data.name}: ${params.data.rawCount}` : `${params.data.name}: ${params.data.pct.toFixed(1)}%`,
},
labelLine: { show: false },
itemStyle: {
borderColor: 'rgba(255,255,255,0.12)',
borderWidth: 1,
borderRadius: 3,
},
cursor: 'default',
emphasis: { disabled: true },
data: funnelValues,
},
],
}
})
</script>
<template>
<ChartCard title="Progress Funnel" :icon="Waypoints" :color-index="10" :loading :error :empty="isEmpty">
<template #controls>
<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">
{{ selectedModeLabel }}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" class="text-xs">
<DropdownMenuItem @click="mode = 'percent'">Percent</DropdownMenuItem>
<DropdownMenuItem @click="mode = 'counts'">Counts</DropdownMenuItem>
<DropdownMenuItem @click="mode = 'dropoff'">Drop-off</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</template>
<div v-if="lowConfidence" class="text-muted-foreground flex h-full items-center justify-center text-sm">
Need at least {{ MIN_STARTED }} started books for a reliable funnel
</div>
<div v-else-if="showFlatState" class="text-muted-foreground flex h-full flex-col items-center justify-center gap-2 text-sm">
<p>No observed drop-off in this window.</p>
<p class="text-xs opacity-80">Every started book progressed through all funnel stages.</p>
</div>
<div v-else class="flex h-full min-h-0 flex-col gap-2.5">
<div class="flex flex-wrap gap-1.5 text-xs">
<span class="border-border bg-muted/20 rounded-md border px-2 py-1">
Started {{ current.started }}
<span v-if="comparisonSummary" class="text-muted-foreground">
({{ comparisonSummary.startedDelta >= 0 ? '+' : '' }}{{ comparisonSummary.startedDelta }})
</span>
</span>
<span class="border-border bg-muted/20 rounded-md border px-2 py-1">
{{ completionRate.toFixed(1) }}%
<span v-if="comparisonSummary" :class="comparisonSummary.rateDelta >= 0 ? 'text-emerald-400' : 'text-rose-400'">
({{ comparisonSummary.rateDelta >= 0 ? '+' : '' }}{{ comparisonSummary.rateDelta.toFixed(1) }}pp)
</span>
</span>
</div>
<div class="border-border/60 bg-muted/10 min-h-[180px] flex-1 rounded-lg border p-2 md:min-h-[220px]">
<VChart :option autoresize class="h-full w-full" />
</div>
</div>
</ChartCard>
</template>
@@ -0,0 +1,153 @@
<script setup lang="ts">
import { computed, shallowRef, watchEffect } from 'vue'
import VChart from 'vue-echarts'
import { Calendar } from 'lucide-vue-next'
import { useThemeStore } from '@/stores/theme'
import { buildHeatmapPalette } from '../heatmap-palette'
import { useUserReadingHeatmap } from '../composables/useUserReadingHeatmap'
import ChartCard from './ChartCard.vue'
const MIN_ACTIVE_DAYS = 5
const themeStore = useThemeStore()
const { data, loading, error } = useUserReadingHeatmap()
const option = shallowRef({})
const totalEvents = computed(() => data.value.reduce((sum, item) => sum + item.eventsCount, 0))
const activeDays = computed(() => data.value.filter((item) => item.eventsCount > 0).length)
const isEmpty = computed(() => totalEvents.value === 0)
const lowConfidence = computed(() => totalEvents.value > 0 && activeDays.value < MIN_ACTIVE_DAYS)
const heatmapPaletteState = computed(() => ({
accent: themeStore.accent,
palette: buildHeatmapPalette({ theme: themeStore.theme, profile: 'github' }),
}))
const legendSwatches = computed(() => {
return heatmapPaletteState.value.palette.scale
})
function buildContributionPieces(scale: string[]) {
// Fixed bins (minutes/day) for stronger, stable contrast across datasets.
const b1 = 15
const b2 = 30
const b3 = 60
return [
{ value: 0, color: scale[0] },
{ gt: 0, lte: b1, color: scale[1] },
{ gt: b1, lte: b2, color: scale[2] },
{ gt: b2, lte: b3, color: scale[3] },
{ gt: b3, color: scale[4] },
]
}
watchEffect(() => {
option.value = {}
if (isEmpty.value || lowConfidence.value || !data.value.length) return
const now = new Date()
const year = now.getUTCFullYear()
const yearStart = new Date(Date.UTC(year, 0, 1))
const yearEnd = new Date(Date.UTC(year, 11, 31))
const yearStartKey = yearStart.toISOString().slice(0, 10)
const yearEndKey = yearEnd.toISOString().slice(0, 10)
const palette = heatmapPaletteState.value.palette
const byDay = new Map(data.value.map((item) => [item.day, item]))
const values: Array<readonly [string, number, number]> = []
for (const cursor = new Date(yearStart); cursor <= yearEnd; cursor.setUTCDate(cursor.getUTCDate() + 1)) {
const day = cursor.toISOString().slice(0, 10)
const item = byDay.get(day)
values.push([day, Number(((item?.readingSeconds ?? 0) / 60).toFixed(1)), item?.eventsCount ?? 0] as const)
}
const pieces = buildContributionPieces(palette.scale)
option.value = {
tooltip: {
confine: true,
enterable: false,
backgroundColor: palette.tooltipBackground,
borderColor: palette.tooltipBorder,
borderWidth: 1,
textStyle: { color: palette.tooltipText, fontSize: 12 },
formatter: (params: { value: [string, number, number] }) => {
const [day, minutes, events] = params.value
const minuteLabel = minutes === 1 ? 'minute' : 'minutes'
const eventLabel = events === 1 ? 'event' : 'events'
return `${day}<br/><strong>${minutes}</strong> ${minuteLabel}<br/>${events} ${eventLabel}`
},
},
visualMap: {
type: 'piecewise',
show: false,
calculable: false,
dimension: 1,
pieces,
},
calendar: {
top: 40,
left: 32,
right: 12,
bottom: 20,
cellSize: ['auto', 12],
range: [yearStartKey, yearEndKey],
yearLabel: { show: false },
splitLine: { show: false },
monthLabel: {
show: true,
fontSize: 11,
color: palette.axisColor,
margin: 10,
nameMap: ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'],
},
dayLabel: {
show: true,
firstDay: 1,
fontSize: 10,
color: palette.axisColor,
margin: 10,
nameMap: ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'],
},
itemStyle: {
color: 'transparent',
borderWidth: 0.8,
borderColor: palette.borderColor,
borderRadius: 1,
},
},
series: [
{
type: 'heatmap',
coordinateSystem: 'calendar',
data: values,
emphasis: { disabled: true },
},
],
}
})
</script>
<template>
<ChartCard title="Reading Heatmap" :icon="Calendar" :color-index="4" :loading :error :empty="isEmpty">
<div v-if="lowConfidence" class="text-muted-foreground flex h-full items-center justify-center text-sm">
Need activity on at least {{ MIN_ACTIVE_DAYS }} days to show a reliable pattern
</div>
<div v-else class="flex h-full min-h-0 flex-col">
<div class="min-h-0 flex-1 rounded-md px-2 py-3">
<VChart :option autoresize class="h-full w-full" />
</div>
<div class="text-muted-foreground mt-2 flex items-center justify-center gap-2 text-xs">
<span>Less</span>
<div class="flex items-center gap-1" aria-hidden="true">
<span
v-for="(swatch, idx) in legendSwatches"
:key="idx"
class="border-border/60 inline-block size-2.5 rounded-[3px] border"
:style="{ backgroundColor: swatch }"
/>
</div>
<span>More</span>
</div>
</div>
</ChartCard>
</template>
@@ -3,81 +3,30 @@ import { type Component, defineAsyncComponent } from 'vue'
import { VueDraggable } from 'vue-draggable-plus'
import type { ChartConfigEntry, StatisticsChartId } from '@projectx/types'
import { STATISTICS_CHART_META, type StatisticsChartSize } from '../statistics-chart-meta'
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,
},
'metadata-score-distribution': {
component: defineAsyncComponent(() => import('./MetadataScoreDistributionChart.vue')),
label: 'Metadata Score Distribution',
wide: false,
},
'library-metadata-completeness': {
component: defineAsyncComponent(() => import('./LibraryMetadataCompletenessHeatmapChart.vue')),
label: 'Library Metadata Completeness',
wide: true,
},
'format-share-over-time': {
component: defineAsyncComponent(() => import('./FormatShareOverTimeChart.vue')),
label: 'Format Share Over Time',
wide: true,
},
'genre-rank-over-time': {
component: defineAsyncComponent(() => import('./GenreRankOverTimeChart.vue')),
label: 'Genre Rank Over Time',
wide: true,
},
'page-count-distribution': {
component: defineAsyncComponent(() => import('./PageCountDistributionChart.vue')),
label: 'Page Count Distribution',
wide: false,
},
const CHART_COMPONENTS: Record<StatisticsChartId, Component> = {
'format-distribution': defineAsyncComponent(() => import('./FormatDistributionChart.vue')),
'language-distribution': defineAsyncComponent(() => import('./LanguageDistributionChart.vue')),
'books-added-over-time': defineAsyncComponent(() => import('./BooksAddedOverTimeChart.vue')),
'storage-by-format': defineAsyncComponent(() => import('./StorageByFormatChart.vue')),
'publication-decade': defineAsyncComponent(() => import('./PublicationDecadeChart.vue')),
'top-authors': defineAsyncComponent(() => import('./TopAuthorsChart.vue')),
'metadata-completeness': defineAsyncComponent(() => import('./MetadataCompletenessChart.vue')),
'genre-distribution': defineAsyncComponent(() => import('./GenreDistributionChart.vue')),
'metadata-score-distribution': defineAsyncComponent(() => import('./MetadataScoreDistributionChart.vue')),
'library-metadata-completeness': defineAsyncComponent(() => import('./LibraryMetadataCompletenessHeatmapChart.vue')),
'format-share-over-time': defineAsyncComponent(() => import('./FormatShareOverTimeChart.vue')),
'genre-rank-over-time': defineAsyncComponent(() => import('./GenreRankOverTimeChart.vue')),
'page-count-distribution': defineAsyncComponent(() => import('./PageCountDistributionChart.vue')),
'reading-heatmap': defineAsyncComponent(() => import('./ReadingHeatmapChart.vue')),
'peak-reading-hours': defineAsyncComponent(() => import('./PeakReadingHoursChart.vue')),
'favorite-reading-days': defineAsyncComponent(() => import('./FavoriteReadingDaysChart.vue')),
'completion-timeline': defineAsyncComponent(() => import('./CompletionTimelineChart.vue')),
'goal-trajectory': defineAsyncComponent(() => import('./GoalTrajectoryChart.vue')),
'progress-funnel': defineAsyncComponent(() => import('./ProgressFunnelChart.vue')),
'completion-latency': defineAsyncComponent(() => import('./CompletionLatencyChart.vue')),
}
const { visibleCharts, reorder } = useStatisticsConfig()
@@ -85,24 +34,26 @@ const { visibleCharts, reorder } = useStatisticsConfig()
function handleReorder(newList: ChartConfigEntry[]) {
reorder(newList)
}
function tileClass(size: StatisticsChartSize): string {
if (size === '2x1') return 'md:col-span-2 md:row-span-1'
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'
return 'md:col-span-1 md:row-span-1'
}
</script>
<template>
<VueDraggable
:model-value="visibleCharts"
class="grid grid-flow-row-dense grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4"
class="grid grid-flow-row-dense grid-cols-1 gap-4 md:grid-cols-2 md:auto-rows-[360px] xl: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 v-for="chart in visibleCharts" :key="chart.id" :class="tileClass(STATISTICS_CHART_META[chart.id].size)">
<component :is="CHART_COMPONENTS[chart.id]" />
</div>
</VueDraggable>
</template>
@@ -1,21 +1,6 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import {
BarChart3,
BookOpen,
CalendarDays,
Check,
ChevronDown,
Globe,
GripVertical,
HardDrive,
ListChecks,
PieChart,
Settings2,
Tag,
TrendingUp,
Users,
} from 'lucide-vue-next'
import { Check, ChevronDown, GripVertical, Settings2 } from 'lucide-vue-next'
import { VueDraggable } from 'vue-draggable-plus'
import type { ChartConfigEntry, StatisticsChartId } from '@projectx/types'
@@ -23,42 +8,11 @@ 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 { STATISTICS_CHART_META } from '../statistics-chart-meta'
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',
'metadata-score-distribution': 'Metadata Score Distribution',
'library-metadata-completeness': 'Library Metadata Completeness',
'format-share-over-time': 'Format Share Over Time',
'genre-rank-over-time': 'Genre Rank Over Time',
'page-count-distribution': 'Page Count Distribution',
}
const CHART_ICONS: Record<StatisticsChartId, unknown> = {
'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,
'metadata-score-distribution': BarChart3,
'library-metadata-completeness': ListChecks,
'format-share-over-time': TrendingUp,
'genre-rank-over-time': Tag,
'page-count-distribution': BookOpen,
}
const { orderedCharts, filters, init, toggleVisibility, reorder, resetToDefaults, setLibraryFilter } = useStatisticsConfig()
const { libraries, fetchLibraries } = useLibraries()
const configOpen = ref(false)
@@ -114,6 +68,10 @@ function handleReset() {
function openConfig() {
configOpen.value = true
}
function chartMeta(id: StatisticsChartId) {
return STATISTICS_CHART_META[id]
}
</script>
<template>
@@ -207,9 +165,9 @@ function openConfig() {
]"
>
<GripVertical class="text-muted-foreground/80 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']" />
<component :is="chartMeta(chart.id).icon" :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] }}
{{ chartMeta(chart.id).label }}
</span>
<ToggleSwitch :model-value="chart.visible" @update:model-value="handleToggleChart(chart.id)" />
</div>
@@ -1,10 +1,12 @@
<script setup lang="ts">
import { computed } from 'vue'
import { BarChart3, BookOpen, Building2, CalendarPlus, CalendarRange, Globe, Layers, Tags, Users } from 'lucide-vue-next'
import { BarChart3, BookCheck, BookOpen, BookText, Building2, CalendarPlus, CalendarRange, Globe, Layers, Tags, Users } from 'lucide-vue-next'
import { useStatisticsSummary } from '../composables/useStatisticsSummary'
import { useUserStatisticsSummary } from '../composables/useUserStatisticsSummary'
const { data, loading } = useStatisticsSummary()
const { data, loading: libraryLoading } = useStatisticsSummary()
const { data: userData, loading: userLoading } = useUserStatisticsSummary()
function formatBytes(bytes: number): string {
if (bytes >= 1_073_741_824) return `${(bytes / 1_073_741_824).toFixed(1)} GB`
@@ -24,6 +26,13 @@ const publicationRange = computed(() => {
return `${min} - ${max}`
})
const avgProgress = computed(() => {
if (!userData.value) return '-'
return `${userData.value.meanProgressPercent.toFixed(1)}%`
})
const loading = computed(() => libraryLoading.value || userLoading.value)
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 },
@@ -34,6 +43,10 @@ const kpis = computed(() => [
{ 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 },
{ icon: BookText, label: 'Started', value: userData.value ? formatNumber(userData.value.startedBooks) : '-', colorIndex: 1 },
{ icon: BookOpen, label: 'In Progress', value: userData.value ? formatNumber(userData.value.inProgressBooks) : '-', colorIndex: 2 },
{ icon: BookCheck, label: 'Completed', value: userData.value ? formatNumber(userData.value.completedBooks) : '-', colorIndex: 3 },
{ icon: BarChart3, label: 'Avg Progress', value: avgProgress.value, colorIndex: 4 },
])
</script>
@@ -1,29 +1,14 @@
import { onMounted, ref, watch } from 'vue'
import type { BooksAddedDataPoint, StatisticsResult } from '@projectx/types'
import { fetchBooksAddedOverTime } from '../api/statistics.api'
import { useStatisticsConfig } from './useStatisticsConfig'
import { useStatisticsQuery } from './useStatisticsQuery'
const EMPTY: StatisticsResult<BooksAddedDataPoint> = { items: [], unknownCount: 0 }
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 }
return useStatisticsQuery({
initialData: EMPTY,
fetcher: fetchBooksAddedOverTime,
watchFilterFields: [(filters) => filters.booksOverTimeGranularity, (filters) => filters.booksOverTimeRange],
})
}
@@ -1,29 +1,13 @@
import { onMounted, ref, watch } from 'vue'
import type { FormatDistributionItem, StatisticsResult } from '@projectx/types'
import { fetchFormatDistribution } from '../api/statistics.api'
import { useStatisticsConfig } from './useStatisticsConfig'
import { useStatisticsQuery } from './useStatisticsQuery'
const EMPTY: StatisticsResult<FormatDistributionItem> = { items: [], unknownCount: 0 }
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 }
return useStatisticsQuery({
initialData: EMPTY,
fetcher: fetchFormatDistribution,
})
}
@@ -1,29 +1,13 @@
import { onMounted, ref, watch } from 'vue'
import type { FormatShareOverTimeItem, StatisticsResult } from '@projectx/types'
import { fetchFormatShareOverTime } from '../api/statistics.api'
import { useStatisticsConfig } from './useStatisticsConfig'
import { useStatisticsQuery } from './useStatisticsQuery'
const EMPTY: StatisticsResult<FormatShareOverTimeItem> = { items: [], unknownCount: 0 }
export function useFormatShareOverTime() {
const data = ref<StatisticsResult<FormatShareOverTimeItem>>({ items: [], unknownCount: 0 })
const loading = ref(true)
const error = ref(false)
const { filters } = useStatisticsConfig()
async function load() {
loading.value = true
error.value = false
try {
data.value = await fetchFormatShareOverTime(filters.value)
} catch {
error.value = true
} finally {
loading.value = false
}
}
watch(() => filters.value.libraryIds.join(','), load)
onMounted(load)
return { data, loading, error }
return useStatisticsQuery({
initialData: EMPTY,
fetcher: fetchFormatShareOverTime,
})
}
@@ -1,29 +1,13 @@
import { onMounted, ref, watch } from 'vue'
import type { GenreDistributionItem, StatisticsResult } from '@projectx/types'
import { fetchGenreDistribution } from '../api/statistics.api'
import { useStatisticsConfig } from './useStatisticsConfig'
import { useStatisticsQuery } from './useStatisticsQuery'
const EMPTY: StatisticsResult<GenreDistributionItem> = { items: [], unknownCount: 0 }
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 }
return useStatisticsQuery({
initialData: EMPTY,
fetcher: fetchGenreDistribution,
})
}
@@ -1,29 +1,13 @@
import { onMounted, ref, watch } from 'vue'
import type { GenreRankOverTimeItem, StatisticsResult } from '@projectx/types'
import { fetchGenreRankOverTime } from '../api/statistics.api'
import { useStatisticsConfig } from './useStatisticsConfig'
import { useStatisticsQuery } from './useStatisticsQuery'
const EMPTY: StatisticsResult<GenreRankOverTimeItem> = { items: [], unknownCount: 0 }
export function useGenreRankOverTime() {
const data = ref<StatisticsResult<GenreRankOverTimeItem>>({ items: [], unknownCount: 0 })
const loading = ref(true)
const error = ref(false)
const { filters } = useStatisticsConfig()
async function load() {
loading.value = true
error.value = false
try {
data.value = await fetchGenreRankOverTime(filters.value)
} catch {
error.value = true
} finally {
loading.value = false
}
}
watch(() => filters.value.libraryIds.join(','), load)
onMounted(load)
return { data, loading, error }
return useStatisticsQuery({
initialData: EMPTY,
fetcher: fetchGenreRankOverTime,
})
}
@@ -1,29 +1,13 @@
import { onMounted, ref, watch } from 'vue'
import type { LanguageDistributionItem, StatisticsResult } from '@projectx/types'
import { fetchLanguageDistribution } from '../api/statistics.api'
import { useStatisticsConfig } from './useStatisticsConfig'
import { useStatisticsQuery } from './useStatisticsQuery'
const EMPTY: StatisticsResult<LanguageDistributionItem> = { items: [], unknownCount: 0 }
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 }
return useStatisticsQuery({
initialData: EMPTY,
fetcher: fetchLanguageDistribution,
})
}
@@ -1,29 +1,13 @@
import { onMounted, ref, watch } from 'vue'
import type { LibraryMetadataCompletenessItem, StatisticsResult } from '@projectx/types'
import { fetchLibraryMetadataCompleteness } from '../api/statistics.api'
import { useStatisticsConfig } from './useStatisticsConfig'
import { useStatisticsQuery } from './useStatisticsQuery'
const EMPTY: StatisticsResult<LibraryMetadataCompletenessItem> = { items: [], unknownCount: 0 }
export function useLibraryMetadataCompleteness() {
const data = ref<StatisticsResult<LibraryMetadataCompletenessItem>>({ items: [], unknownCount: 0 })
const loading = ref(true)
const error = ref(false)
const { filters } = useStatisticsConfig()
async function load() {
loading.value = true
error.value = false
try {
data.value = await fetchLibraryMetadataCompleteness(filters.value)
} catch {
error.value = true
} finally {
loading.value = false
}
}
watch(() => filters.value.libraryIds.join(','), load)
onMounted(load)
return { data, loading, error }
return useStatisticsQuery({
initialData: EMPTY,
fetcher: fetchLibraryMetadataCompleteness,
})
}
@@ -1,29 +1,13 @@
import { onMounted, ref, watch } from 'vue'
import type { MetadataCompletenessItem, StatisticsResult } from '@projectx/types'
import { fetchMetadataCompleteness } from '../api/statistics.api'
import { useStatisticsConfig } from './useStatisticsConfig'
import { useStatisticsQuery } from './useStatisticsQuery'
const EMPTY: StatisticsResult<MetadataCompletenessItem> = { items: [], unknownCount: 0 }
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 }
return useStatisticsQuery({
initialData: EMPTY,
fetcher: fetchMetadataCompleteness,
})
}
@@ -1,8 +1,7 @@
import { onMounted, ref, watch } from 'vue'
import type { MetadataScoreDistribution } from '@projectx/types'
import { fetchMetadataScoreDistribution } from '../api/statistics.api'
import { useStatisticsConfig } from './useStatisticsConfig'
import { useStatisticsQuery } from './useStatisticsQuery'
const EMPTY: MetadataScoreDistribution = {
bins: [],
@@ -15,25 +14,8 @@ const EMPTY: MetadataScoreDistribution = {
}
export function useMetadataScoreDistribution() {
const data = ref<MetadataScoreDistribution>(EMPTY)
const loading = ref(true)
const error = ref(false)
const { filters } = useStatisticsConfig()
async function load() {
loading.value = true
error.value = false
try {
data.value = await fetchMetadataScoreDistribution(filters.value)
} catch {
error.value = true
} finally {
loading.value = false
}
}
watch(() => filters.value.libraryIds.join(','), load)
onMounted(load)
return { data, loading, error }
return useStatisticsQuery({
initialData: EMPTY,
fetcher: fetchMetadataScoreDistribution,
})
}
@@ -1,29 +1,13 @@
import { onMounted, ref, watch } from 'vue'
import type { PageCountDistributionItem, StatisticsResult } from '@projectx/types'
import { fetchPageCountDistribution } from '../api/statistics.api'
import { useStatisticsConfig } from './useStatisticsConfig'
import { useStatisticsQuery } from './useStatisticsQuery'
const EMPTY: StatisticsResult<PageCountDistributionItem> = { items: [], unknownCount: 0 }
export function usePageCountDistribution() {
const data = ref<StatisticsResult<PageCountDistributionItem>>({ items: [], unknownCount: 0 })
const loading = ref(true)
const error = ref(false)
const { filters } = useStatisticsConfig()
async function load() {
loading.value = true
error.value = false
try {
data.value = await fetchPageCountDistribution(filters.value)
} catch {
error.value = true
} finally {
loading.value = false
}
}
watch(() => filters.value.libraryIds.join(','), load)
onMounted(load)
return { data, loading, error }
return useStatisticsQuery({
initialData: EMPTY,
fetcher: fetchPageCountDistribution,
})
}
@@ -1,29 +1,13 @@
import { onMounted, ref, watch } from 'vue'
import type { PublicationDecadeItem, StatisticsResult } from '@projectx/types'
import { fetchPublicationDecade } from '../api/statistics.api'
import { useStatisticsConfig } from './useStatisticsConfig'
import { useStatisticsQuery } from './useStatisticsQuery'
const EMPTY: StatisticsResult<PublicationDecadeItem> = { items: [], unknownCount: 0 }
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 }
return useStatisticsQuery({
initialData: EMPTY,
fetcher: fetchPublicationDecade,
})
}
@@ -2,7 +2,6 @@ import { computed, ref } from 'vue'
import { toast } from 'vue-sonner'
import {
DEFAULT_STATISTICS_CHART_ORDER,
DEFAULT_STATISTICS_FILTERS,
type ChartConfigEntry,
type StatisticsChartId,
@@ -13,9 +12,9 @@ import {
} from '@projectx/types'
import { api } from '@/lib/api'
import { useAuth } from '@/features/auth/composables/useAuth'
import { STATISTICS_CHART_IDS } from '../statistics-chart-meta'
// Must stay in sync with CHART_REGISTRY in StatisticsGrid.vue.
const KNOWN_CHART_IDS: StatisticsChartId[] = [...DEFAULT_STATISTICS_CHART_ORDER]
const KNOWN_CHART_IDS: StatisticsChartId[] = [...STATISTICS_CHART_IDS]
const DEFAULT_FILTERS: StatisticsFilterConfig = {
libraryIds: [...DEFAULT_STATISTICS_FILTERS.libraryIds],
@@ -85,7 +84,27 @@ export function useStatisticsConfig() {
}
function reorder(newOrder: ChartConfigEntry[]) {
config.value = newOrder.map((entry, i) => ({ ...entry, order: i }))
const visibleIds = new Set(newOrder.map((entry) => entry.id))
const previousById = new Map(config.value.map((entry) => [entry.id, entry]))
const reorderedVisible = newOrder.map((entry, index) => {
const existing = previousById.get(entry.id)
return {
id: entry.id,
visible: existing?.visible ?? true,
order: index,
}
})
const reorderedHidden = config.value
.filter((entry) => !visibleIds.has(entry.id))
.sort((a, b) => a.order - b.order)
.map((entry, index) => ({
...entry,
order: reorderedVisible.length + index,
}))
config.value = [...reorderedVisible, ...reorderedHidden]
scheduleSave()
}
@@ -0,0 +1,47 @@
import { onMounted, ref, watch } from 'vue'
import type { StatisticsFilterConfig } from '@projectx/types'
import { useStatisticsConfig } from './useStatisticsConfig'
interface UseStatisticsQueryOptions<T> {
initialData: T
fetcher: (filters: StatisticsFilterConfig) => Promise<T>
watchFilterFields?: Array<(filters: StatisticsFilterConfig) => string | number>
}
export function useStatisticsQuery<T>({ initialData, fetcher, watchFilterFields = [] }: UseStatisticsQueryOptions<T>) {
const data = ref<T>(initialData)
const loading = ref(true)
const error = ref(false)
let latestRequestId = 0
const { filters } = useStatisticsConfig()
async function load() {
const requestId = ++latestRequestId
loading.value = true
error.value = false
try {
const nextData = await fetcher(filters.value)
if (requestId !== latestRequestId) return
data.value = nextData
} catch {
if (requestId !== latestRequestId) return
error.value = true
} finally {
if (requestId === latestRequestId) {
loading.value = false
}
}
}
const watchSources: Array<() => string | number> = [
() => filters.value.libraryIds.join(','),
...watchFilterFields.map((selector) => () => selector(filters.value)),
]
watch(watchSources, load)
onMounted(load)
return { data, loading, error }
}
@@ -1,29 +1,13 @@
import { onMounted, ref, watch } from 'vue'
import type { StatisticsResult, StorageByFormatItem } from '@projectx/types'
import type { StorageByFormatItem, StatisticsResult } from '@projectx/types'
import { fetchStorageByFormat } from '../api/statistics.api'
import { useStatisticsConfig } from './useStatisticsConfig'
import { useStatisticsQuery } from './useStatisticsQuery'
const EMPTY: StatisticsResult<StorageByFormatItem> = { items: [], unknownCount: 0 }
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 }
return useStatisticsQuery({
initialData: EMPTY,
fetcher: fetchStorageByFormat,
})
}
@@ -1,29 +1,13 @@
import { onMounted, ref, watch } from 'vue'
import type { StatisticsResult, TopAuthorItem } from '@projectx/types'
import type { TopAuthorItem, StatisticsResult } from '@projectx/types'
import { fetchTopAuthors } from '../api/statistics.api'
import { useStatisticsConfig } from './useStatisticsConfig'
import { useStatisticsQuery } from './useStatisticsQuery'
const EMPTY: StatisticsResult<TopAuthorItem> = { items: [], unknownCount: 0 }
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 }
return useStatisticsQuery({
initialData: EMPTY,
fetcher: fetchTopAuthors,
})
}
@@ -0,0 +1,19 @@
import type { UserCompletionLatencyDistribution } from '@projectx/types'
import { fetchUserCompletionLatency } from '../api/statistics.api'
import { useStatisticsQuery } from './useStatisticsQuery'
const EMPTY: UserCompletionLatencyDistribution = {
totalCompletions: 0,
medianDays: null,
percentile75Days: null,
percentile90Days: null,
buckets: [],
}
export function useUserCompletionLatency() {
return useStatisticsQuery({
initialData: EMPTY,
fetcher: fetchUserCompletionLatency,
})
}
@@ -0,0 +1,13 @@
import type { UserCompletionTimelinePoint } from '@projectx/types'
import { fetchUserCompletionTimeline } from '../api/statistics.api'
import { useStatisticsQuery } from './useStatisticsQuery'
const EMPTY: UserCompletionTimelinePoint[] = []
export function useUserCompletionTimeline() {
return useStatisticsQuery({
initialData: EMPTY,
fetcher: fetchUserCompletionTimeline,
})
}
@@ -0,0 +1,13 @@
import type { UserFavoriteDayStat } from '@projectx/types'
import { fetchUserFavoriteReadingDays } from '../api/statistics.api'
import { useStatisticsQuery } from './useStatisticsQuery'
const EMPTY: UserFavoriteDayStat[] = []
export function useUserFavoriteReadingDays() {
return useStatisticsQuery({
initialData: EMPTY,
fetcher: fetchUserFavoriteReadingDays,
})
}
@@ -0,0 +1,16 @@
import type { UserGoalTrajectory } from '@projectx/types'
import { fetchUserGoalTrajectory } from '../api/statistics.api'
import { useStatisticsQuery } from './useStatisticsQuery'
const EMPTY: UserGoalTrajectory = {
goalBooks: 12,
points: [],
}
export function useUserGoalTrajectory() {
return useStatisticsQuery({
initialData: EMPTY,
fetcher: fetchUserGoalTrajectory,
})
}
@@ -0,0 +1,13 @@
import type { UserPeakHourStat } from '@projectx/types'
import { fetchUserPeakReadingHours } from '../api/statistics.api'
import { useStatisticsQuery } from './useStatisticsQuery'
const EMPTY: UserPeakHourStat[] = []
export function useUserPeakReadingHours() {
return useStatisticsQuery({
initialData: EMPTY,
fetcher: fetchUserPeakReadingHours,
})
}
@@ -0,0 +1,23 @@
import type { UserProgressFunnelComparison } from '@projectx/types'
import { fetchUserProgressFunnel } from '../api/statistics.api'
import { useStatisticsQuery } from './useStatisticsQuery'
const EMPTY: UserProgressFunnelComparison = {
days: 365,
current: {
started: 0,
reached25: 0,
reached50: 0,
reached75: 0,
completed: 0,
},
previous: null,
}
export function useUserProgressFunnel() {
return useStatisticsQuery({
initialData: EMPTY,
fetcher: fetchUserProgressFunnel,
})
}
@@ -0,0 +1,13 @@
import type { UserDailyReadingStat } from '@projectx/types'
import { fetchUserReadingHeatmap } from '../api/statistics.api'
import { useStatisticsQuery } from './useStatisticsQuery'
const EMPTY: UserDailyReadingStat[] = []
export function useUserReadingHeatmap() {
return useStatisticsQuery({
initialData: EMPTY,
fetcher: fetchUserReadingHeatmap,
})
}
@@ -0,0 +1,27 @@
import { ref, watch } from 'vue'
import type { UserStatisticsSummary } from '@projectx/types'
import { fetchUserStatisticsSummary } from '../api/statistics.api'
import { useStatisticsConfig } from './useStatisticsConfig'
export function useUserStatisticsSummary() {
const { filters } = useStatisticsConfig()
const data = ref<UserStatisticsSummary | null>(null)
const loading = ref(true)
async function load() {
loading.value = true
try {
data.value = await fetchUserStatisticsSummary(filters.value)
} catch {
// leave stale data on error so summary card can degrade gracefully
} finally {
loading.value = false
}
}
watch(() => filters.value.libraryIds.join(','), load, { immediate: true })
return { data, loading }
}
@@ -0,0 +1,155 @@
import { readCssColor } from '@/lib/echarts'
type Rgb = {
r: number
g: number
b: number
}
let colorParserContext: CanvasRenderingContext2D | null = null
export interface HeatmapPalette {
axisColor: string
borderColor: string
tooltipBackground: string
tooltipBorder: string
tooltipText: string
seriesColor: string
scale: string[]
}
interface BuildHeatmapPaletteOptions {
theme: 'light' | 'dark'
profile?: 'soft' | 'github'
}
function clampChannel(value: number): number {
return Math.max(0, Math.min(255, Math.round(value)))
}
function parseRgb(color: string): Rgb | null {
const match = color.trim().match(/^rgba?\(([^)]+)\)$/i)
if (!match) return null
const channels = match[1]
if (!channels) return null
const parts = channels.split(',').map((part) => Number.parseFloat(part.trim()))
const r = parts[0] ?? Number.NaN
const g = parts[1] ?? Number.NaN
const b = parts[2] ?? Number.NaN
if (Number.isNaN(r) || Number.isNaN(g) || Number.isNaN(b)) return null
return { r, g, b }
}
function parseHex(color: string): Rgb | null {
const normalized = color.trim().replace(/^#/, '')
if (!/^[\da-f]{3}$|^[\da-f]{6}$/i.test(normalized)) return null
const hex =
normalized.length === 3
? normalized
.split('')
.map((c) => `${c}${c}`)
.join('')
: normalized
const value = Number.parseInt(hex, 16)
return {
r: (value >> 16) & 255,
g: (value >> 8) & 255,
b: value & 255,
}
}
function parseViaCanvas(color: string): Rgb | null {
if (typeof document === 'undefined') return null
if (!colorParserContext) {
const canvas = document.createElement('canvas')
colorParserContext = canvas.getContext('2d')
}
if (!colorParserContext) return null
try {
colorParserContext.fillStyle = '#000000'
colorParserContext.fillStyle = color
} catch {
return null
}
const normalized = String(colorParserContext.fillStyle)
return parseRgb(normalized) ?? parseHex(normalized)
}
function parseColor(color: string): Rgb | null {
return parseRgb(color) ?? parseHex(color) ?? parseViaCanvas(color)
}
function rgbString(color: Rgb): string {
return `rgb(${clampChannel(color.r)}, ${clampChannel(color.g)}, ${clampChannel(color.b)})`
}
function rgbaString(color: Rgb, alpha: number): string {
return `rgba(${clampChannel(color.r)}, ${clampChannel(color.g)}, ${clampChannel(color.b)}, ${alpha})`
}
function resolveCssColor(rawValue: string, fallback: string): Rgb {
const value = rawValue.trim() || fallback
const probe = document.createElement('span')
probe.style.color = value
probe.style.position = 'absolute'
probe.style.pointerEvents = 'none'
probe.style.opacity = '0'
document.body.appendChild(probe)
const resolved = window.getComputedStyle(probe).color
probe.remove()
return parseColor(resolved) ?? parseColor(fallback) ?? { r: 100, g: 116, b: 139 }
}
function withAlpha(color: string, alpha: number): string {
if (alpha <= 0) return 'transparent'
if (alpha >= 1) return color
const parsed = parseColor(color)
if (parsed) return rgbaString(parsed, alpha)
const pct = Math.round(alpha * 1000) / 10
return `color-mix(in srgb, ${color} ${pct}%, transparent)`
}
export function buildHeatmapPalette({ theme, profile = 'soft' }: BuildHeatmapPaletteOptions): HeatmapPalette {
const rootStyle = window.getComputedStyle(document.documentElement)
const isDark = theme === 'dark'
const axis = resolveCssColor(rootStyle.getPropertyValue('--muted-foreground'), isDark ? 'rgb(163, 172, 189)' : 'rgb(100, 116, 139)')
const border = resolveCssColor(rootStyle.getPropertyValue('--border'), isDark ? 'rgb(52, 64, 84)' : 'rgb(226, 232, 240)')
const borderRaw = readCssColor('--border')
const card = resolveCssColor(rootStyle.getPropertyValue('--card'), isDark ? 'rgb(30, 41, 59)' : 'rgb(255, 255, 255)')
const seriesBase = readCssColor('--chart-1')
const foreground = resolveCssColor(rootStyle.getPropertyValue('--foreground'), isDark ? 'rgb(241, 245, 249)' : 'rgb(15, 23, 42)')
let scale: string[]
if (profile === 'github') {
// 0% should reveal the chart surface. 100% should match --chart-1 exactly.
scale = [
withAlpha(seriesBase, 0),
withAlpha(seriesBase, isDark ? 0.35 : 0.28),
withAlpha(seriesBase, isDark ? 0.56 : 0.45),
withAlpha(seriesBase, isDark ? 0.78 : 0.66),
withAlpha(seriesBase, 1),
]
} else {
scale = [
withAlpha(seriesBase, 0),
withAlpha(seriesBase, isDark ? 0.2 : 0.16),
withAlpha(seriesBase, isDark ? 0.38 : 0.3),
withAlpha(seriesBase, isDark ? 0.6 : 0.5),
withAlpha(seriesBase, 1),
]
}
return {
axisColor: rgbString(axis),
borderColor: borderRaw,
tooltipBackground: rgbaString(card, isDark ? 0.96 : 0.98),
tooltipBorder: rgbaString(border, isDark ? 0.75 : 0.9),
tooltipText: rgbString(foreground),
seriesColor: seriesBase,
scale,
}
}
@@ -0,0 +1,136 @@
import type { Component } from 'vue'
import {
BarChart3,
Calendar,
CalendarDays,
CalendarRange,
Clock3,
BookOpen,
Goal,
Globe,
HardDrive,
ListChecks,
PieChart,
Rabbit,
Tag,
TrendingUp,
Users,
Waypoints,
} from 'lucide-vue-next'
import { DEFAULT_STATISTICS_CHART_ORDER, type StatisticsChartId } from '@projectx/types'
export type StatisticsChartSize = '1x1' | '2x1' | '2x2' | '1x2' | '3x1'
export interface StatisticsChartMetaEntry {
label: string
icon: Component
size: StatisticsChartSize
}
export const STATISTICS_CHART_META: Record<StatisticsChartId, StatisticsChartMetaEntry> = {
'format-distribution': {
label: 'Format Distribution',
icon: PieChart,
size: '1x1',
},
'language-distribution': {
label: 'Language Distribution',
icon: Globe,
size: '1x1',
},
'books-added-over-time': {
label: 'Books Added Over Time',
icon: TrendingUp,
size: '2x1',
},
'storage-by-format': {
label: 'Storage by Format',
icon: HardDrive,
size: '1x1',
},
'publication-decade': {
label: 'Publication Decade',
icon: CalendarDays,
size: '1x1',
},
'top-authors': {
label: 'Top 25 Authors',
icon: Users,
size: '2x1',
},
'metadata-completeness': {
label: 'Metadata Completeness',
icon: ListChecks,
size: '1x1',
},
'genre-distribution': {
label: 'Genre Distribution',
icon: Tag,
size: '2x1',
},
'metadata-score-distribution': {
label: 'Metadata Score Distribution',
icon: BarChart3,
size: '1x1',
},
'library-metadata-completeness': {
label: 'Library Metadata Completeness',
icon: ListChecks,
size: '2x1',
},
'format-share-over-time': {
label: 'Format Share Over Time',
icon: TrendingUp,
size: '2x1',
},
'genre-rank-over-time': {
label: 'Genre Rank Over Time',
icon: Tag,
size: '2x1',
},
'page-count-distribution': {
label: 'Page Count Distribution',
icon: BookOpen,
size: '1x1',
},
'reading-heatmap': {
label: 'Reading Heatmap',
icon: Calendar,
size: '2x1',
},
'peak-reading-hours': {
label: 'Peak Reading Hours',
icon: Clock3,
size: '2x1',
},
'favorite-reading-days': {
label: 'Favorite Reading Days',
icon: CalendarDays,
size: '1x1',
},
'completion-timeline': {
label: 'Completion Timeline',
icon: CalendarRange,
size: '2x1',
},
'goal-trajectory': {
label: 'Pace vs Goal',
icon: Goal,
size: '2x1',
},
'progress-funnel': {
label: 'Progress Funnel',
icon: Waypoints,
size: '1x1',
},
'completion-latency': {
label: 'Completion Latency',
icon: Rabbit,
size: '1x1',
},
}
export const STATISTICS_CHART_IDS: StatisticsChartId[] = DEFAULT_STATISTICS_CHART_ORDER.filter(
(id): id is StatisticsChartId => id in STATISTICS_CHART_META,
)
+3 -2
View File
@@ -1,6 +1,6 @@
import { use, registerTheme } from 'echarts/core'
import { SVGRenderer } from 'echarts/renderers'
import { BarChart, BoxplotChart, HeatmapChart, LineChart, PieChart, SunburstChart } from 'echarts/charts'
import { BarChart, BoxplotChart, FunnelChart, HeatmapChart, LineChart, PieChart, SunburstChart } from 'echarts/charts'
import {
CalendarComponent,
GridComponent,
@@ -20,6 +20,7 @@ use([
PieChart,
BarChart,
LineChart,
FunnelChart,
HeatmapChart,
BoxplotChart,
SunburstChart,
@@ -33,7 +34,7 @@ use([
MarkAreaComponent,
])
function readCssColor(varName: string): string {
export function readCssColor(varName: string): string {
const el = document.createElement('span')
el.style.color = `var(${varName})`
document.body.appendChild(el)