mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
bc10ebcc33
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.
88 lines
2.9 KiB
Vue
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>
|