mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
feat(stats): add user analytics charts and stabilize ECharts theme
This commit is contained in:
+10
-10
@@ -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
@@ -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>
|
||||
|
||||
+3
-7
@@ -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 },
|
||||
},
|
||||
],
|
||||
}
|
||||
+3
-5
@@ -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 },
|
||||
},
|
||||
],
|
||||
}
|
||||
+2
-4
@@ -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>
|
||||
+2
-6
@@ -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 },
|
||||
},
|
||||
],
|
||||
}
|
||||
+2
-7
@@ -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))
|
||||
+3
-5
@@ -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
-5
@@ -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,
|
||||
+2
-6
@@ -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 },
|
||||
},
|
||||
],
|
||||
}
|
||||
+2
-5
@@ -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
|
||||
? {
|
||||
+2
-5
@@ -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 },
|
||||
},
|
||||
],
|
||||
}
|
||||
+2
-6
@@ -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 },
|
||||
},
|
||||
],
|
||||
}
|
||||
+2
-6
@@ -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 },
|
||||
},
|
||||
],
|
||||
}
|
||||
+2
-6
@@ -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>
|
||||
+2
-6
@@ -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 },
|
||||
},
|
||||
],
|
||||
}
|
||||
+2
-6
@@ -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 },
|
||||
},
|
||||
],
|
||||
}
|
||||
+2
-6
@@ -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>
|
||||
+2
-8
@@ -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 },
|
||||
},
|
||||
],
|
||||
}
|
||||
+2
-6
@@ -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 },
|
||||
},
|
||||
],
|
||||
}
|
||||
+2
-10
@@ -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
-5
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user