mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
feat: add user-level statistics pipeline and dashboard charts
This commit is contained in:
@@ -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>
|
||||
+48
-18
@@ -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,
|
||||
)
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user