Files
bookorbit/client/src/features/statistics/components/user/SourceDistributionChart.vue
T
neon bc10ebcc33 feat(stats): record android as a reading source
Accept android as a reading-session source from clients and report it as
its own statistics bucket, labelled Android app, beside the iOS app and
Apple Watch. A generated migration adds it to the reading_sessions check
constraint, achievements count Android reading toward Full Orbit and Two
Worlds, and the web charts draw it in a new --pill-android token at hue 95.
2026-09-23 01:34:19 +00:00

88 lines
2.9 KiB
Vue

<script setup lang="ts">
import { computed, shallowRef, watchEffect } from 'vue'
import { useI18n } from 'vue-i18n'
import VChart from 'vue-echarts'
import { MonitorSmartphone } from '@lucide/vue'
import { READING_SESSION_SOURCE_BUCKET_LABELS } from '@bookorbit/types'
import { breakpointsTailwind, useBreakpoints } from '@vueuse/core'
import { useThemeStore } from '@/stores/theme'
import { resolveSourceBucketColors } from '../../lib/source-bucket-colors'
import { useUserReadingSourceDistribution } from '../../composables/useUserReadingSourceDistribution'
import ChartCard from '../ChartCard.vue'
const { t } = useI18n()
const themeStore = useThemeStore()
const { data, loading, error } = useUserReadingSourceDistribution()
const { md } = useBreakpoints(breakpointsTailwind)
const isEmpty = computed(() => data.value.slices.length === 0)
function formatDuration(seconds: number): string {
const total = Math.max(0, Math.floor(seconds))
const hours = Math.floor(total / 3600)
const mins = Math.floor((total % 3600) / 60)
if (hours > 0) return `${hours}h ${mins}m`
if (mins > 0) return `${mins}m`
return `${total}s`
}
const option = shallowRef({})
watchEffect(() => {
option.value = {}
if (isEmpty.value) return
const colors = resolveSourceBucketColors(`${themeStore.resolvedTheme}:${themeStore.accent}`)
option.value = {
tooltip: {
trigger: 'item',
appendToBody: true,
formatter: (p: { name: string; value: number; percent: number; color: string }) =>
`<div style="font-size:12px;line-height:1.5">` +
`<span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:${p.color};margin-right:6px"></span>${p.name}` +
`<div><span style="font-weight:600">${formatDuration(p.value)}</span> · ${p.percent}%</div></div>`,
},
legend: {
orient: md.value ? 'vertical' : 'horizontal',
right: md.value ? '2%' : 'auto',
bottom: md.value ? 'auto' : 0,
top: md.value ? 'middle' : 'auto',
itemWidth: 10,
itemHeight: 10,
textStyle: { fontSize: 11 },
},
series: [
{
type: 'pie',
radius: ['45%', '72%'],
center: md.value ? ['38%', '50%'] : ['50%', '44%'],
data: data.value.slices.map((slice) => ({
name: READING_SESSION_SOURCE_BUCKET_LABELS[slice.bucket],
value: slice.readingSeconds,
itemStyle: { color: colors[slice.bucket] },
})),
label: { show: false },
},
],
}
})
</script>
<template>
<ChartCard
:title="t('statistics.charts.sourceDistribution.title')"
:icon="MonitorSmartphone"
:color-index="6"
:loading
:error
:empty="isEmpty"
:empty-title="t('statistics.charts.sourceDistribution.emptyTitle')"
:empty-description="t('statistics.charts.sourceDistribution.emptyDescriptionWithNativeClients')"
>
<VChart :option autoresize style="height: 100%" />
</ChartCard>
</template>