-
Library Statistics
+
+
+
-
+
Drag to reorder, toggle to show or hide.
@@ -150,14 +189,14 @@ function chartMeta(id: StatisticsChartId) {
diff --git a/client/src/features/statistics/components/BooksAddedOverTimeChart.vue b/client/src/features/statistics/components/library/BooksAddedOverTimeChart.vue
similarity index 92%
rename from client/src/features/statistics/components/BooksAddedOverTimeChart.vue
rename to client/src/features/statistics/components/library/BooksAddedOverTimeChart.vue
index 5d84dba14..745a146b4 100644
--- a/client/src/features/statistics/components/BooksAddedOverTimeChart.vue
+++ b/client/src/features/statistics/components/library/BooksAddedOverTimeChart.vue
@@ -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 },
},
],
}
diff --git a/client/src/features/statistics/components/FormatDistributionChart.vue b/client/src/features/statistics/components/library/FormatDistributionChart.vue
similarity index 82%
rename from client/src/features/statistics/components/FormatDistributionChart.vue
rename to client/src/features/statistics/components/library/FormatDistributionChart.vue
index 78a962dda..5b0c34944 100644
--- a/client/src/features/statistics/components/FormatDistributionChart.vue
+++ b/client/src/features/statistics/components/library/FormatDistributionChart.vue
@@ -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 },
},
],
}
diff --git a/client/src/features/statistics/components/FormatShareOverTimeChart.vue b/client/src/features/statistics/components/library/FormatShareOverTimeChart.vue
similarity index 95%
rename from client/src/features/statistics/components/FormatShareOverTimeChart.vue
rename to client/src/features/statistics/components/library/FormatShareOverTimeChart.vue
index 607cbf906..947ec08d4 100644
--- a/client/src/features/statistics/components/FormatShareOverTimeChart.vue
+++ b/client/src/features/statistics/components/library/FormatShareOverTimeChart.vue
@@ -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 ''
diff --git a/client/src/features/statistics/components/library/GenreCooccurrenceChart.vue b/client/src/features/statistics/components/library/GenreCooccurrenceChart.vue
new file mode 100644
index 000000000..3cbe2a664
--- /dev/null
+++ b/client/src/features/statistics/components/library/GenreCooccurrenceChart.vue
@@ -0,0 +1,56 @@
+
+
+
+
+
+
+
diff --git a/client/src/features/statistics/components/GenreDistributionChart.vue b/client/src/features/statistics/components/library/GenreDistributionChart.vue
similarity index 86%
rename from client/src/features/statistics/components/GenreDistributionChart.vue
rename to client/src/features/statistics/components/library/GenreDistributionChart.vue
index 1ed38c150..d130ee537 100644
--- a/client/src/features/statistics/components/GenreDistributionChart.vue
+++ b/client/src/features/statistics/components/library/GenreDistributionChart.vue
@@ -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 },
},
],
}
diff --git a/client/src/features/statistics/components/GenreRankOverTimeChart.vue b/client/src/features/statistics/components/library/GenreRankOverTimeChart.vue
similarity index 93%
rename from client/src/features/statistics/components/GenreRankOverTimeChart.vue
rename to client/src/features/statistics/components/library/GenreRankOverTimeChart.vue
index dafe8b020..ddebb2068 100644
--- a/client/src/features/statistics/components/GenreRankOverTimeChart.vue
+++ b/client/src/features/statistics/components/library/GenreRankOverTimeChart.vue
@@ -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))
diff --git a/client/src/features/statistics/components/LanguageDistributionChart.vue b/client/src/features/statistics/components/library/LanguageDistributionChart.vue
similarity index 82%
rename from client/src/features/statistics/components/LanguageDistributionChart.vue
rename to client/src/features/statistics/components/library/LanguageDistributionChart.vue
index b06631639..2ece29e9a 100644
--- a/client/src/features/statistics/components/LanguageDistributionChart.vue
+++ b/client/src/features/statistics/components/library/LanguageDistributionChart.vue
@@ -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 },
},
],
}
diff --git a/client/src/features/statistics/components/LibraryMetadataCompletenessHeatmapChart.vue b/client/src/features/statistics/components/library/LibraryMetadataCompletenessHeatmapChart.vue
similarity index 93%
rename from client/src/features/statistics/components/LibraryMetadataCompletenessHeatmapChart.vue
rename to client/src/features/statistics/components/library/LibraryMetadataCompletenessHeatmapChart.vue
index 020b0bd4e..9c284bd46 100644
--- a/client/src/features/statistics/components/LibraryMetadataCompletenessHeatmapChart.vue
+++ b/client/src/features/statistics/components/library/LibraryMetadataCompletenessHeatmapChart.vue
@@ -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,
diff --git a/client/src/features/statistics/components/MetadataCompletenessChart.vue b/client/src/features/statistics/components/library/MetadataCompletenessChart.vue
similarity index 87%
rename from client/src/features/statistics/components/MetadataCompletenessChart.vue
rename to client/src/features/statistics/components/library/MetadataCompletenessChart.vue
index 0dd997021..7c0ca8559 100644
--- a/client/src/features/statistics/components/MetadataCompletenessChart.vue
+++ b/client/src/features/statistics/components/library/MetadataCompletenessChart.vue
@@ -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 },
},
],
}
diff --git a/client/src/features/statistics/components/MetadataScoreDistributionChart.vue b/client/src/features/statistics/components/library/MetadataScoreDistributionChart.vue
similarity index 91%
rename from client/src/features/statistics/components/MetadataScoreDistributionChart.vue
rename to client/src/features/statistics/components/library/MetadataScoreDistributionChart.vue
index fb999a43e..89038679f 100644
--- a/client/src/features/statistics/components/MetadataScoreDistributionChart.vue
+++ b/client/src/features/statistics/components/library/MetadataScoreDistributionChart.vue
@@ -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
? {
diff --git a/client/src/features/statistics/components/PageCountDistributionChart.vue b/client/src/features/statistics/components/library/PageCountDistributionChart.vue
similarity index 88%
rename from client/src/features/statistics/components/PageCountDistributionChart.vue
rename to client/src/features/statistics/components/library/PageCountDistributionChart.vue
index 0b2808d93..55d004f35 100644
--- a/client/src/features/statistics/components/PageCountDistributionChart.vue
+++ b/client/src/features/statistics/components/library/PageCountDistributionChart.vue
@@ -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 },
},
],
}
diff --git a/client/src/features/statistics/components/PublicationDecadeChart.vue b/client/src/features/statistics/components/library/PublicationDecadeChart.vue
similarity index 86%
rename from client/src/features/statistics/components/PublicationDecadeChart.vue
rename to client/src/features/statistics/components/library/PublicationDecadeChart.vue
index b1a3c041a..9cc0ebc4a 100644
--- a/client/src/features/statistics/components/PublicationDecadeChart.vue
+++ b/client/src/features/statistics/components/library/PublicationDecadeChart.vue
@@ -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 },
},
],
}
diff --git a/client/src/features/statistics/components/StorageByFormatChart.vue b/client/src/features/statistics/components/library/StorageByFormatChart.vue
similarity index 88%
rename from client/src/features/statistics/components/StorageByFormatChart.vue
rename to client/src/features/statistics/components/library/StorageByFormatChart.vue
index 17b615efe..5b23a4723 100644
--- a/client/src/features/statistics/components/StorageByFormatChart.vue
+++ b/client/src/features/statistics/components/library/StorageByFormatChart.vue
@@ -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 },
},
],
}
diff --git a/client/src/features/statistics/components/TopAuthorsChart.vue b/client/src/features/statistics/components/library/TopAuthorsChart.vue
similarity index 86%
rename from client/src/features/statistics/components/TopAuthorsChart.vue
rename to client/src/features/statistics/components/library/TopAuthorsChart.vue
index 6d6e0261f..bdeb3a3e7 100644
--- a/client/src/features/statistics/components/TopAuthorsChart.vue
+++ b/client/src/features/statistics/components/library/TopAuthorsChart.vue
@@ -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 },
},
],
}
diff --git a/client/src/features/statistics/components/user/BooksCompletedChart.vue b/client/src/features/statistics/components/user/BooksCompletedChart.vue
new file mode 100644
index 000000000..e9cf20a94
--- /dev/null
+++ b/client/src/features/statistics/components/user/BooksCompletedChart.vue
@@ -0,0 +1,80 @@
+
+
+
+
+
+ Complete at least {{ MIN_COMPLETIONS }} books to see your curve
+
+
+
+
diff --git a/client/src/features/statistics/components/CompletionLatencyChart.vue b/client/src/features/statistics/components/user/CompletionLatencyChart.vue
similarity index 92%
rename from client/src/features/statistics/components/CompletionLatencyChart.vue
rename to client/src/features/statistics/components/user/CompletionLatencyChart.vue
index 910275310..46544d2c8 100644
--- a/client/src/features/statistics/components/CompletionLatencyChart.vue
+++ b/client/src/features/statistics/components/user/CompletionLatencyChart.vue
@@ -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 },
},
],
}
diff --git a/client/src/features/statistics/components/CompletionTimelineChart.vue b/client/src/features/statistics/components/user/CompletionTimelineChart.vue
similarity index 90%
rename from client/src/features/statistics/components/CompletionTimelineChart.vue
rename to client/src/features/statistics/components/user/CompletionTimelineChart.vue
index 3678edd9f..7a9445fd4 100644
--- a/client/src/features/statistics/components/CompletionTimelineChart.vue
+++ b/client/src/features/statistics/components/user/CompletionTimelineChart.vue
@@ -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 },
},
],
}
diff --git a/client/src/features/statistics/components/FavoriteReadingDaysChart.vue b/client/src/features/statistics/components/user/FavoriteReadingDaysChart.vue
similarity index 93%
rename from client/src/features/statistics/components/FavoriteReadingDaysChart.vue
rename to client/src/features/statistics/components/user/FavoriteReadingDaysChart.vue
index 3a74faa89..e76010db4 100644
--- a/client/src/features/statistics/components/FavoriteReadingDaysChart.vue
+++ b/client/src/features/statistics/components/user/FavoriteReadingDaysChart.vue
@@ -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 },
},
],
}
diff --git a/client/src/features/statistics/components/user/GenreReadingTimeTreemapChart.vue b/client/src/features/statistics/components/user/GenreReadingTimeTreemapChart.vue
new file mode 100644
index 000000000..fc5163f96
--- /dev/null
+++ b/client/src/features/statistics/components/user/GenreReadingTimeTreemapChart.vue
@@ -0,0 +1,75 @@
+
+
+
+
+
+ Need at least {{ MIN_GENRES }} genres with reading time
+
+
+
+
diff --git a/client/src/features/statistics/components/GoalTrajectoryChart.vue b/client/src/features/statistics/components/user/GoalTrajectoryChart.vue
similarity index 91%
rename from client/src/features/statistics/components/GoalTrajectoryChart.vue
rename to client/src/features/statistics/components/user/GoalTrajectoryChart.vue
index 31e56da90..076078ca9 100644
--- a/client/src/features/statistics/components/GoalTrajectoryChart.vue
+++ b/client/src/features/statistics/components/user/GoalTrajectoryChart.vue
@@ -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}:
${item.data}`)
return `${params[0]?.axisValue ?? ''}
${lines.join('
')}`
@@ -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 },
},
],
}
diff --git a/client/src/features/statistics/components/PeakReadingHoursChart.vue b/client/src/features/statistics/components/user/PeakReadingHoursChart.vue
similarity index 90%
rename from client/src/features/statistics/components/PeakReadingHoursChart.vue
rename to client/src/features/statistics/components/user/PeakReadingHoursChart.vue
index fcf87949d..fb34f7240 100644
--- a/client/src/features/statistics/components/PeakReadingHoursChart.vue
+++ b/client/src/features/statistics/components/user/PeakReadingHoursChart.vue
@@ -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 },
},
],
}
diff --git a/client/src/features/statistics/components/ProgressFunnelChart.vue b/client/src/features/statistics/components/user/ProgressFunnelChart.vue
similarity index 96%
rename from client/src/features/statistics/components/ProgressFunnelChart.vue
rename to client/src/features/statistics/components/user/ProgressFunnelChart.vue
index 3cda648b9..aa31520f7 100644
--- a/client/src/features/statistics/components/ProgressFunnelChart.vue
+++ b/client/src/features/statistics/components/user/ProgressFunnelChart.vue
@@ -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
= {
@@ -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,
},
],
diff --git a/client/src/features/statistics/components/user/ReadingClockChart.vue b/client/src/features/statistics/components/user/ReadingClockChart.vue
new file mode 100644
index 000000000..69fc2dd41
--- /dev/null
+++ b/client/src/features/statistics/components/user/ReadingClockChart.vue
@@ -0,0 +1,107 @@
+
+
+
+
+
+ Need at least {{ MIN_EVENTS }} sessions for a stable clock
+
+
+
+ Peak: {{ HOUR_LABELS[peakHour.hour] }}
+
+
+
+
+
diff --git a/client/src/features/statistics/components/ReadingHeatmapChart.vue b/client/src/features/statistics/components/user/ReadingHeatmapChart.vue
similarity index 95%
rename from client/src/features/statistics/components/ReadingHeatmapChart.vue
rename to client/src/features/statistics/components/user/ReadingHeatmapChart.vue
index 0ff5da1d1..af2008226 100644
--- a/client/src/features/statistics/components/ReadingHeatmapChart.vue
+++ b/client/src/features/statistics/components/user/ReadingHeatmapChart.vue
@@ -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,
diff --git a/client/src/features/statistics/components/user/ReadingPaceScatterChart.vue b/client/src/features/statistics/components/user/ReadingPaceScatterChart.vue
new file mode 100644
index 000000000..b0255efec
--- /dev/null
+++ b/client/src/features/statistics/components/user/ReadingPaceScatterChart.vue
@@ -0,0 +1,68 @@
+
+
+
+
+
+ Need at least {{ MIN_SESSIONS }} sessions with progress data
+
+
+
+
diff --git a/client/src/features/statistics/components/user/SessionArchetypesChart.vue b/client/src/features/statistics/components/user/SessionArchetypesChart.vue
new file mode 100644
index 000000000..b21d21bc5
--- /dev/null
+++ b/client/src/features/statistics/components/user/SessionArchetypesChart.vue
@@ -0,0 +1,88 @@
+
+
+
+
+
+
+
diff --git a/client/src/features/statistics/composables/useGenreCooccurrence.ts b/client/src/features/statistics/composables/useGenreCooccurrence.ts
new file mode 100644
index 000000000..56bf99636
--- /dev/null
+++ b/client/src/features/statistics/composables/useGenreCooccurrence.ts
@@ -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,
+ })
+}
diff --git a/client/src/features/statistics/composables/useStatisticsConfig.ts b/client/src/features/statistics/composables/useStatisticsConfig.ts
index 09930a451..0b54b7931 100644
--- a/client/src/features/statistics/composables/useStatisticsConfig.ts
+++ b/client/src/features/statistics/composables/useStatisticsConfig.ts
@@ -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(LIBRARY_CHART_IDS)
+const userChartIdSet = new Set(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,
+ }
}
diff --git a/client/src/features/statistics/composables/useUserBooksCompleted.ts b/client/src/features/statistics/composables/useUserBooksCompleted.ts
new file mode 100644
index 000000000..00b7cf725
--- /dev/null
+++ b/client/src/features/statistics/composables/useUserBooksCompleted.ts
@@ -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,
+ })
+}
diff --git a/client/src/features/statistics/composables/useUserGenreReadingTime.ts b/client/src/features/statistics/composables/useUserGenreReadingTime.ts
new file mode 100644
index 000000000..5b59f7642
--- /dev/null
+++ b/client/src/features/statistics/composables/useUserGenreReadingTime.ts
@@ -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,
+ })
+}
diff --git a/client/src/features/statistics/composables/useUserReadingPace.ts b/client/src/features/statistics/composables/useUserReadingPace.ts
new file mode 100644
index 000000000..bd92de4a3
--- /dev/null
+++ b/client/src/features/statistics/composables/useUserReadingPace.ts
@@ -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,
+ })
+}
diff --git a/client/src/features/statistics/composables/useUserSessionArchetypes.ts b/client/src/features/statistics/composables/useUserSessionArchetypes.ts
new file mode 100644
index 000000000..8f06a5acc
--- /dev/null
+++ b/client/src/features/statistics/composables/useUserSessionArchetypes.ts
@@ -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,
+ })
+}
diff --git a/client/src/features/statistics/statistics-chart-meta.ts b/client/src/features/statistics/statistics-chart-meta.ts
index be52304d5..7875b5f82 100644
--- a/client/src/features/statistics/statistics-chart-meta.ts
+++ b/client/src/features/statistics/statistics-chart-meta.ts
@@ -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 = {
@@ -33,104 +40,162 @@ export const STATISTICS_CHART_META: Record 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]
diff --git a/client/src/lib/echarts.ts b/client/src/lib/echarts.ts
index 4ad423df1..f21cf8626 100644
--- a/client/src/lib/echarts.ts
+++ b/client/src/lib/echarts.ts
@@ -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 = {
+ 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
}
diff --git a/packages/types/src/statistics.ts b/packages/types/src/statistics.ts
index 5a3fa1980..04cde4e30 100644
--- a/packages/types/src/statistics.ts
+++ b/packages/types/src/statistics.ts
@@ -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 {
items: T[];
unknownCount: number;
diff --git a/packages/types/src/user-statistics.ts b/packages/types/src/user-statistics.ts
index 0d6c39e7b..f9516d66b 100644
--- a/packages/types/src/user-statistics.ts
+++ b/packages/types/src/user-statistics.ts
@@ -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[];
+}
diff --git a/scripts/seed/reading-sessions.sh b/scripts/seed/reading-sessions.sh
new file mode 100755
index 000000000..9505b0031
--- /dev/null
+++ b/scripts/seed/reading-sessions.sh
@@ -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."
diff --git a/server/src/modules/statistics/statistics.controller.ts b/server/src/modules/statistics/statistics.controller.ts
index f5a14b60a..b8c7909fe 100644
--- a/server/src/modules/statistics/statistics.controller.ts
+++ b/server/src/modules/statistics/statistics.controller.ts
@@ -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);
+ }
}
diff --git a/server/src/modules/statistics/statistics.repository.ts b/server/src/modules/statistics/statistics.repository.ts
index 296a64811..0d19ab328 100644
--- a/server/src/modules/statistics/statistics.repository.ts
+++ b/server/src/modules/statistics/statistics.repository.ts
@@ -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 {
+ 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 })),
+ };
+ }
}
diff --git a/server/src/modules/statistics/statistics.service.ts b/server/src/modules/statistics/statistics.service.ts
index cdc2ee21e..3aadad0e0 100644
--- a/server/src/modules/statistics/statistics.service.ts
+++ b/server/src/modules/statistics/statistics.service.ts
@@ -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 {
return this.repo.getSummary(user.id, user.isSuperuser, query.libraryIds);
}
+
+ async getGenreCooccurrence(user: RequestUser, query: StatisticsFilterQueryDto): Promise {
+ return this.repo.getGenreCooccurrence(user.id, user.isSuperuser, query.libraryIds);
+ }
}
diff --git a/server/src/modules/user-statistics/user-statistics-aggregation.job.ts b/server/src/modules/user-statistics/user-statistics-aggregation.job.ts
index ece0b0ce1..2df74bec5 100644
--- a/server/src/modules/user-statistics/user-statistics-aggregation.job.ts
+++ b/server/src/modules/user-statistics/user-statistics-aggregation.job.ts
@@ -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);
}
}
}
diff --git a/server/src/modules/user-statistics/user-statistics.controller.ts b/server/src/modules/user-statistics/user-statistics.controller.ts
index 01a639000..9714dfe04 100644
--- a/server/src/modules/user-statistics/user-statistics.controller.ts
+++ b/server/src/modules/user-statistics/user-statistics.controller.ts
@@ -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);
+ }
}
diff --git a/server/src/modules/user-statistics/user-statistics.repository.ts b/server/src/modules/user-statistics/user-statistics.repository.ts
index c321202d4..b5e2d9e8d 100644
--- a/server/src/modules/user-statistics/user-statistics.repository.ts
+++ b/server/src/modules/user-statistics/user-statistics.repository.ts
@@ -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;
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 {
+ 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`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 {
+ 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 {
+ 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`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 {
+ const accessible = await this.getAccessibleLibraryIds(userId, isSuperuser);
+ const libraryFilter = this.libraryFilter(this.intersectLibraryIds(accessible, filterLibraryIds));
+ const since = this.sinceDateForDays(days);
+
+ const hourExpr = sql`extract(hour from ${readingSessions.startedAt}) + extract(minute from ${readingSessions.startedAt}) / 60.0`;
+ const durationExpr = sql`${readingSessions.durationSeconds} / 60.0`;
+ const dowExpr = sql`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 {
+ 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
`);
diff --git a/server/src/modules/user-statistics/user-statistics.service.ts b/server/src/modules/user-statistics/user-statistics.service.ts
index bb16295d8..239192415 100644
--- a/server/src/modules/user-statistics/user-statistics.service.ts
+++ b/server/src/modules/user-statistics/user-statistics.service.ts
@@ -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 {
+ 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 {
+ 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();
+
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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();