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
}