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.
This commit is contained in:
neon
2026-09-23 01:34:19 +00:00
parent 4a4b62bbef
commit bc10ebcc33
21 changed files with 22445 additions and 32 deletions
+2
View File
@@ -144,6 +144,7 @@
--pill-web: oklch(0.55 0.16 245);
--pill-ios: oklch(0.58 0.16 150);
--pill-watchos: oklch(0.58 0.18 35);
--pill-android: oklch(0.58 0.11 95);
--pill-koreader: oklch(0.52 0.2 295);
--pill-kobo: oklch(0.55 0.1 195);
--pill-repaired: oklch(0.62 0.14 70);
@@ -381,6 +382,7 @@
--pill-web: oklch(0.72 0.14 245);
--pill-ios: oklch(0.75 0.14 150);
--pill-watchos: oklch(0.76 0.15 35);
--pill-android: oklch(0.75 0.14 95);
--pill-koreader: oklch(0.72 0.17 295);
--pill-kobo: oklch(0.74 0.1 195);
--pill-repaired: oklch(0.78 0.13 70);
@@ -75,7 +75,7 @@ function genreLabel(genre: BroadReadingGenre): string {
}
function sourceLabel(source: string): string {
if (['web', 'ios', 'watchos', 'koreader', 'manual', 'kobo', 'unknown'].includes(source)) {
if (['web', 'ios', 'watchos', 'android', 'koreader', 'manual', 'kobo', 'unknown'].includes(source)) {
return t(`adminFeature.sharedInsights.sourceNames.${source}`)
}
return source
@@ -40,6 +40,7 @@ const BUCKET_TOKEN: Record<ReadingSessionSourceBucket, string> = {
bookorbit: '--pill-web',
ios: '--pill-ios',
watchos: '--pill-watchos',
android: '--pill-android',
koreader: '--pill-koreader',
kobo: '--pill-kobo',
}
@@ -20,6 +20,7 @@ const BUCKET_TOKEN: Record<ReadingSessionSourceBucket, string> = {
bookorbit: '--pill-web',
ios: '--pill-ios',
watchos: '--pill-watchos',
android: '--pill-android',
koreader: '--pill-koreader',
kobo: '--pill-kobo',
}
@@ -26,6 +26,7 @@ const BUCKET_TOKEN: Record<ReadingSessionSourceBucket, string> = {
bookorbit: '--pill-web',
ios: '--pill-ios',
watchos: '--pill-watchos',
android: '--pill-android',
koreader: '--pill-koreader',
kobo: '--pill-kobo',
}
@@ -80,7 +80,7 @@ watchEffect(() => {
:error
:empty="isEmpty"
:empty-title="t('statistics.charts.sourceDistribution.emptyTitle')"
:empty-description="t('statistics.charts.sourceDistribution.emptyDescriptionWithAppleClients')"
:empty-description="t('statistics.charts.sourceDistribution.emptyDescriptionWithNativeClients')"
>
<VChart :option autoresize style="height: 100%" />
</ChartCard>
@@ -15,8 +15,8 @@ describe('breakdown', () => {
it('returns all source buckets for the source dimension', () => {
const series = getBreakdownSeries('source', 'theme:violet', [])
expect(series.map((s) => s.key)).toEqual(['bookorbit', 'ios', 'watchos', 'koreader', 'kobo'])
expect(series.map((s) => s.label)).toEqual(['BookOrbit', 'iOS app', 'Apple Watch', 'KOReader', 'Kobo'])
expect(series.map((s) => s.key)).toEqual(['bookorbit', 'ios', 'watchos', 'android', 'koreader', 'kobo'])
expect(series.map((s) => s.label)).toEqual(['BookOrbit', 'iOS app', 'Apple Watch', 'Android app', 'KOReader', 'Kobo'])
})
it('returns one series per format key for the format dimension', () => {
@@ -8,6 +8,7 @@ const mocks = vi.hoisted(() => ({
'--pill-web': 'oklch(0.72 0.14 245)',
'--pill-ios': 'oklch(0.75 0.14 150)',
'--pill-watchos': 'oklch(0.76 0.15 35)',
'--pill-android': 'oklch(0.75 0.14 95)',
'--pill-koreader': 'oklch(0.72 0.17 295)',
'--pill-kobo': 'oklch(0.74 0.1 195)',
}) as Record<string, string>
@@ -30,6 +31,7 @@ describe('source-bucket-colors', () => {
bookorbit: '--pill-web',
ios: '--pill-ios',
watchos: '--pill-watchos',
android: '--pill-android',
koreader: '--pill-koreader',
kobo: '--pill-kobo',
})
@@ -38,13 +40,14 @@ describe('source-bucket-colors', () => {
it('converts oklch tokens to a zrender-parseable hex so bars do not vanish on hover', () => {
const colors = resolveSourceBucketColors('dark:violet')
expect(colors).toEqual({ bookorbit: '#abcdef', ios: '#abcdef', watchos: '#abcdef', koreader: '#abcdef', kobo: '#abcdef' })
expect(colors).toEqual({ bookorbit: '#abcdef', ios: '#abcdef', watchos: '#abcdef', android: '#abcdef', koreader: '#abcdef', kobo: '#abcdef' })
expect(mocks.readCssColor).toHaveBeenCalledWith('--pill-web')
expect(mocks.readCssColor).toHaveBeenCalledWith('--pill-ios')
expect(mocks.readCssColor).toHaveBeenCalledWith('--pill-watchos')
expect(mocks.readCssColor).toHaveBeenCalledWith('--pill-android')
expect(mocks.readCssColor).toHaveBeenCalledWith('--pill-koreader')
expect(mocks.readCssColor).toHaveBeenCalledWith('--pill-kobo')
expect(mocks.oklchToHex).toHaveBeenCalledTimes(5)
expect(mocks.oklchToHex).toHaveBeenCalledTimes(6)
})
it('passes through values that are already rgb/hex', () => {
@@ -7,6 +7,7 @@ export const SOURCE_BUCKET_COLOR_TOKENS: Record<ReadingSessionSourceBucket, stri
bookorbit: '--pill-web',
ios: '--pill-ios',
watchos: '--pill-watchos',
android: '--pill-android',
koreader: '--pill-koreader',
kobo: '--pill-kobo',
}
@@ -43,6 +44,7 @@ export function resolveSourceBucketColors(themeKey: string): Record<ReadingSessi
bookorbit: toParseableColor(readCssColor(SOURCE_BUCKET_COLOR_TOKENS.bookorbit)),
ios: toParseableColor(readCssColor(SOURCE_BUCKET_COLOR_TOKENS.ios)),
watchos: toParseableColor(readCssColor(SOURCE_BUCKET_COLOR_TOKENS.watchos)),
android: toParseableColor(readCssColor(SOURCE_BUCKET_COLOR_TOKENS.android)),
koreader: toParseableColor(readCssColor(SOURCE_BUCKET_COLOR_TOKENS.koreader)),
kobo: toParseableColor(readCssColor(SOURCE_BUCKET_COLOR_TOKENS.kobo)),
}
+3 -1
View File
@@ -3642,6 +3642,7 @@
"web": "Web reader",
"ios": "iOS app",
"watchos": "Apple Watch",
"android": "Android app",
"koreader": "KOReader",
"manual": "Manual",
"kobo": "Kobo",
@@ -9826,7 +9827,8 @@
"title": "Where You Read",
"emptyTitle": "No reading activity yet",
"emptyDescription": "Read on the web, KOReader, or Kobo to see your source split.",
"emptyDescriptionWithAppleClients": "Read on the web, iOS app, Apple Watch, KOReader, or Kobo to see your source split."
"emptyDescriptionWithAppleClients": "Read on the web, iOS app, Apple Watch, KOReader, or Kobo to see your source split.",
"emptyDescriptionWithNativeClients": "Read on the web, iOS app, Android app, Apple Watch, KOReader, or Kobo to see your source split."
}
}
},