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];