feat(stats): add user analytics charts and stabilize ECharts theme

This commit is contained in:
neon
2026-03-26 04:28:08 -06:00
parent 1418957b6f
commit ba37f19b45
49 changed files with 1574 additions and 227 deletions
+10 -10
View File
@@ -33,16 +33,16 @@
"clsx": "^2.1.1",
"echarts": "^6.0.0",
"howler": "^2.2.4",
"lucide-vue-next": "^0.577.0",
"lucide-vue-next": "^1.0.0",
"pdfjs-dist": "^5.5.207",
"pinia": "^3.0.4",
"reka-ui": "^2.9.2",
"socket.io-client": "^4.8.3",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.2",
"vue": "^3.5.30",
"vue": "^3.5.31",
"vue-draggable-plus": "^0.6.1",
"vue-echarts": "^8.0.1",
"vue-echarts": "8.1.0-beta.2",
"vue-router": "^5.0.4",
"vue-sonner": "^2.0.9",
"vue-virtual-scroller": "^1.1.2"
@@ -52,25 +52,25 @@
"@types/jsdom": "^28.0.1",
"@types/node": "^25.5.0",
"@vitejs/plugin-vue": "^6.0.5",
"@vitest/coverage-v8": "^4.1.0",
"@vitest/coverage-v8": "^4.1.1",
"@vitest/eslint-plugin": "^1.6.13",
"@vue/eslint-config-typescript": "^14.7.0",
"@vue/test-utils": "^2.4.6",
"@vue/tsconfig": "^0.9.0",
"@vue/tsconfig": "^0.9.1",
"eslint": "^10.1.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-oxlint": "~1.56.0",
"eslint-plugin-oxlint": "~1.57.0",
"eslint-plugin-vue": "~10.8.0",
"jiti": "^2.6.1",
"jsdom": "^29.0.1",
"npm-run-all2": "^8.0.4",
"oxlint": "~1.56.0",
"oxlint": "~1.57.0",
"prettier": "3.8.1",
"tw-animate-css": "^1.4.0",
"typescript": "~5.9.3",
"vite": "^8.0.1",
"vite-plugin-vue-devtools": "^8.1.0",
"vitest": "^4.1.0",
"vite": "^8.0.2",
"vite-plugin-vue-devtools": "^8.1.1",
"vitest": "^4.1.1",
"vue-tsc": "^3.2.6"
},
"engines": {
+6 -16
View File
@@ -1,33 +1,23 @@
<script setup lang="ts">
import { computed, onMounted, provide, ref, watch } from 'vue'
import { computed, provide } from 'vue'
import { INIT_OPTIONS_KEY, THEME_KEY } from 'vue-echarts'
import { useThemeStore } from '@/stores/theme'
import { useChangePasswordDialog } from '@/composables/useChangePasswordDialog'
import { initChartThemes } from '@/lib/echarts'
import { useThemeStore } from '@/stores/theme'
import { getProjectxThemeName, initChartThemes } from '@/lib/echarts'
import ChangePasswordDialog from '@/features/auth/ChangePasswordDialog.vue'
import { Toaster } from '@/components/ui/sonner'
import { TooltipProvider } from '@/components/ui/tooltip'
const themeStore = useThemeStore()
const { isOpen } = useChangePasswordDialog()
const themeStore = useThemeStore()
const themeVersion = ref(0)
initChartThemes()
provide(INIT_OPTIONS_KEY, { renderer: 'svg' })
provide(
THEME_KEY,
computed(() => `projectx-v${themeVersion.value}`),
computed(() => getProjectxThemeName(themeStore.theme, themeStore.accent)),
)
onMounted(() => {
initChartThemes(0)
})
watch([() => themeStore.theme, () => themeStore.accent], () => {
const v = themeVersion.value + 1
initChartThemes(v)
themeVersion.value = v
})
</script>
<template>
@@ -1,8 +1,10 @@
import { api } from '@/lib/api'
import type {
BooksAddedDataPoint,
ChordDiagramData,
UserCompletionLatencyDistribution,
FormatShareOverTimeItem,
UserGenreReadingTimeItem,
UserGoalTrajectory,
StatisticsSummary,
UserCompletionTimelinePoint,
@@ -10,6 +12,8 @@ import type {
UserFavoriteDayStat,
UserProgressFunnelComparison,
UserPeakHourStat,
UserReadingPacePoint,
UserSessionArchetypePoint,
UserStatisticsSummary,
FormatDistributionItem,
GenreRankOverTimeItem,
@@ -161,3 +165,27 @@ export async function fetchUserCompletionLatency(filters: StatisticsFilterConfig
if (!res.ok) throw new Error(`User completion latency request failed: ${res.status}`)
return res.json() as Promise<UserCompletionLatencyDistribution>
}
export async function fetchUserGenreReadingTime(filters: StatisticsFilterConfig): Promise<UserGenreReadingTimeItem[]> {
const res = await api(`/api/v1/user-statistics/genre-reading-time${buildParams(filters, { days: '365' })}`)
if (!res.ok) throw new Error(`User genre reading time request failed: ${res.status}`)
return res.json() as Promise<UserGenreReadingTimeItem[]>
}
export async function fetchUserReadingPace(filters: StatisticsFilterConfig): Promise<UserReadingPacePoint[]> {
const res = await api(`/api/v1/user-statistics/reading-pace${buildParams(filters, { days: '1825' })}`)
if (!res.ok) throw new Error(`User reading pace request failed: ${res.status}`)
return res.json() as Promise<UserReadingPacePoint[]>
}
export async function fetchGenreCooccurrence(filters: StatisticsFilterConfig): Promise<ChordDiagramData> {
const res = await api(`/api/v1/statistics/genre-cooccurrence${buildParams(filters)}`)
if (!res.ok) throw new Error(`Genre co-occurrence request failed: ${res.status}`)
return res.json() as Promise<ChordDiagramData>
}
export async function fetchUserSessionArchetypes(filters: StatisticsFilterConfig): Promise<UserSessionArchetypePoint[]> {
const res = await api(`/api/v1/user-statistics/session-archetypes${buildParams(filters, { days: '365' })}`)
if (!res.ok) throw new Error(`User session archetypes request failed: ${res.status}`)
return res.json() as Promise<UserSessionArchetypePoint[]>
}
@@ -6,30 +6,38 @@ import type { ChartConfigEntry, StatisticsChartId } from '@projectx/types'
import { STATISTICS_CHART_META, type StatisticsChartSize } from '../statistics-chart-meta'
import { useStatisticsConfig } from '../composables/useStatisticsConfig'
defineProps<{ charts: ChartConfigEntry[] }>()
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')),
'format-distribution': defineAsyncComponent(() => import('./library/FormatDistributionChart.vue')),
'language-distribution': defineAsyncComponent(() => import('./library/LanguageDistributionChart.vue')),
'books-added-over-time': defineAsyncComponent(() => import('./library/BooksAddedOverTimeChart.vue')),
'storage-by-format': defineAsyncComponent(() => import('./library/StorageByFormatChart.vue')),
'publication-decade': defineAsyncComponent(() => import('./library/PublicationDecadeChart.vue')),
'top-authors': defineAsyncComponent(() => import('./library/TopAuthorsChart.vue')),
'metadata-completeness': defineAsyncComponent(() => import('./library/MetadataCompletenessChart.vue')),
'genre-distribution': defineAsyncComponent(() => import('./library/GenreDistributionChart.vue')),
'genre-cooccurrence': defineAsyncComponent(() => import('./library/GenreCooccurrenceChart.vue')),
'metadata-score-distribution': defineAsyncComponent(() => import('./library/MetadataScoreDistributionChart.vue')),
'library-metadata-completeness': defineAsyncComponent(() => import('./library/LibraryMetadataCompletenessHeatmapChart.vue')),
'format-share-over-time': defineAsyncComponent(() => import('./library/FormatShareOverTimeChart.vue')),
'genre-rank-over-time': defineAsyncComponent(() => import('./library/GenreRankOverTimeChart.vue')),
'page-count-distribution': defineAsyncComponent(() => import('./library/PageCountDistributionChart.vue')),
'reading-heatmap': defineAsyncComponent(() => import('./user/ReadingHeatmapChart.vue')),
'peak-reading-hours': defineAsyncComponent(() => import('./user/PeakReadingHoursChart.vue')),
'favorite-reading-days': defineAsyncComponent(() => import('./user/FavoriteReadingDaysChart.vue')),
'completion-timeline': defineAsyncComponent(() => import('./user/CompletionTimelineChart.vue')),
'goal-trajectory': defineAsyncComponent(() => import('./user/GoalTrajectoryChart.vue')),
'progress-funnel': defineAsyncComponent(() => import('./user/ProgressFunnelChart.vue')),
'completion-latency': defineAsyncComponent(() => import('./user/CompletionLatencyChart.vue')),
'genre-reading-time': defineAsyncComponent(() => import('./user/GenreReadingTimeTreemapChart.vue')),
'reading-pace': defineAsyncComponent(() => import('./user/ReadingPaceScatterChart.vue')),
'books-completed': defineAsyncComponent(() => import('./user/BooksCompletedChart.vue')),
'reading-clock': defineAsyncComponent(() => import('./user/ReadingClockChart.vue')),
'session-archetypes': defineAsyncComponent(() => import('./user/SessionArchetypesChart.vue')),
}
const { visibleCharts, reorder } = useStatisticsConfig()
const { reorder } = useStatisticsConfig()
function handleReorder(newList: ChartConfigEntry[]) {
reorder(newList)
@@ -46,13 +54,13 @@ function tileClass(size: StatisticsChartSize): string {
<template>
<VueDraggable
:model-value="visibleCharts"
:model-value="charts"
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="tileClass(STATISTICS_CHART_META[chart.id].size)">
<div v-for="chart in charts" :key="chart.id" :class="tileClass(STATISTICS_CHART_META[chart.id].size)">
<component :is="CHART_COMPONENTS[chart.id]" />
</div>
</VueDraggable>
@@ -13,16 +13,34 @@ import { useStatisticsConfig } from '../composables/useStatisticsConfig'
import StatisticsGrid from './StatisticsGrid.vue'
import StatisticsSummaryCard from './StatisticsSummaryCard.vue'
const { orderedCharts, filters, init, toggleVisibility, reorder, resetToDefaults, setLibraryFilter } = useStatisticsConfig()
const {
orderedLibraryCharts,
orderedUserCharts,
visibleLibraryCharts,
visibleUserCharts,
libraryChartCount,
userChartCount,
visibleLibraryChartCount,
visibleUserChartCount,
filters,
init,
toggleVisibility,
reorder,
resetToDefaults,
setLibraryFilter,
} = useStatisticsConfig()
const { libraries, fetchLibraries } = useLibraries()
const configOpen = ref(false)
const activeTab = ref<'library' | 'user'>('library')
// Called in setup (not onMounted) so filters are populated before chart children mount.
init()
onMounted(fetchLibraries)
const visibleCount = computed(() => orderedCharts.value.filter((c) => c.visible).length)
const totalCount = computed(() => orderedCharts.value.length)
const activeVisibleCount = computed(() => (activeTab.value === 'library' ? visibleLibraryChartCount.value : visibleUserChartCount.value))
const activeTotalCount = computed(() => (activeTab.value === 'library' ? libraryChartCount.value : userChartCount.value))
const activeOrderedCharts = computed(() => (activeTab.value === 'library' ? orderedLibraryCharts.value : orderedUserCharts.value))
const libraryLabel = computed(() => {
const count = filters.value.libraryIds.length
@@ -72,17 +90,38 @@ function openConfig() {
function chartMeta(id: StatisticsChartId) {
return STATISTICS_CHART_META[id]
}
function setTab(tab: 'library' | 'user') {
activeTab.value = tab
}
</script>
<template>
<div class="flex flex-col gap-6 pt-4">
<div class="flex items-center justify-between">
<div>
<h1 class="text-foreground text-[22px] font-semibold">Library Statistics</h1>
<div class="flex items-center gap-1 rounded-lg bg-muted p-1">
<button
:class="[
'rounded-md px-4 py-1.5 text-sm font-medium transition-colors',
activeTab === 'library' ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground',
]"
@click="setTab('library')"
>
Library Stats
</button>
<button
:class="[
'rounded-md px-4 py-1.5 text-sm font-medium transition-colors',
activeTab === 'user' ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground',
]"
@click="setTab('user')"
>
My Reading
</button>
</div>
<div class="flex items-center gap-2">
<Popover v-if="libraries.length > 1">
<Popover v-if="activeTab === 'library' && libraries.length > 1">
<PopoverTrigger as-child>
<button
:class="[
@@ -142,7 +181,7 @@ function chartMeta(id: StatisticsChartId) {
<div class="flex items-center justify-between pr-8">
<SheetTitle>Configure Charts</SheetTitle>
<span class="bg-muted text-muted-foreground rounded-full px-2 py-0.5 text-xs font-medium tabular-nums">
{{ visibleCount }} / {{ totalCount }}
{{ activeVisibleCount }} / {{ activeTotalCount }}
</span>
</div>
<SheetDescription>Drag to reorder, toggle to show or hide.</SheetDescription>
@@ -150,14 +189,14 @@ function chartMeta(id: StatisticsChartId) {
<div class="flex-1 overflow-y-auto px-4">
<VueDraggable
:model-value="orderedCharts"
:model-value="activeOrderedCharts"
class="flex flex-col gap-2"
handle=".drawer-drag-handle"
:animation="150"
@update:model-value="handleReorder"
>
<div
v-for="chart in orderedCharts"
v-for="chart in activeOrderedCharts"
:key="chart.id"
:class="[
'border-border/50 bg-muted/40 flex items-center gap-3 rounded-lg border px-3 py-2.5 transition-opacity',
@@ -185,6 +224,11 @@ function chartMeta(id: StatisticsChartId) {
</SheetContent>
</Sheet>
<StatisticsGrid />
<div v-show="activeTab === 'library'">
<StatisticsGrid :charts="visibleLibraryCharts" />
</div>
<div v-show="activeTab === 'user'">
<StatisticsGrid :charts="visibleUserCharts" />
</div>
</div>
</template>
@@ -4,9 +4,9 @@ import VChart from 'vue-echarts'
import { TrendingUp } from 'lucide-vue-next'
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
import { useBooksAddedOverTime } from '../composables/useBooksAddedOverTime'
import { useStatisticsConfig } from '../composables/useStatisticsConfig'
import ChartCard from './ChartCard.vue'
import { useBooksAddedOverTime } from '../../composables/useBooksAddedOverTime'
import { useStatisticsConfig } from '../../composables/useStatisticsConfig'
import ChartCard from '../ChartCard.vue'
const MONTH_NAMES = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
@@ -30,8 +30,6 @@ watchEffect(() => {
option.value = {
tooltip: {
trigger: 'axis',
confine: true,
enterable: false,
formatter: (params: { name: string; value: number }[]) => {
const p = params[0]
if (!p) return ''
@@ -58,10 +56,8 @@ watchEffect(() => {
{
type: 'bar',
data: data.value.items.map((d) => d.count),
cursor: 'default',
itemStyle: { borderRadius: [3, 3, 0, 0] },
barMaxWidth: 40,
emphasis: { disabled: true },
},
],
}
@@ -4,8 +4,8 @@ import VChart from 'vue-echarts'
import { PieChart } from 'lucide-vue-next'
import { breakpointsTailwind, useBreakpoints } from '@vueuse/core'
import { useFormatDistribution } from '../composables/useFormatDistribution'
import ChartCard from './ChartCard.vue'
import { useFormatDistribution } from '../../composables/useFormatDistribution'
import ChartCard from '../ChartCard.vue'
const { data, loading, error } = useFormatDistribution()
const { md } = useBreakpoints(breakpointsTailwind)
@@ -15,7 +15,7 @@ const option = shallowRef({})
watchEffect(() => {
if (!data.value.items.length) return
option.value = {
tooltip: { trigger: 'item', confine: true, enterable: false },
tooltip: { trigger: 'item' },
legend: {
orient: md.value ? 'vertical' : 'horizontal',
right: md.value ? '2%' : 'auto',
@@ -28,9 +28,7 @@ watchEffect(() => {
radius: ['40%', '70%'],
center: md.value ? ['38%', '50%'] : ['50%', '44%'],
data: data.value.items.map((item) => ({ name: item.format.toUpperCase(), value: item.count })),
cursor: 'default',
label: { show: false },
emphasis: { disabled: true },
},
],
}
@@ -3,8 +3,8 @@ import { shallowRef, watchEffect } from 'vue'
import VChart from 'vue-echarts'
import { TrendingUp } from 'lucide-vue-next'
import { useFormatShareOverTime } from '../composables/useFormatShareOverTime'
import ChartCard from './ChartCard.vue'
import { useFormatShareOverTime } from '../../composables/useFormatShareOverTime'
import ChartCard from '../ChartCard.vue'
const MONTH_NAMES = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
@@ -32,8 +32,6 @@ watchEffect(() => {
option.value = {
tooltip: {
trigger: 'axis',
confine: true,
enterable: false,
formatter: (params: { dataIndex: number; marker: string; seriesName: string }[]) => {
const first = params[0]
if (!first) return ''
@@ -0,0 +1,56 @@
<script setup lang="ts">
import { computed, shallowRef, watch } from 'vue'
import VChart from 'vue-echarts'
import { GitMerge } from 'lucide-vue-next'
import type { ChordDiagramData } from '@projectx/types'
import { useGenreCooccurrence } from '../../composables/useGenreCooccurrence'
import ChartCard from '../ChartCard.vue'
const { data, loading, error } = useGenreCooccurrence()
const option = shallowRef({})
const isEmpty = computed(() => data.value.nodes.length === 0)
function buildOption(chordData: ChordDiagramData) {
if (!chordData.nodes.length) return {}
return {
tooltip: {
trigger: 'item',
formatter: (params: { dataType: string; name: string; value: number; data: { source?: string; target?: string; value?: number } }) => {
if (params.dataType === 'edge') {
return `<strong>${params.data.source} + ${params.data.target}</strong><br/>${params.data.value} shared book${params.data.value === 1 ? '' : 's'}`
}
return `<strong>${params.name}</strong>`
},
},
series: [
{
type: 'chord',
clockwise: false,
label: { show: true, fontSize: 11 },
lineStyle: { color: 'target', opacity: 0.6 },
itemStyle: { borderWidth: 1 },
emphasis: { focus: 'adjacency' },
data: chordData.nodes,
links: chordData.links,
},
],
}
}
watch(
() => data.value,
(nextData) => {
option.value = buildOption(nextData)
},
{ immediate: true },
)
</script>
<template>
<ChartCard title="Genre Co-occurrence" :icon="GitMerge" :color-index="3" :loading :error :empty="isEmpty">
<VChart :option autoresize style="height: 100%" />
</ChartCard>
</template>
@@ -3,8 +3,8 @@ import { shallowRef, watchEffect } from 'vue'
import VChart from 'vue-echarts'
import { Tag } from 'lucide-vue-next'
import { useGenreDistribution } from '../composables/useGenreDistribution'
import ChartCard from './ChartCard.vue'
import { useGenreDistribution } from '../../composables/useGenreDistribution'
import ChartCard from '../ChartCard.vue'
const { data, loading, error } = useGenreDistribution()
const option = shallowRef({})
@@ -14,8 +14,6 @@ watchEffect(() => {
option.value = {
tooltip: {
trigger: 'axis',
confine: true,
enterable: false,
formatter: (params: { name: string; value: number }[]) => {
const p = params[0]
if (!p) return ''
@@ -44,10 +42,8 @@ watchEffect(() => {
{
type: 'bar',
data: data.value.items.map((d) => d.count),
cursor: 'default',
itemStyle: { borderRadius: [3, 3, 0, 0] },
barMaxWidth: 32,
emphasis: { disabled: true },
},
],
}
@@ -3,8 +3,8 @@ import { shallowRef, watchEffect } from 'vue'
import VChart from 'vue-echarts'
import { Tag } from 'lucide-vue-next'
import { useGenreRankOverTime } from '../composables/useGenreRankOverTime'
import ChartCard from './ChartCard.vue'
import { useGenreRankOverTime } from '../../composables/useGenreRankOverTime'
import ChartCard from '../ChartCard.vue'
const { data, loading, error } = useGenreRankOverTime()
const option = shallowRef({})
@@ -32,8 +32,6 @@ watchEffect(() => {
option.value = {
tooltip: {
trigger: 'axis',
confine: true,
enterable: false,
formatter: (params: { name: string; value: number }[]) => {
const p = params[0]
if (!p) return ''
@@ -58,7 +56,6 @@ watchEffect(() => {
data: rows.map((r) => r.count),
barMaxWidth: 38,
itemStyle: { borderRadius: [3, 3, 0, 0] },
emphasis: { disabled: true },
},
],
}
@@ -68,8 +65,6 @@ watchEffect(() => {
option.value = {
tooltip: {
trigger: 'axis',
confine: true,
enterable: false,
formatter: (params: { seriesName: string; data: number; axisValue: number }[]) => {
const rows = params
.filter((p) => Number.isFinite(p.data))
@@ -4,8 +4,8 @@ import VChart from 'vue-echarts'
import { Globe } from 'lucide-vue-next'
import { breakpointsTailwind, useBreakpoints } from '@vueuse/core'
import { useLanguageDistribution } from '../composables/useLanguageDistribution'
import ChartCard from './ChartCard.vue'
import { useLanguageDistribution } from '../../composables/useLanguageDistribution'
import ChartCard from '../ChartCard.vue'
const { data, loading, error } = useLanguageDistribution()
const { md } = useBreakpoints(breakpointsTailwind)
@@ -15,7 +15,7 @@ const option = shallowRef({})
watchEffect(() => {
if (!data.value.items.length) return
option.value = {
tooltip: { trigger: 'item', confine: true, enterable: false },
tooltip: { trigger: 'item' },
legend: {
orient: md.value ? 'vertical' : 'horizontal',
right: md.value ? '2%' : 'auto',
@@ -28,9 +28,7 @@ watchEffect(() => {
radius: ['40%', '70%'],
center: md.value ? ['38%', '50%'] : ['50%', '44%'],
data: data.value.items.map((item) => ({ name: item.language.toUpperCase(), value: item.count })),
cursor: 'default',
label: { show: false },
emphasis: { disabled: true },
},
],
}
@@ -4,9 +4,9 @@ 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'
import { buildHeatmapPalette } from '../../heatmap-palette'
import { useLibraryMetadataCompleteness } from '../../composables/useLibraryMetadataCompleteness'
import ChartCard from '../ChartCard.vue'
const FIELD_ORDER = [
'Title',
@@ -55,8 +55,7 @@ watchEffect(() => {
option.value = {
tooltip: {
confine: true,
enterable: false,
appendToBody: true,
backgroundColor: palette.tooltipBackground,
borderColor: palette.tooltipBorder,
borderWidth: 1,
@@ -3,8 +3,8 @@ import { shallowRef, watchEffect } from 'vue'
import VChart from 'vue-echarts'
import { ListChecks } from 'lucide-vue-next'
import { useMetadataCompleteness } from '../composables/useMetadataCompleteness'
import ChartCard from './ChartCard.vue'
import { useMetadataCompleteness } from '../../composables/useMetadataCompleteness'
import ChartCard from '../ChartCard.vue'
const { data, loading, error } = useMetadataCompleteness()
const option = shallowRef({})
@@ -14,8 +14,6 @@ watchEffect(() => {
option.value = {
tooltip: {
trigger: 'axis',
confine: true,
enterable: false,
formatter: (params: { name: string; value: number }[]) => {
const p = params[0]
if (!p) return ''
@@ -45,10 +43,8 @@ watchEffect(() => {
{
type: 'bar',
data: [...data.value.items].reverse().map((d) => (d.totalCount > 0 ? Math.round((d.presentCount / d.totalCount) * 100) : 0)),
cursor: 'default',
itemStyle: { borderRadius: [3, 3, 0, 0] },
barMaxWidth: 40,
emphasis: { disabled: true },
},
],
}
@@ -3,8 +3,8 @@ import { shallowRef, watchEffect } from 'vue'
import VChart from 'vue-echarts'
import { BarChart3 } from 'lucide-vue-next'
import { useMetadataScoreDistribution } from '../composables/useMetadataScoreDistribution'
import ChartCard from './ChartCard.vue'
import { useMetadataScoreDistribution } from '../../composables/useMetadataScoreDistribution'
import ChartCard from '../ChartCard.vue'
const { data, loading, error } = useMetadataScoreDistribution()
const option = shallowRef({})
@@ -25,8 +25,6 @@ watchEffect(() => {
option.value = {
tooltip: {
trigger: 'axis',
confine: true,
enterable: false,
formatter: (params: { name: string; value: number }[]) => {
const p = params[0]
if (!p) return ''
@@ -51,7 +49,6 @@ watchEffect(() => {
data: data.value.bins.map((b) => b.count),
barMaxWidth: 44,
itemStyle: { borderRadius: [3, 3, 0, 0] },
emphasis: { disabled: true },
markArea:
p25Index != null && p75Index != null
? {
@@ -3,8 +3,8 @@ import { shallowRef, watchEffect } from 'vue'
import VChart from 'vue-echarts'
import { BookOpen } from 'lucide-vue-next'
import { usePageCountDistribution } from '../composables/usePageCountDistribution'
import ChartCard from './ChartCard.vue'
import { usePageCountDistribution } from '../../composables/usePageCountDistribution'
import ChartCard from '../ChartCard.vue'
const { data, loading, error } = usePageCountDistribution()
const option = shallowRef({})
@@ -15,8 +15,6 @@ watchEffect(() => {
option.value = {
tooltip: {
trigger: 'item',
confine: true,
enterable: false,
formatter: (params: { dataIndex: number }) => {
const row = data.value.items[params.dataIndex]
if (!row) return ''
@@ -45,7 +43,6 @@ watchEffect(() => {
{
type: 'boxplot',
data: data.value.items.map((item) => [item.min, item.q1, item.median, item.q3, item.max]),
emphasis: { disabled: true },
},
],
}
@@ -3,8 +3,8 @@ import { shallowRef, watchEffect } from 'vue'
import VChart from 'vue-echarts'
import { CalendarDays } from 'lucide-vue-next'
import { usePublicationDecade } from '../composables/usePublicationDecade'
import ChartCard from './ChartCard.vue'
import { usePublicationDecade } from '../../composables/usePublicationDecade'
import ChartCard from '../ChartCard.vue'
const { data, loading, error } = usePublicationDecade()
const option = shallowRef({})
@@ -14,8 +14,6 @@ watchEffect(() => {
option.value = {
tooltip: {
trigger: 'axis',
confine: true,
enterable: false,
formatter: (params: { name: string; value: number }[]) => {
const p = params[0]
if (!p) return ''
@@ -38,10 +36,8 @@ watchEffect(() => {
{
type: 'bar',
data: data.value.items.map((d) => d.count),
cursor: 'default',
itemStyle: { borderRadius: [3, 3, 0, 0] },
barMaxWidth: 48,
emphasis: { disabled: true },
},
],
}
@@ -4,8 +4,8 @@ import VChart from 'vue-echarts'
import { HardDrive } from 'lucide-vue-next'
import { breakpointsTailwind, useBreakpoints } from '@vueuse/core'
import { useStorageByFormat } from '../composables/useStorageByFormat'
import ChartCard from './ChartCard.vue'
import { useStorageByFormat } from '../../composables/useStorageByFormat'
import ChartCard from '../ChartCard.vue'
const { data, loading, error } = useStorageByFormat()
const { md } = useBreakpoints(breakpointsTailwind)
@@ -23,8 +23,6 @@ watchEffect(() => {
option.value = {
tooltip: {
trigger: 'item',
confine: true,
enterable: false,
formatter: (params: { name: string; value: number; percent: number }) => `${params.name}: ${formatBytes(params.value)} (${params.percent}%)`,
},
legend: {
@@ -39,9 +37,7 @@ watchEffect(() => {
radius: ['40%', '70%'],
center: md.value ? ['38%', '50%'] : ['50%', '44%'],
data: data.value.items.map((item) => ({ name: item.format.toUpperCase(), value: item.sizeBytes })),
cursor: 'default',
label: { show: false },
emphasis: { disabled: true },
},
],
}
@@ -3,8 +3,8 @@ import { shallowRef, watchEffect } from 'vue'
import VChart from 'vue-echarts'
import { Users } from 'lucide-vue-next'
import { useTopAuthors } from '../composables/useTopAuthors'
import ChartCard from './ChartCard.vue'
import { useTopAuthors } from '../../composables/useTopAuthors'
import ChartCard from '../ChartCard.vue'
const { data, loading, error } = useTopAuthors()
const option = shallowRef({})
@@ -14,8 +14,6 @@ watchEffect(() => {
option.value = {
tooltip: {
trigger: 'axis',
confine: true,
enterable: false,
formatter: (params: { name: string; value: number }[]) => {
const p = params[0]
if (!p) return ''
@@ -44,10 +42,8 @@ watchEffect(() => {
{
type: 'bar',
data: data.value.items.map((d) => d.count),
cursor: 'default',
itemStyle: { borderRadius: [3, 3, 0, 0] },
barMaxWidth: 32,
emphasis: { disabled: true },
},
],
}
@@ -0,0 +1,80 @@
<script setup lang="ts">
import { computed, shallowRef, watchEffect } from 'vue'
import VChart from 'vue-echarts'
import { BookCheck } from 'lucide-vue-next'
import { useUserBooksCompleted } from '../../composables/useUserBooksCompleted'
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 } = useUserBooksCompleted()
const totalCompletions = computed(() => data.value.reduce((s, p) => s + p.count, 0))
const isEmpty = computed(() => totalCompletions.value === 0)
const hasEnoughData = computed(() => totalCompletions.value >= MIN_COMPLETIONS)
const option = shallowRef({})
watchEffect(() => {
option.value = {}
if (isEmpty.value || !hasEnoughData.value || !data.value.length) return
const labels = data.value.map((p) => `${MONTH_NAMES[p.month - 1]} ${p.year}`)
let running = 0
const cumulative = data.value.map((p) => {
running += p.count
return running
})
option.value = {
tooltip: {
trigger: 'axis',
formatter: (params: Array<{ axisValue: string; data: number }>) => {
const point = params[0]
if (!point) return ''
const label = point.data === 1 ? 'book' : 'books'
return `${point.axisValue}<br/><strong>${point.data}</strong> ${label} completed`
},
},
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: cumulative,
smooth: 0.3,
showSymbol: false,
step: false,
areaStyle: { opacity: 0.15 },
lineStyle: { width: 2.5 },
},
],
}
})
</script>
<template>
<ChartCard title="Books Completed" :icon="BookCheck" :color-index="3" :loading :error :empty="isEmpty">
<div v-if="!hasEnoughData" class="text-muted-foreground flex h-full items-center justify-center text-sm">
Complete at least {{ MIN_COMPLETIONS }} books to see your curve
</div>
<VChart v-else :option autoresize style="height: 100%" />
</ChartCard>
</template>
@@ -3,8 +3,8 @@ 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'
import { useUserCompletionLatency } from '../../composables/useUserCompletionLatency'
import ChartCard from '../ChartCard.vue'
const MIN_COMPLETIONS = 5
@@ -21,8 +21,6 @@ watchEffect(() => {
option.value = {
tooltip: {
trigger: 'axis',
confine: true,
enterable: false,
formatter: (params: Array<{ axisValue: string; data: number }>) => {
const point = params[0]
if (!point) return ''
@@ -49,8 +47,6 @@ watchEffect(() => {
data: data.value.buckets.map((bucket) => bucket.count),
barMaxWidth: 28,
itemStyle: { borderRadius: [4, 4, 0, 0] },
cursor: 'default',
emphasis: { disabled: true },
},
],
}
@@ -3,8 +3,8 @@ 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'
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
@@ -26,8 +26,6 @@ watchEffect(() => {
option.value = {
tooltip: {
trigger: 'axis',
confine: true,
enterable: false,
formatter: (params: Array<{ axisValue: string; data: number }>) => {
const point = params[0]
if (!point) return ''
@@ -60,8 +58,6 @@ watchEffect(() => {
showSymbol: false,
areaStyle: { opacity: 0.2 },
lineStyle: { width: 2 },
cursor: 'default',
emphasis: { disabled: true },
},
],
}
@@ -3,8 +3,8 @@ 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'
import { useUserFavoriteReadingDays } from '../../composables/useUserFavoriteReadingDays'
import ChartCard from '../ChartCard.vue'
const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
const MIN_EVENTS = 14
@@ -42,8 +42,6 @@ watchEffect(() => {
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 ''
@@ -77,8 +75,6 @@ watchEffect(() => {
data: averageMinutesByDay,
barMaxWidth: 30,
itemStyle: { borderRadius: [4, 4, 0, 0] },
cursor: 'default',
emphasis: { disabled: true },
},
],
}
@@ -0,0 +1,75 @@
<script setup lang="ts">
import { computed, shallowRef, watchEffect } from 'vue'
import VChart from 'vue-echarts'
import { Tag } from 'lucide-vue-next'
import { useUserGenreReadingTime } from '../../composables/useUserGenreReadingTime'
import ChartCard from '../ChartCard.vue'
const MIN_GENRES = 2
const { data, loading, error } = useUserGenreReadingTime()
const totalSeconds = computed(() => data.value.reduce((s, item) => s + item.readingSeconds, 0))
const isEmpty = computed(() => totalSeconds.value === 0)
const hasEnoughData = computed(() => data.value.length >= MIN_GENRES)
const option = shallowRef({})
watchEffect(() => {
option.value = {}
if (isEmpty.value || !hasEnoughData.value || !data.value.length) return
option.value = {
tooltip: {
trigger: 'item',
appendToBody: true,
formatter: (params: { name: string; value: number }) => {
const hours = (params.value / 3600).toFixed(1)
const pct = totalSeconds.value > 0 ? ((params.value / totalSeconds.value) * 100).toFixed(1) : '0'
return `<strong>${params.name}</strong><br/>${hours}h reading time<br/>${pct}% of total`
},
},
series: [
{
type: 'treemap',
roam: false,
nodeClick: false,
breadcrumb: { show: false },
data: data.value.map((item, i) => ({
name: item.genre,
value: item.readingSeconds,
itemStyle: {
color: `var(--chart-${(i % 10) + 1})`,
borderWidth: 2,
borderColor: 'var(--background)',
},
})),
label: {
show: true,
fontSize: 12,
fontWeight: 500,
overflow: 'truncate',
color: '#fff',
},
emphasis: { disabled: true },
upperLabel: { show: false },
levels: [
{
itemStyle: { borderWidth: 0, gapWidth: 3 },
},
],
},
],
}
})
</script>
<template>
<ChartCard title="Genre Reading Time" :icon="Tag" :color-index="6" :loading :error :empty="isEmpty">
<div v-if="!hasEnoughData" class="text-muted-foreground flex h-full items-center justify-center text-sm">
Need at least {{ MIN_GENRES }} genres with reading time
</div>
<VChart v-else :option autoresize style="height: 100%" />
</ChartCard>
</template>
@@ -3,8 +3,8 @@ 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'
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
@@ -26,8 +26,6 @@ watchEffect(() => {
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/>')}`
@@ -58,8 +56,6 @@ watchEffect(() => {
symbolSize: 5,
lineStyle: { width: 2.5 },
areaStyle: { opacity: 0.15 },
cursor: 'default',
emphasis: { disabled: true },
},
{
name: `Goal (${data.value.goalBooks}/yr)`,
@@ -69,8 +65,6 @@ watchEffect(() => {
showSymbol: true,
symbolSize: 5,
lineStyle: { width: 2, type: 'dashed' },
cursor: 'default',
emphasis: { disabled: true },
},
],
}
@@ -3,8 +3,8 @@ 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'
import { useUserPeakReadingHours } from '../../composables/useUserPeakReadingHours'
import ChartCard from '../ChartCard.vue'
const MIN_EVENTS = 20
@@ -22,8 +22,6 @@ watchEffect(() => {
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 ''
@@ -53,8 +51,6 @@ watchEffect(() => {
data: data.value.map((item) => Math.round(item.readingSeconds / 60)),
barMaxWidth: 24,
itemStyle: { borderRadius: [4, 4, 0, 0] },
cursor: 'default',
emphasis: { disabled: true },
},
],
}
@@ -4,8 +4,8 @@ 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'
import { useUserProgressFunnel } from '../../composables/useUserProgressFunnel'
import ChartCard from '../ChartCard.vue'
type FunnelMode = 'percent' | 'counts' | 'dropoff'
const MODE_LABELS: Record<FunnelMode, string> = {
@@ -87,8 +87,6 @@ watchEffect(() => {
tooltip: {
trigger: 'axis',
axisPointer: { type: 'shadow' },
confine: true,
enterable: false,
formatter: (params: Array<{ axisValue: string; data: number }>) => {
const point = params[0]
if (!point) return ''
@@ -116,8 +114,6 @@ watchEffect(() => {
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',
@@ -142,8 +138,6 @@ watchEffect(() => {
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'
@@ -176,8 +170,6 @@ watchEffect(() => {
borderWidth: 1,
borderRadius: 3,
},
cursor: 'default',
emphasis: { disabled: true },
data: funnelValues,
},
],
@@ -0,0 +1,107 @@
<script setup lang="ts">
import { computed, shallowRef, watchEffect } from 'vue'
import VChart from 'vue-echarts'
import { Clock } from 'lucide-vue-next'
import { useUserPeakReadingHours } from '../../composables/useUserPeakReadingHours'
import ChartCard from '../ChartCard.vue'
const HOUR_LABELS = [
'12am',
'1am',
'2am',
'3am',
'4am',
'5am',
'6am',
'7am',
'8am',
'9am',
'10am',
'11am',
'12pm',
'1pm',
'2pm',
'3pm',
'4pm',
'5pm',
'6pm',
'7pm',
'8pm',
'9pm',
'10pm',
'11pm',
]
const MIN_EVENTS = 20
const { data, loading, error } = useUserPeakReadingHours()
const option = shallowRef({})
const totalEvents = computed(() => data.value.reduce((s, d) => s + d.eventsCount, 0))
const isEmpty = computed(() => totalEvents.value === 0)
const lowConfidence = computed(() => totalEvents.value > 0 && totalEvents.value < MIN_EVENTS)
const peakHour = computed(() => {
if (!data.value.length) return null
return data.value.reduce((best, d) => (d.readingSeconds > best.readingSeconds ? d : best))
})
watchEffect(() => {
option.value = {}
if (isEmpty.value || lowConfidence.value || !data.value.length) return
const values = data.value.map((d) => Math.round(d.readingSeconds / 60))
option.value = {
polar: { radius: ['20%', '80%'] },
angleAxis: {
type: 'category',
data: HOUR_LABELS,
startAngle: 90,
clockwise: false,
axisLabel: { fontSize: 10 },
axisTick: { show: false },
axisLine: { show: false },
},
radiusAxis: {
min: 0,
axisLabel: { show: false },
axisLine: { show: false },
splitLine: { show: false },
},
tooltip: {
trigger: 'item',
formatter: (params: { dataIndex: number; data: number }) => {
const label = HOUR_LABELS[params.dataIndex]
const mins = params.data
const events = data.value[params.dataIndex]?.eventsCount ?? 0
const eventLabel = events === 1 ? 'session' : 'sessions'
return `<strong>${label}</strong><br/>${mins} min<br/>${events} ${eventLabel}`
},
},
series: [
{
type: 'bar',
data: values,
coordinateSystem: 'polar',
itemStyle: { borderRadius: [3, 3, 0, 0] },
},
],
}
})
</script>
<template>
<ChartCard title="Reading Clock" :icon="Clock" :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 }} sessions for a stable clock
</div>
<template v-else>
<div v-if="peakHour" class="text-muted-foreground mb-1 text-center text-xs">
Peak: <span class="text-foreground font-medium">{{ HOUR_LABELS[peakHour.hour] }}</span>
</div>
<VChart :option autoresize style="height: calc(100% - 20px)" />
</template>
</ChartCard>
</template>
@@ -4,9 +4,9 @@ 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'
import { buildHeatmapPalette } from '../../heatmap-palette'
import { useUserReadingHeatmap } from '../../composables/useUserReadingHeatmap'
import ChartCard from '../ChartCard.vue'
const MIN_ACTIVE_DAYS = 5
@@ -64,8 +64,7 @@ watchEffect(() => {
option.value = {
tooltip: {
confine: true,
enterable: false,
appendToBody: true,
backgroundColor: palette.tooltipBackground,
borderColor: palette.tooltipBorder,
borderWidth: 1,
@@ -0,0 +1,68 @@
<script setup lang="ts">
import { computed, shallowRef, watchEffect } from 'vue'
import VChart from 'vue-echarts'
import { Zap } from 'lucide-vue-next'
import { useUserReadingPace } from '../../composables/useUserReadingPace'
import ChartCard from '../ChartCard.vue'
const MIN_SESSIONS = 10
const { data, loading, error } = useUserReadingPace()
const isEmpty = computed(() => data.value.length === 0)
const hasEnoughData = computed(() => data.value.length >= MIN_SESSIONS)
const option = shallowRef({})
watchEffect(() => {
option.value = {}
if (isEmpty.value || !hasEnoughData.value || !data.value.length) return
const points = data.value
.filter((p) => p.durationSeconds > 0 && p.durationSeconds <= 14400 && p.progressDelta > 0 && p.progressDelta <= 100)
.map((p) => [+(p.durationSeconds / 60).toFixed(1), +p.progressDelta.toFixed(2)])
option.value = {
tooltip: {
trigger: 'item',
formatter: (params: { data: [number, number] }) => {
const [mins, pct] = params.data
return `${mins} min session<br/><strong>${pct}%</strong> progress made`
},
},
grid: { left: '3%', right: '5%', bottom: '8%', top: '6%', containLabel: true },
xAxis: {
type: 'value',
name: 'Duration (min)',
nameLocation: 'middle',
nameGap: 28,
axisLabel: { fontSize: 11 },
},
yAxis: {
type: 'value',
name: 'Progress %',
nameLocation: 'middle',
nameGap: 40,
axisLabel: { fontSize: 11 },
},
series: [
{
type: 'scatter',
data: points,
symbolSize: 5,
itemStyle: { opacity: 0.45 },
},
],
}
})
</script>
<template>
<ChartCard title="Reading Pace" :icon="Zap" :color-index="2" :loading :error :empty="isEmpty">
<div v-if="!hasEnoughData" class="text-muted-foreground flex h-full items-center justify-center text-sm">
Need at least {{ MIN_SESSIONS }} sessions with progress data
</div>
<VChart v-else :option autoresize style="height: 100%" />
</ChartCard>
</template>
@@ -0,0 +1,88 @@
<script setup lang="ts">
import { shallowRef, watchEffect } from 'vue'
import VChart from 'vue-echarts'
import { Layers } from 'lucide-vue-next'
import { useUserSessionArchetypes } from '../../composables/useUserSessionArchetypes'
import ChartCard from '../ChartCard.vue'
const DAY_NAMES = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
const DAY_COLORS = [
'#f97316', // Sun
'#3b82f6', // Mon
'#22c55e', // Tue
'#a855f7', // Wed
'#ec4899', // Thu
'#eab308', // Fri
'#06b6d4', // Sat
]
const { data, loading, error } = useUserSessionArchetypes()
const option = shallowRef({})
const isEmpty = () => data.value.length === 0
watchEffect(() => {
option.value = {}
if (!data.value.length) return
const seriesByDay = DAY_NAMES.map((name, dow) => ({
name,
type: 'scatter',
data: data.value.filter((d) => d.dayOfWeek === dow).map((d) => [d.hour, d.durationMinutes]),
itemStyle: { color: DAY_COLORS[dow], opacity: 0.7 },
symbolSize: 6,
}))
option.value = {
grid: { top: 24, right: 16, bottom: 40, left: 52 },
xAxis: {
type: 'value',
min: 0,
max: 23,
interval: 3,
axisLabel: {
fontSize: 11,
formatter: (v: number) => {
if (v === 0) return '12am'
if (v < 12) return `${v}am`
if (v === 12) return '12pm'
return `${v - 12}pm`
},
},
},
yAxis: {
type: 'value',
name: 'Duration (min)',
nameLocation: 'middle',
nameGap: 40,
axisLabel: { fontSize: 11 },
splitLine: { lineStyle: { type: 'dashed' } },
},
tooltip: {
trigger: 'item',
formatter: (params: { seriesName: string; data: [number, number] }) => {
const hour = params.data[0]
const mins = params.data[1]
const label = hour === 0 ? '12am' : hour < 12 ? `${hour}am` : hour === 12 ? '12pm' : `${hour - 12}pm`
return `<strong>${params.seriesName} at ${label}</strong><br/>${mins} min`
},
},
legend: {
data: DAY_NAMES,
bottom: 0,
itemWidth: 10,
itemHeight: 10,
textStyle: { fontSize: 11 },
},
series: seriesByDay,
}
})
</script>
<template>
<ChartCard title="Session Archetypes" :icon="Layers" :color-index="8" :loading :error :empty="isEmpty()">
<VChart :option autoresize style="height: 100%" />
</ChartCard>
</template>
@@ -0,0 +1,13 @@
import type { ChordDiagramData } from '@projectx/types'
import { fetchGenreCooccurrence } from '../api/statistics.api'
import { useStatisticsQuery } from './useStatisticsQuery'
const EMPTY: ChordDiagramData = { nodes: [], links: [] }
export function useGenreCooccurrence() {
return useStatisticsQuery({
initialData: EMPTY,
fetcher: fetchGenreCooccurrence,
})
}
@@ -12,7 +12,7 @@ import {
} from '@projectx/types'
import { api } from '@/lib/api'
import { useAuth } from '@/features/auth/composables/useAuth'
import { STATISTICS_CHART_IDS } from '../statistics-chart-meta'
import { LIBRARY_CHART_IDS, STATISTICS_CHART_IDS, STATISTICS_CHART_META, USER_CHART_IDS } from '../statistics-chart-meta'
const KNOWN_CHART_IDS: StatisticsChartId[] = [...STATISTICS_CHART_IDS]
@@ -37,6 +37,9 @@ function normalizeCharts(saved: ChartConfigEntry[] | undefined): ChartConfigEntr
return [...filtered, ...newEntries]
}
const libraryChartIdSet = new Set<StatisticsChartId>(LIBRARY_CHART_IDS)
const userChartIdSet = new Set<StatisticsChartId>(USER_CHART_IDS)
function normalizeFilters(saved: StatisticsFilterConfig | undefined): StatisticsFilterConfig {
return {
libraryIds: saved?.libraryIds ?? DEFAULT_FILTERS.libraryIds,
@@ -76,6 +79,16 @@ export function useStatisticsConfig() {
const orderedCharts = computed(() => [...config.value].sort((a, b) => a.order - b.order))
const visibleCharts = computed(() => orderedCharts.value.filter((c) => c.visible))
const orderedLibraryCharts = computed(() => orderedCharts.value.filter((c) => libraryChartIdSet.has(c.id)))
const orderedUserCharts = computed(() => orderedCharts.value.filter((c) => userChartIdSet.has(c.id)))
const visibleLibraryCharts = computed(() => orderedLibraryCharts.value.filter((c) => c.visible))
const visibleUserCharts = computed(() => orderedUserCharts.value.filter((c) => c.visible))
const libraryChartCount = computed(() => orderedLibraryCharts.value.length)
const userChartCount = computed(() => orderedUserCharts.value.length)
const visibleLibraryChartCount = computed(() => visibleLibraryCharts.value.length)
const visibleUserChartCount = computed(() => visibleUserCharts.value.length)
function toggleVisibility(id: StatisticsChartId) {
const entry = config.value.find((c) => c.id === id)
if (!entry) return
@@ -129,5 +142,29 @@ export function useStatisticsConfig() {
scheduleSave()
}
return { orderedCharts, visibleCharts, filters, init, toggleVisibility, reorder, resetToDefaults, setLibraryFilter, setGranularity, setDateRange }
function chartCategory(id: StatisticsChartId) {
return STATISTICS_CHART_META[id].category
}
return {
orderedCharts,
visibleCharts,
orderedLibraryCharts,
orderedUserCharts,
visibleLibraryCharts,
visibleUserCharts,
libraryChartCount,
userChartCount,
visibleLibraryChartCount,
visibleUserChartCount,
filters,
init,
toggleVisibility,
reorder,
resetToDefaults,
setLibraryFilter,
setGranularity,
setDateRange,
chartCategory,
}
}
@@ -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 useUserBooksCompleted() {
return useStatisticsQuery({
initialData: EMPTY,
fetcher: fetchUserCompletionTimeline,
})
}
@@ -0,0 +1,13 @@
import type { UserGenreReadingTimeItem } from '@projectx/types'
import { fetchUserGenreReadingTime } from '../api/statistics.api'
import { useStatisticsQuery } from './useStatisticsQuery'
const EMPTY: UserGenreReadingTimeItem[] = []
export function useUserGenreReadingTime() {
return useStatisticsQuery({
initialData: EMPTY,
fetcher: fetchUserGenreReadingTime,
})
}
@@ -0,0 +1,13 @@
import type { UserReadingPacePoint } from '@projectx/types'
import { fetchUserReadingPace } from '../api/statistics.api'
import { useStatisticsQuery } from './useStatisticsQuery'
const EMPTY: UserReadingPacePoint[] = []
export function useUserReadingPace() {
return useStatisticsQuery({
initialData: EMPTY,
fetcher: fetchUserReadingPace,
})
}
@@ -0,0 +1,13 @@
import type { UserSessionArchetypePoint } from '@projectx/types'
import { fetchUserSessionArchetypes } from '../api/statistics.api'
import { useStatisticsQuery } from './useStatisticsQuery'
const EMPTY: UserSessionArchetypePoint[] = []
export function useUserSessionArchetypes() {
return useStatisticsQuery({
initialData: EMPTY,
fetcher: fetchUserSessionArchetypes,
})
}
@@ -1,14 +1,18 @@
import type { Component } from 'vue'
import {
BarChart3,
BookCheck,
Calendar,
CalendarDays,
CalendarRange,
Clock,
Clock3,
BookOpen,
GitMerge,
Goal,
Globe,
HardDrive,
Layers,
ListChecks,
PieChart,
Rabbit,
@@ -16,16 +20,19 @@ import {
TrendingUp,
Users,
Waypoints,
Zap,
} from 'lucide-vue-next'
import { DEFAULT_STATISTICS_CHART_ORDER, type StatisticsChartId } from '@projectx/types'
import { DEFAULT_LIBRARY_CHART_ORDER, DEFAULT_USER_CHART_ORDER, type StatisticsChartId } from '@projectx/types'
export type StatisticsChartSize = '1x1' | '2x1' | '2x2' | '1x2' | '3x1'
export type StatisticsChartCategory = 'library' | 'user'
export interface StatisticsChartMetaEntry {
label: string
icon: Component
size: StatisticsChartSize
category: StatisticsChartCategory
}
export const STATISTICS_CHART_META: Record<StatisticsChartId, StatisticsChartMetaEntry> = {
@@ -33,104 +40,162 @@ export const STATISTICS_CHART_META: Record<StatisticsChartId, StatisticsChartMet
label: 'Format Distribution',
icon: PieChart,
size: '1x1',
category: 'library',
},
'language-distribution': {
label: 'Language Distribution',
icon: Globe,
size: '1x1',
category: 'library',
},
'books-added-over-time': {
label: 'Books Added Over Time',
icon: TrendingUp,
size: '2x1',
category: 'library',
},
'storage-by-format': {
label: 'Storage by Format',
icon: HardDrive,
size: '1x1',
category: 'library',
},
'publication-decade': {
label: 'Publication Decade',
icon: CalendarDays,
size: '1x1',
category: 'library',
},
'top-authors': {
label: 'Top 25 Authors',
icon: Users,
size: '2x1',
category: 'library',
},
'metadata-completeness': {
label: 'Metadata Completeness',
icon: ListChecks,
size: '1x1',
category: 'library',
},
'genre-distribution': {
label: 'Genre Distribution',
icon: Tag,
size: '2x1',
category: 'library',
},
'metadata-score-distribution': {
label: 'Metadata Score Distribution',
icon: BarChart3,
size: '1x1',
category: 'library',
},
'library-metadata-completeness': {
label: 'Library Metadata Completeness',
icon: ListChecks,
size: '2x1',
category: 'library',
},
'genre-cooccurrence': {
label: 'Genre Co-occurrence',
icon: GitMerge,
size: '2x2',
category: 'library',
},
'format-share-over-time': {
label: 'Format Share Over Time',
icon: TrendingUp,
size: '2x1',
category: 'library',
},
'genre-rank-over-time': {
label: 'Genre Rank Over Time',
icon: Tag,
size: '2x1',
category: 'library',
},
'page-count-distribution': {
label: 'Page Count Distribution',
icon: BookOpen,
size: '1x1',
category: 'library',
},
'reading-heatmap': {
label: 'Reading Heatmap',
icon: Calendar,
size: '2x1',
category: 'user',
},
'peak-reading-hours': {
label: 'Peak Reading Hours',
icon: Clock3,
size: '2x1',
category: 'user',
},
'favorite-reading-days': {
label: 'Favorite Reading Days',
icon: CalendarDays,
size: '1x1',
category: 'user',
},
'completion-timeline': {
label: 'Completion Timeline',
icon: CalendarRange,
size: '2x1',
category: 'user',
},
'goal-trajectory': {
label: 'Pace vs Goal',
icon: Goal,
size: '2x1',
category: 'user',
},
'progress-funnel': {
label: 'Progress Funnel',
icon: Waypoints,
size: '1x1',
category: 'user',
},
'completion-latency': {
label: 'Completion Latency',
icon: Rabbit,
size: '1x1',
category: 'user',
},
'genre-reading-time': {
label: 'Genre Reading Time',
icon: Tag,
size: '2x1',
category: 'user',
},
'reading-pace': {
label: 'Reading Pace',
icon: Zap,
size: '2x1',
category: 'user',
},
'books-completed': {
label: 'Books Completed',
icon: BookCheck,
size: '2x1',
category: 'user',
},
'reading-clock': {
label: 'Reading Clock',
icon: Clock,
size: '1x1',
category: 'user',
},
'session-archetypes': {
label: 'Session Archetypes',
icon: Layers,
size: '2x1',
category: 'user',
},
}
export const STATISTICS_CHART_IDS: StatisticsChartId[] = DEFAULT_STATISTICS_CHART_ORDER.filter(
(id): id is StatisticsChartId => id in STATISTICS_CHART_META,
)
export const LIBRARY_CHART_IDS: StatisticsChartId[] = DEFAULT_LIBRARY_CHART_ORDER.filter((id): id is StatisticsChartId => id in STATISTICS_CHART_META)
export const USER_CHART_IDS: StatisticsChartId[] = DEFAULT_USER_CHART_ORDER.filter((id): id is StatisticsChartId => id in STATISTICS_CHART_META)
export const STATISTICS_CHART_IDS: StatisticsChartId[] = [...LIBRARY_CHART_IDS, ...USER_CHART_IDS]
+108 -26
View File
@@ -1,12 +1,24 @@
import { use, registerTheme } from 'echarts/core'
import { SVGRenderer } from 'echarts/renderers'
import { BarChart, BoxplotChart, FunnelChart, HeatmapChart, LineChart, PieChart, SunburstChart } from 'echarts/charts'
import {
BarChart,
BoxplotChart,
ChordChart,
FunnelChart,
HeatmapChart,
LineChart,
PieChart,
ScatterChart,
SunburstChart,
TreemapChart,
} from 'echarts/charts'
import {
CalendarComponent,
GridComponent,
LegendComponent,
MarkAreaComponent,
MarkLineComponent,
PolarComponent,
TitleComponent,
TooltipComponent,
VisualMapComponent,
@@ -19,11 +31,14 @@ use([
SVGRenderer,
PieChart,
BarChart,
ChordChart,
LineChart,
FunnelChart,
HeatmapChart,
BoxplotChart,
SunburstChart,
ScatterChart,
TreemapChart,
TitleComponent,
TooltipComponent,
LegendComponent,
@@ -32,8 +47,12 @@ use([
VisualMapComponent,
MarkLineComponent,
MarkAreaComponent,
PolarComponent,
])
const PROJECTX_THEME_NAME = 'projectx'
let themeRegistered = false
export function readCssColor(varName: string): string {
const el = document.createElement('span')
el.style.color = `var(${varName})`
@@ -43,35 +62,85 @@ export function readCssColor(varName: string): string {
return color
}
function buildTheme() {
const border = readCssColor('--border')
const mutedFg = readCssColor('--muted-foreground')
const axisConfig = {
axisLine: { show: true, lineStyle: { color: border } },
type ProjectxThemeMode = 'light' | 'dark'
const ACCENT_HUE_BY_ID: Record<string, number> = {
white: 0,
grey: 0,
rose: 15,
orange: 42,
amber: 70,
yellow: 95,
lime: 118,
green: 142,
emerald: 162,
teal: 180,
cyan: 197,
sky: 213,
blue: 263,
indigo: 276,
violet: 292,
fuchsia: 312,
pink: 328,
coral: 25,
peach: 38,
butter: 80,
lemon: 100,
celadon: 122,
sage: 142,
mint: 158,
seafoam: 178,
powder: 205,
mist: 218,
periwinkle: 265,
wisteria: 285,
lavender: 300,
orchid: 315,
blush: 345,
}
const THEME_MODES: ProjectxThemeMode[] = ['light', 'dark']
const DEFAULT_ACCENT = 'blue'
function normalizeHue(value: number): number {
const mod = value % 360
return mod < 0 ? mod + 360 : mod
}
function hsl(h: number, s: number, l: number): string {
return `hsl(${Math.round(normalizeHue(h))}, ${s}%, ${l}%)`
}
function buildSeriesPalette(baseHue: number, dark: boolean): string[] {
const offsets = [0, 72, 144, 216, 288, 36, 108, 180, 252, 324]
const sat = dark ? [74, 70, 68, 72, 74, 68, 66, 64, 68, 70] : [68, 64, 62, 66, 68, 62, 60, 58, 62, 64]
const light = dark ? [64, 62, 60, 66, 64, 48, 46, 44, 50, 47] : [56, 54, 52, 58, 56, 42, 40, 38, 44, 41]
return offsets.map((offset, index) => hsl(baseHue + offset, sat[index] ?? sat[0] ?? 68, light[index] ?? light[0] ?? 56))
}
function buildAxisConfig(dark: boolean) {
const axisLine = dark ? '#374151' : '#D1D5DB'
const axisLabel = dark ? '#9CA3AF' : '#6B7280'
const splitLine = dark ? '#2A3441' : '#E5E7EB'
return {
axisLine: { show: true, lineStyle: { color: axisLine } },
axisTick: { show: false },
axisLabel: { show: true, color: mutedFg },
splitLine: { show: true, lineStyle: { color: [border] } },
axisLabel: { show: true, color: axisLabel },
splitLine: { show: true, lineStyle: { color: [splitLine] } },
splitArea: { show: false },
}
}
function buildTheme(baseHue: number, dark: boolean) {
const axisConfig = buildAxisConfig(dark)
return {
color: [
readCssColor('--chart-1'),
readCssColor('--chart-2'),
readCssColor('--chart-3'),
readCssColor('--chart-4'),
readCssColor('--chart-5'),
readCssColor('--chart-6'),
readCssColor('--chart-7'),
readCssColor('--chart-8'),
readCssColor('--chart-9'),
readCssColor('--chart-10'),
],
color: buildSeriesPalette(baseHue, dark),
backgroundColor: 'transparent',
legend: { textStyle: { color: readCssColor('--foreground') } },
legend: { textStyle: { color: dark ? '#F3F4F6' : '#111827' } },
tooltip: {
backgroundColor: readCssColor('--card'),
borderColor: border,
textStyle: { color: readCssColor('--card-foreground') },
backgroundColor: dark ? '#1F2937' : '#FFFFFF',
borderColor: dark ? '#374151' : '#D1D5DB',
textStyle: { color: dark ? '#F9FAFB' : '#111827' },
},
categoryAxis: axisConfig,
valueAxis: axisConfig,
@@ -80,6 +149,19 @@ function buildTheme() {
}
}
export function initChartThemes(version: number = 0): void {
registerTheme(`projectx-v${version}`, buildTheme())
export function getProjectxThemeName(mode: ProjectxThemeMode = 'dark', accent: string = DEFAULT_ACCENT): string {
const resolvedAccent = accent in ACCENT_HUE_BY_ID ? accent : DEFAULT_ACCENT
return `${PROJECTX_THEME_NAME}-${mode}-${resolvedAccent}`
}
export function initChartThemes(): void {
if (themeRegistered) return
const accents = Object.keys(ACCENT_HUE_BY_ID)
for (const mode of THEME_MODES) {
for (const accent of accents) {
const hue = ACCENT_HUE_BY_ID[accent] ?? ACCENT_HUE_BY_ID[DEFAULT_ACCENT] ?? 263
registerTheme(getProjectxThemeName(mode, accent), buildTheme(hue, mode === 'dark'))
}
}
themeRegistered = true
}
+41 -9
View File
@@ -18,7 +18,13 @@ export type StatisticsChartId =
| "completion-timeline"
| "goal-trajectory"
| "progress-funnel"
| "completion-latency";
| "completion-latency"
| "genre-reading-time"
| "reading-pace"
| "books-completed"
| "reading-clock"
| "session-archetypes"
| "genre-cooccurrence";
export type StatisticsGranularity = "monthly" | "yearly";
export type StatisticsDateRange = "last-year" | "last-5-years" | "all-time";
@@ -42,19 +48,12 @@ export interface StatisticsSettings {
filters: StatisticsFilterConfig;
}
export const DEFAULT_STATISTICS_CHART_ORDER: StatisticsChartId[] = [
export const DEFAULT_LIBRARY_CHART_ORDER: StatisticsChartId[] = [
"format-distribution",
"language-distribution",
"storage-by-format",
"publication-decade",
"books-added-over-time",
"reading-heatmap",
"peak-reading-hours",
"favorite-reading-days",
"completion-timeline",
"goal-trajectory",
"progress-funnel",
"completion-latency",
"format-share-over-time",
"metadata-completeness",
"metadata-score-distribution",
@@ -63,8 +62,26 @@ export const DEFAULT_STATISTICS_CHART_ORDER: StatisticsChartId[] = [
"genre-distribution",
"genre-rank-over-time",
"library-metadata-completeness",
"genre-cooccurrence",
];
export const DEFAULT_USER_CHART_ORDER: StatisticsChartId[] = [
"reading-heatmap",
"peak-reading-hours",
"favorite-reading-days",
"completion-timeline",
"goal-trajectory",
"progress-funnel",
"completion-latency",
"genre-reading-time",
"reading-pace",
"books-completed",
"reading-clock",
"session-archetypes",
];
export const DEFAULT_STATISTICS_CHART_ORDER: StatisticsChartId[] = [...DEFAULT_LIBRARY_CHART_ORDER, ...DEFAULT_USER_CHART_ORDER];
export const DEFAULT_STATISTICS_FILTERS: StatisticsFilterConfig = {
libraryIds: [],
booksOverTimeRange: "all-time",
@@ -85,6 +102,21 @@ export function createDefaultStatisticsSettings(): StatisticsSettings {
// Generic wrapper returned by all statistics endpoints.
// unknownCount = books excluded due to NULL in the relevant metadata field.
// Is 0 for charts where the source column is never NULL (format, addedAt).
export interface ChordNode {
name: string;
}
export interface ChordLink {
source: string;
target: string;
value: number;
}
export interface ChordDiagramData {
nodes: ChordNode[];
links: ChordLink[];
}
export interface StatisticsResult<T> {
items: T[];
unknownCount: number;
+51
View File
@@ -71,3 +71,54 @@ export interface UserCompletionLatencyDistribution {
percentile90Days: number | null;
buckets: UserCompletionLatencyBucket[];
}
export interface UserGenreReadingTimeItem {
genre: string;
readingSeconds: number;
}
export interface UserReadingPacePoint {
durationSeconds: number;
progressDelta: number;
}
export interface UserReadingSurvivalPoint {
threshold: number;
survivedCount: number;
survivedPct: number;
}
export interface UserCompletionRacePoint {
daysSinceStart: number;
progress: number;
}
export interface UserCompletionRaceBook {
bookId: number;
title: string;
points: UserCompletionRacePoint[];
}
export interface UserSessionArchetypePoint {
hour: number;
durationMinutes: number;
dayOfWeek: number;
}
export interface ReadingTimeBreakdownBook {
bookId: number;
title: string;
readingSeconds: number;
}
export interface ReadingTimeBreakdownAuthor {
authorName: string;
readingSeconds: number;
books: ReadingTimeBreakdownBook[];
}
export interface ReadingTimeBreakdownGenre {
genre: string;
readingSeconds: number;
authors: ReadingTimeBreakdownAuthor[];
}
+140
View File
@@ -0,0 +1,140 @@
#!/usr/bin/env bash
# Seeds synthetic reading sessions and daily stats for the first user so that
# all user-statistics charts (heatmap, peak hours, completion timeline, etc.)
# show realistic data out of the box.
#
# Safe to run multiple times - sessions are deduplicated by session_id and
# daily stats are upserted. Running again adds more data on top.
#
# Usage: ./scripts/seed/reading-sessions.sh [USER_ID]
# USER_ID defaults to 1.
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
cd "$ROOT_DIR"
POSTGRES_USER="${POSTGRES_USER:-projectx}"
POSTGRES_DB="${POSTGRES_DB:-projectx}"
TARGET_USER_ID="${1:-1}"
echo "Seeding reading sessions for user_id=${TARGET_USER_ID}..."
docker compose -f docker-compose.dev.yml exec -T postgres psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" << EOSQL
DO \$\$
DECLARE
v_user_id int := ${TARGET_USER_ID};
v_day date;
v_file_id int;
v_library_id int;
v_sessions int;
v_i int;
v_hour int;
v_duration int;
v_started timestamptz;
v_ended timestamptz;
v_end_pct real;
v_delta real;
v_session_id text;
v_dow int;
v_file_ids int[];
v_total_sessions int := 0;
v_total_completions int := 0;
BEGIN
-- Verify user exists
IF NOT EXISTS (SELECT 1 FROM users WHERE id = v_user_id) THEN
RAISE EXCEPTION 'User with id % not found', v_user_id;
END IF;
-- Verify there are book files to work with
IF NOT EXISTS (SELECT 1 FROM book_files LIMIT 1) THEN
RAISE EXCEPTION 'No book files found - scan a library first';
END IF;
-- Pick up to 40 real file ids to spread sessions across
SELECT array_agg(id ORDER BY random()) INTO v_file_ids
FROM (SELECT id FROM book_files ORDER BY random() LIMIT 40) t;
-- Spread books.added_at across the past 3 years so Format Share Over Time
-- chart has data across multiple months instead of a single spike today.
UPDATE books SET added_at = now() - (random() * interval '3 years')
WHERE added_at > now() - interval '7 days';
-- Insert sessions across the past 365 days
FOR v_day IN
SELECT generate_series(
current_date - interval '364 days',
current_date,
interval '1 day'
)::date
LOOP
v_dow := extract(dow from v_day); -- 0=Sun 6=Sat
-- Weekends: 1-3 sessions. Weekdays: 0-2 (30% chance of 0).
IF v_dow IN (0, 6) THEN
v_sessions := 1 + floor(random() * 3)::int;
ELSIF random() < 0.30 THEN
v_sessions := 0;
ELSE
v_sessions := 1 + floor(random() * 2)::int;
END IF;
FOR v_i IN 1..v_sessions LOOP
v_file_id := v_file_ids[1 + floor(random() * array_length(v_file_ids, 1))::int];
SELECT b.library_id INTO v_library_id
FROM book_files bf JOIN books b ON b.id = bf.book_id
WHERE bf.id = v_file_id;
-- Realistic hour distribution: mostly evenings, some mornings/afternoons
IF random() < 0.60 THEN
v_hour := 18 + floor(random() * 6)::int; -- 18-23 evening
ELSIF random() < 0.50 THEN
v_hour := 7 + floor(random() * 3)::int; -- 7-9 morning
ELSE
v_hour := 12 + floor(random() * 4)::int; -- 12-15 afternoon
END IF;
v_started := (v_day + make_interval(hours => v_hour, mins => floor(random() * 60)::int))::timestamptz;
v_duration := 600 + floor(random() * 3000)::int; -- 10-60 min
v_ended := v_started + make_interval(secs => v_duration);
v_delta := round((1 + random() * 8)::numeric, 4); -- 1-9% progress
-- ~15% of sessions are completions (every 7th deterministically by loop)
IF (v_total_sessions % 7) = 0 THEN
v_end_pct := 100;
v_total_completions := v_total_completions + 1;
ELSE
v_end_pct := round((10 + random() * 80)::numeric, 2);
END IF;
v_session_id := md5(v_user_id::text || v_file_id::text || v_started::text || v_i::text);
INSERT INTO reading_sessions
(user_id, book_file_id, session_id, started_at, ended_at, duration_seconds, progress_delta, end_progress)
VALUES
(v_user_id, v_file_id, v_session_id, v_started, v_ended, v_duration, v_delta, v_end_pct)
ON CONFLICT (session_id) DO NOTHING;
-- Real-time upsert of daily stats (mirrors what the server does on session save)
INSERT INTO user_reading_daily_stats
(user_id, library_id, day, reading_seconds, progress_delta, sessions_count, updated_at)
VALUES
(v_user_id, v_library_id, v_day, v_duration, v_delta, 1, now())
ON CONFLICT (user_id, library_id, day) DO UPDATE SET
reading_seconds = user_reading_daily_stats.reading_seconds + excluded.reading_seconds,
progress_delta = user_reading_daily_stats.progress_delta + excluded.progress_delta,
sessions_count = user_reading_daily_stats.sessions_count + 1,
updated_at = now();
v_total_sessions := v_total_sessions + 1;
END LOOP;
END LOOP;
RAISE NOTICE 'Done: % sessions inserted (% completions)', v_total_sessions, v_total_completions;
END;
\$\$;
EOSQL
echo "Reading session seed complete."
@@ -79,4 +79,9 @@ export class StatisticsController {
getSummary(@CurrentUser() user: RequestUser, @Query() query: StatisticsFilterQueryDto) {
return this.statisticsService.getSummary(user, query);
}
@Get('genre-cooccurrence')
getGenreCooccurrence(@CurrentUser() user: RequestUser, @Query() query: StatisticsFilterQueryDto) {
return this.statisticsService.getGenreCooccurrence(user, query);
}
}
@@ -2,7 +2,7 @@ import { Inject, Injectable } from '@nestjs/common';
import { and, desc, eq, gte, inArray, isNotNull, isNull, sql } from 'drizzle-orm';
import type { NodePgDatabase } from 'drizzle-orm/node-postgres';
import type { StatisticsDateRange, StatisticsGranularity } from '@projectx/types';
import type { ChordDiagramData, StatisticsDateRange, StatisticsGranularity } from '@projectx/types';
import { DB } from '../../db';
import * as schema from '../../db/schema';
@@ -444,4 +444,50 @@ export class StatisticsRepository {
booksAddedThisYear: thisYearRow.count,
};
}
async getGenreCooccurrence(userId: number, isSuperuser: boolean, filterLibraryIds?: number[], limit = 15): Promise<ChordDiagramData> {
const accessible = await this.getAccessibleLibraryIds(userId, isSuperuser);
const filter = this.libraryFilter(this.intersectLibraryIds(accessible, filterLibraryIds));
const topGenresRows = await this.db
.select({
id: genres.id,
name: genres.name,
})
.from(bookGenres)
.innerJoin(genres, eq(genres.id, bookGenres.genreId))
.innerJoin(books, eq(books.id, bookGenres.bookId))
.where(filter)
.groupBy(genres.id, genres.name)
.orderBy(desc(sql`count(distinct ${bookGenres.bookId})`))
.limit(limit);
if (topGenresRows.length < 2) return { nodes: topGenresRows.map((g) => ({ name: g.name })), links: [] };
const topGenreIds = topGenresRows.map((g) => g.id);
const topGenreIdList = sql.join(
topGenreIds.map((id) => sql`${id}`),
sql`, `,
);
const pairRows = await this.db.execute<{ source: string; target: string; value: number }>(sql`
select g1.name as source, g2.name as target, count(distinct bg1.book_id)::int as value
from book_genres bg1
inner join book_genres bg2 on bg1.book_id = bg2.book_id and bg1.genre_id < bg2.genre_id
inner join genres g1 on g1.id = bg1.genre_id
inner join genres g2 on g2.id = bg2.genre_id
inner join books b on b.id = bg1.book_id
where bg1.genre_id in (${topGenreIdList})
and bg2.genre_id in (${topGenreIdList})
${filter ? sql`and ${filter}` : sql``}
group by g1.name, g2.name
having count(distinct bg1.book_id) >= 1
order by value desc
`);
return {
nodes: topGenresRows.map((g) => ({ name: g.name })),
links: pairRows.rows.map((r) => ({ source: r.source, target: r.target, value: r.value })),
};
}
}
@@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common';
import type {
BooksAddedDataPoint,
ChordDiagramData,
FormatShareOverTimeItem,
StatisticsSummary,
GenreRankOverTimeItem,
@@ -255,4 +256,8 @@ export class StatisticsService {
async getSummary(user: RequestUser, query: StatisticsFilterQueryDto): Promise<StatisticsSummary> {
return this.repo.getSummary(user.id, user.isSuperuser, query.libraryIds);
}
async getGenreCooccurrence(user: RequestUser, query: StatisticsFilterQueryDto): Promise<ChordDiagramData> {
return this.repo.getGenreCooccurrence(user.id, user.isSuperuser, query.libraryIds);
}
}
@@ -25,7 +25,9 @@ export class UserStatisticsAggregationJob implements OnApplicationBootstrap {
this.logger.log(`User daily stats recomputed from ${result.since}: deleted=${result.deleted}, inserted=${result.inserted}`);
}
} catch (err) {
this.logger.error('User daily stats aggregation failed', err as Error);
const cause = err instanceof Error && err.cause instanceof Error ? err.cause.message : undefined;
const stack = err instanceof Error ? err.stack : String(err);
this.logger.error(`User daily stats aggregation failed${cause ? `: ${cause}` : ''}`, stack);
}
}
}
@@ -55,4 +55,34 @@ export class UserStatisticsController {
getCompletionLatency(@CurrentUser() user: RequestUser, @Query() query: UserDailyReadingQueryDto) {
return this.userStatisticsService.getCompletionLatency(user, query);
}
@Get('genre-reading-time')
getGenreReadingTime(@CurrentUser() user: RequestUser, @Query() query: UserDailyReadingQueryDto) {
return this.userStatisticsService.getGenreReadingTime(user, query);
}
@Get('reading-pace')
getReadingPace(@CurrentUser() user: RequestUser, @Query() query: UserDailyReadingQueryDto) {
return this.userStatisticsService.getReadingPace(user, query);
}
@Get('reading-survival')
getReadingSurvival(@CurrentUser() user: RequestUser, @Query() query: UserDailyReadingQueryDto) {
return this.userStatisticsService.getReadingSurvival(user, query);
}
@Get('completion-race')
getCompletionRace(@CurrentUser() user: RequestUser, @Query() query: UserDailyReadingQueryDto) {
return this.userStatisticsService.getCompletionRace(user, query);
}
@Get('session-archetypes')
getSessionArchetypes(@CurrentUser() user: RequestUser, @Query() query: UserDailyReadingQueryDto) {
return this.userStatisticsService.getSessionArchetypes(user, query);
}
@Get('author-genre-chord')
getAuthorGenreChord(@CurrentUser() user: RequestUser, @Query() query: UserDailyReadingQueryDto) {
return this.userStatisticsService.getAuthorGenreChord(user, query);
}
}
@@ -1,19 +1,34 @@
import { Inject, Injectable } from '@nestjs/common';
import { and, eq, gte, inArray, isNotNull, lt, sql } from 'drizzle-orm';
import { and, eq, gt, gte, inArray, isNotNull, lt, sql } from 'drizzle-orm';
import type { NodePgDatabase } from 'drizzle-orm/node-postgres';
import type {
ChordDiagramData,
UserCompletionTimelinePoint,
UserDailyReadingStat,
UserFavoriteDayStat,
UserGenreReadingTimeItem,
UserPeakHourStat,
UserProgressFunnel,
UserReadingPacePoint,
UserSessionArchetypePoint,
UserStatisticsSummary,
} from '@projectx/types';
import { DB } from '../../db';
import * as schema from '../../db/schema';
import { bookFiles, books, readingProgress, readingSessions, userBookStatus, userLibraryAccess, userReadingDailyStats } from '../../db/schema';
import {
bookFiles,
bookGenres,
bookMetadata,
books,
genres,
readingProgress,
readingSessions,
userBookStatus,
userLibraryAccess,
userReadingDailyStats,
} from '../../db/schema';
type Db = NodePgDatabase<typeof schema>;
const RECENT_DAILY_AGGREGATION_DAYS = 2;
@@ -291,9 +306,225 @@ export class UserStatisticsRepository {
.filter((daysValue) => Number.isFinite(daysValue) && daysValue >= 0);
}
async recomputeRecentDailyStats(days = RECENT_DAILY_AGGREGATION_DAYS): Promise<{ deleted: number; inserted: number; since: string }> {
async getGenreReadingTime(userId: number, isSuperuser: boolean, filterLibraryIds?: number[], days = 365): Promise<UserGenreReadingTimeItem[]> {
const accessible = await this.getAccessibleLibraryIds(userId, isSuperuser);
const libraryFilter = this.libraryFilter(this.intersectLibraryIds(accessible, filterLibraryIds));
const since = this.sinceDateForDays(days);
const sinceDay = since.toISOString().slice(0, 10);
return this.db
.select({
genre: genres.name,
readingSeconds: sql<number>`coalesce(sum(${readingSessions.durationSeconds}), 0)::int`,
})
.from(readingSessions)
.innerJoin(bookFiles, eq(bookFiles.id, readingSessions.bookFileId))
.innerJoin(books, eq(books.id, bookFiles.bookId))
.innerJoin(bookGenres, eq(bookGenres.bookId, books.id))
.innerJoin(genres, eq(genres.id, bookGenres.genreId))
.where(and(eq(readingSessions.userId, userId), gte(readingSessions.startedAt, since), libraryFilter))
.groupBy(genres.name)
.orderBy(sql`sum(${readingSessions.durationSeconds}) desc`)
.limit(30);
}
async getReadingPacePoints(userId: number, isSuperuser: boolean, filterLibraryIds?: number[], days = 1825): Promise<UserReadingPacePoint[]> {
const accessible = await this.getAccessibleLibraryIds(userId, isSuperuser);
const libraryFilter = this.libraryFilter(this.intersectLibraryIds(accessible, filterLibraryIds));
const since = this.sinceDateForDays(days);
const rows = await this.db
.select({
durationSeconds: readingSessions.durationSeconds,
progressDelta: readingSessions.progressDelta,
})
.from(readingSessions)
.innerJoin(bookFiles, eq(bookFiles.id, readingSessions.bookFileId))
.innerJoin(books, eq(books.id, bookFiles.bookId))
.where(
and(
eq(readingSessions.userId, userId),
gte(readingSessions.startedAt, since),
isNotNull(readingSessions.progressDelta),
gt(readingSessions.progressDelta, 0),
libraryFilter,
),
)
.orderBy(readingSessions.startedAt)
.limit(2000);
return rows.map((r) => ({ durationSeconds: r.durationSeconds, progressDelta: r.progressDelta! }));
}
async getReadingSurvivalMaxProgress(userId: number, isSuperuser: boolean, filterLibraryIds?: number[], days = 1825): Promise<number[]> {
const accessible = await this.getAccessibleLibraryIds(userId, isSuperuser);
const libraryFilter = this.libraryFilter(this.intersectLibraryIds(accessible, filterLibraryIds));
const since = this.sinceDateForDays(days);
const perBook = this.db
.select({
bookId: bookFiles.bookId,
maxProgress: sql<number>`max(${readingSessions.endProgress})`.as('max_progress'),
})
.from(readingSessions)
.innerJoin(bookFiles, eq(bookFiles.id, readingSessions.bookFileId))
.innerJoin(books, eq(books.id, bookFiles.bookId))
.where(and(eq(readingSessions.userId, userId), gte(readingSessions.startedAt, since), isNotNull(readingSessions.endProgress), libraryFilter))
.groupBy(bookFiles.bookId)
.as('per_book');
const rows = await this.db.select({ maxProgress: perBook.maxProgress }).from(perBook);
return rows.map((r) => Number(r.maxProgress)).filter((v) => Number.isFinite(v));
}
async getCompletionRaceRawSessions(
userId: number,
isSuperuser: boolean,
filterLibraryIds?: number[],
days = 1825,
limit = 15,
): Promise<{ bookId: number; title: string | null; startedAt: Date; endProgress: number }[]> {
const accessible = await this.getAccessibleLibraryIds(userId, isSuperuser);
const libraryFilter = this.libraryFilter(this.intersectLibraryIds(accessible, filterLibraryIds));
const since = this.sinceDateForDays(days);
const topBookIds = await this.db
.select({ bookId: bookFiles.bookId })
.from(readingSessions)
.innerJoin(bookFiles, eq(bookFiles.id, readingSessions.bookFileId))
.innerJoin(books, eq(books.id, bookFiles.bookId))
.where(and(eq(readingSessions.userId, userId), gte(readingSessions.startedAt, since), isNotNull(readingSessions.endProgress), libraryFilter))
.groupBy(bookFiles.bookId)
.orderBy(sql`count(*) desc`)
.limit(limit)
.then((rows) => rows.map((r) => r.bookId));
if (!topBookIds.length) return [];
return this.db
.select({
bookId: bookFiles.bookId,
title: bookMetadata.title,
startedAt: readingSessions.startedAt,
endProgress: readingSessions.endProgress,
})
.from(readingSessions)
.innerJoin(bookFiles, eq(bookFiles.id, readingSessions.bookFileId))
.leftJoin(bookMetadata, eq(bookMetadata.bookId, bookFiles.bookId))
.where(and(eq(readingSessions.userId, userId), inArray(bookFiles.bookId, topBookIds), isNotNull(readingSessions.endProgress)))
.orderBy(bookFiles.bookId, readingSessions.startedAt)
.then((rows) => rows.map((r) => ({ ...r, endProgress: r.endProgress! })));
}
async getSessionArchetypePoints(
userId: number,
isSuperuser: boolean,
filterLibraryIds?: number[],
days = 365,
): Promise<UserSessionArchetypePoint[]> {
const accessible = await this.getAccessibleLibraryIds(userId, isSuperuser);
const libraryFilter = this.libraryFilter(this.intersectLibraryIds(accessible, filterLibraryIds));
const since = this.sinceDateForDays(days);
const hourExpr = sql<number>`extract(hour from ${readingSessions.startedAt}) + extract(minute from ${readingSessions.startedAt}) / 60.0`;
const durationExpr = sql<number>`${readingSessions.durationSeconds} / 60.0`;
const dowExpr = sql<number>`extract(dow from ${readingSessions.startedAt})::int`;
const rows = await this.db
.select({ hour: hourExpr, durationMinutes: durationExpr, dayOfWeek: dowExpr })
.from(readingSessions)
.innerJoin(bookFiles, eq(bookFiles.id, readingSessions.bookFileId))
.innerJoin(books, eq(books.id, bookFiles.bookId))
.where(and(eq(readingSessions.userId, userId), gte(readingSessions.startedAt, since), gte(readingSessions.durationSeconds, 300), libraryFilter))
.orderBy(readingSessions.startedAt)
.limit(2000);
return rows.map((r) => ({
hour: Number(r.hour),
durationMinutes: Number(r.durationMinutes),
dayOfWeek: Number(r.dayOfWeek),
}));
}
async getAuthorGenreChord(
userId: number,
isSuperuser: boolean,
filterLibraryIds?: number[],
days = 1825,
authorLimit = 12,
genreLimit = 12,
): Promise<ChordDiagramData> {
const accessible = await this.getAccessibleLibraryIds(userId, isSuperuser);
const libFilter = this.intersectLibraryIds(accessible, filterLibraryIds);
const since = this.sinceDateForDays(days);
const sinceStr = since.toISOString().slice(0, 10);
const libCondition = libFilter === null ? sql`true` : libFilter.length === 0 ? sql`false` : inArray(books.libraryId, libFilter);
const rows = await this.db.execute<{ author: string; genre: string; reading_seconds: number }>(sql`
with top_authors as (
select a.name, sum(rs.duration_seconds) as total
from reading_sessions rs
inner join book_files bf on bf.id = rs.book_file_id
inner join books b on b.id = bf.book_id
inner join book_authors ba on ba.book_id = b.id
inner join authors a on a.id = ba.author_id
where rs.user_id = ${userId}
and rs.started_at >= ${sinceStr}::timestamp
and ${libCondition}
group by a.name
order by total desc
limit ${authorLimit}
),
top_genres as (
select g.name, sum(rs.duration_seconds) as total
from reading_sessions rs
inner join book_files bf on bf.id = rs.book_file_id
inner join books b on b.id = bf.book_id
inner join book_genres bg on bg.book_id = b.id
inner join genres g on g.id = bg.genre_id
where rs.user_id = ${userId}
and rs.started_at >= ${sinceStr}::timestamp
and ${libCondition}
group by g.name
order by total desc
limit ${genreLimit}
)
select
a.name as author,
g.name as genre,
sum(rs.duration_seconds)::int as reading_seconds
from reading_sessions rs
inner join book_files bf on bf.id = rs.book_file_id
inner join books b on b.id = bf.book_id
inner join book_authors ba on ba.book_id = b.id
inner join top_authors a on a.name = (
select a2.name from book_authors ba2 inner join authors a2 on a2.id = ba2.author_id where ba2.book_id = b.id order by ba2.display_order, ba2.author_id limit 1
)
inner join book_genres bg on bg.book_id = b.id
inner join top_genres g on g.name = (
select g2.name from book_genres bg2 inner join genres g2 on g2.id = bg2.genre_id where bg2.book_id = b.id order by bg2.genre_id limit 1
)
where rs.user_id = ${userId}
and rs.started_at >= ${sinceStr}::timestamp
and ${libCondition}
group by a.name, g.name
having sum(rs.duration_seconds) > 0
order by reading_seconds desc
`);
if (rows.rows.length === 0) return { nodes: [], links: [] };
const authorNames = [...new Set(rows.rows.map((r) => r.author))];
const genreNames = [...new Set(rows.rows.map((r) => r.genre))];
return {
nodes: [...authorNames.map((n) => ({ name: n })), ...genreNames.map((n) => ({ name: n }))],
links: rows.rows.map((r) => ({ source: r.author, target: r.genre, value: r.reading_seconds })),
};
}
async recomputeRecentDailyStats(days = RECENT_DAILY_AGGREGATION_DAYS): Promise<{ deleted: number; inserted: number; since: string }> {
const sinceDay = this.sinceDateForDays(days).toISOString().slice(0, 10);
return this.db.transaction(async (tx) => {
const deleteResult = await tx.execute(sql`delete from user_reading_daily_stats where day >= ${sinceDay}::date`);
@@ -311,7 +542,7 @@ export class UserStatisticsRepository {
from reading_sessions rs
inner join book_files bf on bf.id = rs.book_file_id
inner join books b on b.id = bf.book_id
where rs.started_at >= ${since}
where rs.started_at >= ${sinceDay}::timestamp
group by rs.user_id, b.library_id, date_trunc('day', rs.started_at)::date
`);
@@ -1,15 +1,21 @@
import { Injectable } from '@nestjs/common';
import type {
ChordDiagramData,
UserCompletionLatencyDistribution,
UserCompletionRaceBook,
UserCompletionTimelinePoint,
UserDailyReadingStat,
UserFavoriteDayStat,
UserGenreReadingTimeItem,
UserGoalTrajectory,
UserGoalTrajectoryPoint,
UserPeakHourStat,
UserProgressFunnelComparison,
UserProgressFunnel,
UserReadingPacePoint,
UserReadingSurvivalPoint,
UserSessionArchetypePoint,
UserStatisticsSummary,
} from '@projectx/types';
@@ -26,6 +32,11 @@ const GOAL_TRAJECTORY_DEFAULT_DAYS = 365;
const GOAL_TRAJECTORY_DEFAULT_GOAL_BOOKS = 12;
const PROGRESS_FUNNEL_DEFAULT_DAYS = 365;
const COMPLETION_LATENCY_DEFAULT_DAYS = 1825;
const GENRE_READING_TIME_DEFAULT_DAYS = 365;
const READING_PACE_DEFAULT_DAYS = 1825;
const READING_SURVIVAL_DEFAULT_DAYS = 1825;
const COMPLETION_RACE_DEFAULT_DAYS = 1825;
const SESSION_ARCHETYPES_DEFAULT_DAYS = 365;
const SUMMARY_CACHE_TTL_MS = 30_000;
const QUERY_CACHE_TTL_MS = 120_000;
const QUERY_CACHE_MAX_ENTRIES = 2_000;
@@ -318,6 +329,80 @@ export class UserStatisticsService {
});
}
async getReadingSurvival(user: RequestUser, query: UserDailyReadingQueryDto): Promise<UserReadingSurvivalPoint[]> {
const days = query.days ?? READING_SURVIVAL_DEFAULT_DAYS;
const key = this.cacheKey('reading-survival', user, { libraries: this.normalizeLibraryIds(query.libraryIds), days });
return this.withCache(key, QUERY_CACHE_TTL_MS, async () => {
const values = await this.repo.getReadingSurvivalMaxProgress(user.id, user.isSuperuser, query.libraryIds, days);
const total = values.length;
const thresholds = Array.from({ length: 21 }, (_, i) => i * 5);
return thresholds.map((threshold) => {
const survivedCount = values.filter((v) => v >= threshold).length;
return {
threshold,
survivedCount,
survivedPct: total > 0 ? Number(((survivedCount / total) * 100).toFixed(1)) : 0,
};
});
});
}
async getCompletionRace(user: RequestUser, query: UserDailyReadingQueryDto): Promise<UserCompletionRaceBook[]> {
const days = query.days ?? COMPLETION_RACE_DEFAULT_DAYS;
const key = this.cacheKey('completion-race', user, { libraries: this.normalizeLibraryIds(query.libraryIds), days });
return this.withCache(key, QUERY_CACHE_TTL_MS, async () => {
const rows = await this.repo.getCompletionRaceRawSessions(user.id, user.isSuperuser, query.libraryIds, days);
const byBook = new Map<number, { title: string; sessions: { startedAt: Date; endProgress: number }[] }>();
for (const row of rows) {
if (!byBook.has(row.bookId)) {
byBook.set(row.bookId, { title: row.title ?? `Book ${row.bookId}`, sessions: [] });
}
byBook.get(row.bookId)!.sessions.push({ startedAt: row.startedAt, endProgress: row.endProgress });
}
const result: UserCompletionRaceBook[] = [];
for (const [bookId, { title, sessions }] of byBook.entries()) {
if (sessions.length < 2) continue;
const firstMs = sessions[0].startedAt.getTime();
result.push({
bookId,
title: title.length > 40 ? `${title.slice(0, 37)}...` : title,
points: sessions.map((s) => ({
daysSinceStart: Number(((s.startedAt.getTime() - firstMs) / 86_400_000).toFixed(2)),
progress: Number(s.endProgress.toFixed(1)),
})),
});
}
return result;
});
}
async getSessionArchetypes(user: RequestUser, query: UserDailyReadingQueryDto): Promise<UserSessionArchetypePoint[]> {
const days = query.days ?? SESSION_ARCHETYPES_DEFAULT_DAYS;
const key = this.cacheKey('session-archetypes', user, { libraries: this.normalizeLibraryIds(query.libraryIds), days });
return this.withCache(key, QUERY_CACHE_TTL_MS, () => this.repo.getSessionArchetypePoints(user.id, user.isSuperuser, query.libraryIds, days));
}
async getGenreReadingTime(user: RequestUser, query: UserDailyReadingQueryDto): Promise<UserGenreReadingTimeItem[]> {
const days = query.days ?? GENRE_READING_TIME_DEFAULT_DAYS;
const key = this.cacheKey('genre-reading-time', user, { libraries: this.normalizeLibraryIds(query.libraryIds), days });
return this.withCache(key, QUERY_CACHE_TTL_MS, () => this.repo.getGenreReadingTime(user.id, user.isSuperuser, query.libraryIds, days));
}
async getReadingPace(user: RequestUser, query: UserDailyReadingQueryDto): Promise<UserReadingPacePoint[]> {
const days = query.days ?? READING_PACE_DEFAULT_DAYS;
const key = this.cacheKey('reading-pace', user, { libraries: this.normalizeLibraryIds(query.libraryIds), days });
return this.withCache(key, QUERY_CACHE_TTL_MS, () => this.repo.getReadingPacePoints(user.id, user.isSuperuser, query.libraryIds, days));
}
async getAuthorGenreChord(user: RequestUser, query: UserDailyReadingQueryDto): Promise<ChordDiagramData> {
const days = query.days ?? 1825;
const key = this.cacheKey('author-genre-chord', user, { libraries: this.normalizeLibraryIds(query.libraryIds), days });
return this.withCache(key, QUERY_CACHE_TTL_MS, () => this.repo.getAuthorGenreChord(user.id, user.isSuperuser, query.libraryIds, days));
}
async recomputeRecentDailyStats(days = 2) {
const result = await this.repo.recomputeRecentDailyStats(days);
this.queryCache.clear();