From cfc48b6218005281fd896e6e89ad6677a99fd345 Mon Sep 17 00:00:00 2001 From: neon <18559658+neonsolstice@users.noreply.github.com> Date: Tue, 30 Jun 2026 19:56:48 +0000 Subject: [PATCH] feat(stats): demote reading source in default charts and breakdowns Reading source is often less relevant for day-to-day analytics than book format or reading pace. Shift the source distribution chart to the end of the user dashboard and change the default breakdown dimension from source to format to prioritize more useful metrics. Include a migration to update existing users who have not customized their chart order. --- .../user/FavoriteReadingDaysChart.vue | 4 +- .../components/user/PeakReadingHoursChart.vue | 4 +- .../components/user/ReadingClockChart.vue | 4 +- .../user/ReadingPaceScatterChart.vue | 4 +- .../user/ReadingSessionTimelineChart.vue | 10 +++- .../composables/useStatisticsConfig.test.ts | 39 ++++++++++++++- .../composables/useStatisticsConfig.ts | 50 +++++++++++++++++-- .../features/statistics/lib/breakdown.test.ts | 7 ++- .../src/features/statistics/lib/breakdown.ts | 4 +- .../statistics/statistics-chart-meta.test.ts | 9 ++++ packages/types/src/statistics.ts | 2 +- 11 files changed, 119 insertions(+), 18 deletions(-) create mode 100644 client/src/features/statistics/statistics-chart-meta.test.ts diff --git a/client/src/features/statistics/components/user/FavoriteReadingDaysChart.vue b/client/src/features/statistics/components/user/FavoriteReadingDaysChart.vue index 38d13b4bc..0f31728b3 100644 --- a/client/src/features/statistics/components/user/FavoriteReadingDaysChart.vue +++ b/client/src/features/statistics/components/user/FavoriteReadingDaysChart.vue @@ -5,7 +5,7 @@ import { CalendarDays } from '@lucide/vue' import type { ReadingSessionSourceBucket } from '@bookorbit/types' import { useThemeStore } from '@/stores/theme' -import { getBreakdownSeries, type BreakdownDimension } from '../../lib/breakdown' +import { DEFAULT_BREAKDOWN_DIMENSION, getBreakdownSeries, type BreakdownDimension } from '../../lib/breakdown' import { useUserFavoriteReadingDays } from '../../composables/useUserFavoriteReadingDays' import BreakdownSelect from '../BreakdownSelect.vue' import ChartCard from '../ChartCard.vue' @@ -22,7 +22,7 @@ const option = shallowRef({}) const totalEvents = computed(() => data.value.reduce((sum, item) => sum + item.eventsCount, 0)) const isEmpty = computed(() => totalEvents.value === 0) const lowConfidence = computed(() => totalEvents.value > 0 && totalEvents.value < MIN_EVENTS) -const dimension = ref('source') +const dimension = ref(DEFAULT_BREAKDOWN_DIMENSION) function weekdayOccurrencesInWindow(days: number): number[] { const counts = Array.from({ length: 7 }, () => 0) diff --git a/client/src/features/statistics/components/user/PeakReadingHoursChart.vue b/client/src/features/statistics/components/user/PeakReadingHoursChart.vue index 866a6b093..23d63133b 100644 --- a/client/src/features/statistics/components/user/PeakReadingHoursChart.vue +++ b/client/src/features/statistics/components/user/PeakReadingHoursChart.vue @@ -5,7 +5,7 @@ import { Clock3 } from '@lucide/vue' import type { ReadingSessionSourceBucket } from '@bookorbit/types' import { useThemeStore } from '@/stores/theme' -import { getBreakdownSeries, type BreakdownDimension } from '../../lib/breakdown' +import { DEFAULT_BREAKDOWN_DIMENSION, getBreakdownSeries, type BreakdownDimension } from '../../lib/breakdown' import { useUserPeakReadingHours } from '../../composables/useUserPeakReadingHours' import BreakdownSelect from '../BreakdownSelect.vue' import ChartCard from '../ChartCard.vue' @@ -16,7 +16,7 @@ const MIN_EVENTS = 20 const themeStore = useThemeStore() const { data, loading, error } = useUserPeakReadingHours() const option = shallowRef({}) -const dimension = ref('source') +const dimension = ref(DEFAULT_BREAKDOWN_DIMENSION) const totalEvents = computed(() => data.value.reduce((sum, item) => sum + item.eventsCount, 0)) const isEmpty = computed(() => totalEvents.value === 0) diff --git a/client/src/features/statistics/components/user/ReadingClockChart.vue b/client/src/features/statistics/components/user/ReadingClockChart.vue index d54e92027..c67646e6d 100644 --- a/client/src/features/statistics/components/user/ReadingClockChart.vue +++ b/client/src/features/statistics/components/user/ReadingClockChart.vue @@ -5,7 +5,7 @@ import { Clock } from '@lucide/vue' import type { ReadingSessionSourceBucket } from '@bookorbit/types' import { useThemeStore } from '@/stores/theme' -import { getBreakdownSeries, type BreakdownDimension } from '../../lib/breakdown' +import { DEFAULT_BREAKDOWN_DIMENSION, getBreakdownSeries, type BreakdownDimension } from '../../lib/breakdown' import { useUserPeakReadingHours } from '../../composables/useUserPeakReadingHours' import BreakdownSelect from '../BreakdownSelect.vue' import ChartCard from '../ChartCard.vue' @@ -28,7 +28,7 @@ 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 dimension = ref('source') +const dimension = ref(DEFAULT_BREAKDOWN_DIMENSION) function formatDuration(seconds: number): string { const total = Math.max(0, Math.floor(seconds)) diff --git a/client/src/features/statistics/components/user/ReadingPaceScatterChart.vue b/client/src/features/statistics/components/user/ReadingPaceScatterChart.vue index 4a7bfc5a3..77d9edaae 100644 --- a/client/src/features/statistics/components/user/ReadingPaceScatterChart.vue +++ b/client/src/features/statistics/components/user/ReadingPaceScatterChart.vue @@ -4,7 +4,7 @@ import VChart from 'vue-echarts' import { Zap } from '@lucide/vue' import { useThemeStore } from '@/stores/theme' -import { getBreakdownSeries, type BreakdownDimension } from '../../lib/breakdown' +import { DEFAULT_BREAKDOWN_DIMENSION, getBreakdownSeries, type BreakdownDimension } from '../../lib/breakdown' import { useUserReadingPace } from '../../composables/useUserReadingPace' import BreakdownSelect from '../BreakdownSelect.vue' import ChartCard from '../ChartCard.vue' @@ -19,7 +19,7 @@ const isEmpty = computed(() => data.value.length === 0) const hasEnoughData = computed(() => data.value.length >= MIN_SESSIONS) const option = shallowRef({}) -const dimension = ref('source') +const dimension = ref(DEFAULT_BREAKDOWN_DIMENSION) watchEffect(() => { option.value = {} diff --git a/client/src/features/statistics/components/user/ReadingSessionTimelineChart.vue b/client/src/features/statistics/components/user/ReadingSessionTimelineChart.vue index 2307cc9c9..0fbb0296f 100644 --- a/client/src/features/statistics/components/user/ReadingSessionTimelineChart.vue +++ b/client/src/features/statistics/components/user/ReadingSessionTimelineChart.vue @@ -8,7 +8,13 @@ import { READING_SESSION_SOURCE_BUCKET_LABELS } from '@bookorbit/types' import { useThemeStore } from '@/stores/theme' import { useCoverVersions } from '@/features/book/composables/useCoverVersions' -import { getBreakdownColor, getBreakdownSeries, type BreakdownDimension, type BreakdownSeries } from '../../lib/breakdown' +import { + DEFAULT_BREAKDOWN_DIMENSION, + getBreakdownColor, + getBreakdownSeries, + type BreakdownDimension, + type BreakdownSeries, +} from '../../lib/breakdown' import { fetchUserReadingSessionTimeline, updateUserReadingSessionTimelineSession } from '../../api/statistics.api' import { useStatisticsConfig } from '../../composables/useStatisticsConfig' import BreakdownSelect from '../BreakdownSelect.vue' @@ -60,7 +66,7 @@ const { filters } = useStatisticsConfig() const { coverUrl } = useCoverVersions() const themeStore = useThemeStore() const themeKey = computed(() => `${themeStore.theme}:${themeStore.accent}`) -const dimension = ref('source') +const dimension = ref(DEFAULT_BREAKDOWN_DIMENSION) const now = new Date() const selectedYear = ref(getIsoWeekYear(now)) diff --git a/client/src/features/statistics/composables/useStatisticsConfig.test.ts b/client/src/features/statistics/composables/useStatisticsConfig.test.ts index a787d4373..05d8ac0db 100644 --- a/client/src/features/statistics/composables/useStatisticsConfig.test.ts +++ b/client/src/features/statistics/composables/useStatisticsConfig.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { ref } from 'vue' import { flushPromises } from '@vue/test-utils' -import type { AuthUser } from '@bookorbit/types' +import type { AuthUser, ChartConfigEntry, StatisticsChartId } from '@bookorbit/types' vi.mock('@/features/auth/composables/useAuth', () => ({ useAuth: vi.fn<() => unknown>(), @@ -45,6 +45,27 @@ function setupAuth(settings: Record = {}) { return { userRef } } +const LEGACY_USER_CHART_ORDER_WITH_SOURCE_SECOND: StatisticsChartId[] = [ + 'reading-heatmap', + 'reading-source-distribution', + 'peak-reading-hours', + 'favorite-reading-days', + 'completion-timeline', + 'goal-trajectory', + 'progress-funnel', + 'completion-latency', + 'genre-reading-time', + 'reading-pace', + 'books-completed', + 'reading-clock', + 'reading-session-timeline', + 'session-archetypes', +] + +function chartEntries(ids: StatisticsChartId[]): ChartConfigEntry[] { + return ids.map((id, order) => ({ id, order, visible: true })) +} + describe('useStatisticsConfig - persist', () => { beforeEach(() => { vi.resetAllMocks() @@ -95,4 +116,20 @@ describe('useStatisticsConfig - persist', () => { expect(mockToast.error).toHaveBeenCalledWith('Failed to save chart configuration') }) + + it('migrates the old default user chart order to put Where You Read last', async () => { + setupAuth({ + statisticsConfig: { + charts: chartEntries(LEGACY_USER_CHART_ORDER_WITH_SOURCE_SECOND), + }, + }) + + const { useStatisticsConfig } = await import('./useStatisticsConfig') + const { init, orderedUserCharts } = useStatisticsConfig() + init() + + const chartIds = orderedUserCharts.value.map((chart) => chart.id) + expect(chartIds[1]).toBe('peak-reading-hours') + expect(chartIds.at(-1)).toBe('reading-source-distribution') + }) }) diff --git a/client/src/features/statistics/composables/useStatisticsConfig.ts b/client/src/features/statistics/composables/useStatisticsConfig.ts index 3f3b8746e..a0b110f0b 100644 --- a/client/src/features/statistics/composables/useStatisticsConfig.ts +++ b/client/src/features/statistics/composables/useStatisticsConfig.ts @@ -25,6 +25,51 @@ const DEFAULT_FILTERS: StatisticsFilterConfig = { const config = ref([]) const filters = ref({ ...DEFAULT_FILTERS }) +const libraryChartIdSet = new Set(LIBRARY_CHART_IDS) +const userChartIdSet = new Set(USER_CHART_IDS) + +const LEGACY_USER_CHART_ORDER_WITH_SOURCE_SECOND: StatisticsChartId[] = [ + 'reading-heatmap', + 'reading-source-distribution', + 'peak-reading-hours', + 'favorite-reading-days', + 'completion-timeline', + 'goal-trajectory', + 'progress-funnel', + 'completion-latency', + 'genre-reading-time', + 'reading-pace', + 'books-completed', + 'reading-clock', + 'reading-session-timeline', + 'session-archetypes', +] + +function matchesOrder(actual: StatisticsChartId[], expected: StatisticsChartId[]): boolean { + return actual.length === expected.length && actual.every((id, index) => id === expected[index]) +} + +function chartIdsByOrder(entries: ChartConfigEntry[], ids: Set): StatisticsChartId[] { + return entries + .filter((entry) => ids.has(entry.id)) + .sort((a, b) => a.order - b.order) + .map((entry) => entry.id) +} + +function withUserChartOrder(entries: ChartConfigEntry[], order: StatisticsChartId[]): ChartConfigEntry[] { + const orderById = new Map(order.map((id, index) => [id, index])) + return entries.map((entry) => { + const order = orderById.get(entry.id) + return order === undefined ? entry : { ...entry, order } + }) +} + +function migrateLegacyUserChartOrder(entries: ChartConfigEntry[]): ChartConfigEntry[] { + const userOrder = chartIdsByOrder(entries, userChartIdSet) + if (!matchesOrder(userOrder, LEGACY_USER_CHART_ORDER_WITH_SOURCE_SECOND)) return entries + return withUserChartOrder(entries, USER_CHART_IDS) +} + function normalizeCharts(saved: ChartConfigEntry[] | undefined): ChartConfigEntry[] { const knownSet = new Set(KNOWN_CHART_IDS) const filtered = (saved ?? []).filter((e) => knownSet.has(e.id)) @@ -34,12 +79,9 @@ function normalizeCharts(saved: ChartConfigEntry[] | undefined): ChartConfigEntr visible: true, order: filtered.length + i, })) - return [...filtered, ...newEntries] + return migrateLegacyUserChartOrder([...filtered, ...newEntries]) } -const libraryChartIdSet = new Set(LIBRARY_CHART_IDS) -const userChartIdSet = new Set(USER_CHART_IDS) - function normalizeFilters(saved: StatisticsFilterConfig | undefined): StatisticsFilterConfig { return { libraryIds: saved?.libraryIds ?? DEFAULT_FILTERS.libraryIds, diff --git a/client/src/features/statistics/lib/breakdown.test.ts b/client/src/features/statistics/lib/breakdown.test.ts index 92e1c5fde..3d40854a9 100644 --- a/client/src/features/statistics/lib/breakdown.test.ts +++ b/client/src/features/statistics/lib/breakdown.test.ts @@ -5,9 +5,14 @@ vi.mock('@/lib/echarts', () => ({ oklchToHex: () => '#000000', })) -import { getBreakdownColor, getBreakdownSeries } from './breakdown' +import { BREAKDOWN_OPTIONS, DEFAULT_BREAKDOWN_DIMENSION, getBreakdownColor, getBreakdownSeries } from './breakdown' describe('breakdown', () => { + it('defaults source-or-format charts to format', () => { + expect(DEFAULT_BREAKDOWN_DIMENSION).toBe('format') + expect(BREAKDOWN_OPTIONS[0]).toEqual({ value: 'format', label: 'Format' }) + }) + it('returns the three source buckets for the source dimension', () => { const series = getBreakdownSeries('source', 'theme:violet', []) expect(series.map((s) => s.key)).toEqual(['bookorbit', 'koreader', 'kobo']) diff --git a/client/src/features/statistics/lib/breakdown.ts b/client/src/features/statistics/lib/breakdown.ts index 7fc8ca3c9..38bea5f9b 100644 --- a/client/src/features/statistics/lib/breakdown.ts +++ b/client/src/features/statistics/lib/breakdown.ts @@ -5,9 +5,11 @@ import { resolveSourceBucketColors } from './source-bucket-colors' export type BreakdownDimension = 'source' | 'format' +export const DEFAULT_BREAKDOWN_DIMENSION: BreakdownDimension = 'format' + export const BREAKDOWN_OPTIONS: { value: BreakdownDimension; label: string }[] = [ - { value: 'source', label: 'Source' }, { value: 'format', label: 'Format' }, + { value: 'source', label: 'Source' }, ] export interface BreakdownSeries { diff --git a/client/src/features/statistics/statistics-chart-meta.test.ts b/client/src/features/statistics/statistics-chart-meta.test.ts new file mode 100644 index 000000000..5ac9193f6 --- /dev/null +++ b/client/src/features/statistics/statistics-chart-meta.test.ts @@ -0,0 +1,9 @@ +import { describe, expect, it } from 'vitest' + +import { USER_CHART_IDS } from './statistics-chart-meta' + +describe('statistics chart meta', () => { + it('places the Where You Read chart at the end of the user tab defaults', () => { + expect(USER_CHART_IDS.at(-1)).toBe('reading-source-distribution') + }) +}) diff --git a/packages/types/src/statistics.ts b/packages/types/src/statistics.ts index be3a91665..8c10511d0 100644 --- a/packages/types/src/statistics.ts +++ b/packages/types/src/statistics.ts @@ -79,7 +79,6 @@ export const DEFAULT_LIBRARY_CHART_ORDER: StatisticsChartId[] = [ export const DEFAULT_USER_CHART_ORDER: StatisticsChartId[] = [ "reading-heatmap", - "reading-source-distribution", "peak-reading-hours", "favorite-reading-days", "completion-timeline", @@ -92,6 +91,7 @@ export const DEFAULT_USER_CHART_ORDER: StatisticsChartId[] = [ "reading-clock", "reading-session-timeline", "session-archetypes", + "reading-source-distribution", ]; export const DEFAULT_STATISTICS_CHART_ORDER: StatisticsChartId[] = [...DEFAULT_LIBRARY_CHART_ORDER, ...DEFAULT_USER_CHART_ORDER];