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-web: oklch(0.55 0.16 245);
--pill-ios: oklch(0.58 0.16 150); --pill-ios: oklch(0.58 0.16 150);
--pill-watchos: oklch(0.58 0.18 35); --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-koreader: oklch(0.52 0.2 295);
--pill-kobo: oklch(0.55 0.1 195); --pill-kobo: oklch(0.55 0.1 195);
--pill-repaired: oklch(0.62 0.14 70); --pill-repaired: oklch(0.62 0.14 70);
@@ -381,6 +382,7 @@
--pill-web: oklch(0.72 0.14 245); --pill-web: oklch(0.72 0.14 245);
--pill-ios: oklch(0.75 0.14 150); --pill-ios: oklch(0.75 0.14 150);
--pill-watchos: oklch(0.76 0.15 35); --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-koreader: oklch(0.72 0.17 295);
--pill-kobo: oklch(0.74 0.1 195); --pill-kobo: oklch(0.74 0.1 195);
--pill-repaired: oklch(0.78 0.13 70); --pill-repaired: oklch(0.78 0.13 70);
@@ -75,7 +75,7 @@ function genreLabel(genre: BroadReadingGenre): string {
} }
function sourceLabel(source: string): 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 t(`adminFeature.sharedInsights.sourceNames.${source}`)
} }
return source return source
@@ -40,6 +40,7 @@ const BUCKET_TOKEN: Record<ReadingSessionSourceBucket, string> = {
bookorbit: '--pill-web', bookorbit: '--pill-web',
ios: '--pill-ios', ios: '--pill-ios',
watchos: '--pill-watchos', watchos: '--pill-watchos',
android: '--pill-android',
koreader: '--pill-koreader', koreader: '--pill-koreader',
kobo: '--pill-kobo', kobo: '--pill-kobo',
} }
@@ -20,6 +20,7 @@ const BUCKET_TOKEN: Record<ReadingSessionSourceBucket, string> = {
bookorbit: '--pill-web', bookorbit: '--pill-web',
ios: '--pill-ios', ios: '--pill-ios',
watchos: '--pill-watchos', watchos: '--pill-watchos',
android: '--pill-android',
koreader: '--pill-koreader', koreader: '--pill-koreader',
kobo: '--pill-kobo', kobo: '--pill-kobo',
} }
@@ -26,6 +26,7 @@ const BUCKET_TOKEN: Record<ReadingSessionSourceBucket, string> = {
bookorbit: '--pill-web', bookorbit: '--pill-web',
ios: '--pill-ios', ios: '--pill-ios',
watchos: '--pill-watchos', watchos: '--pill-watchos',
android: '--pill-android',
koreader: '--pill-koreader', koreader: '--pill-koreader',
kobo: '--pill-kobo', kobo: '--pill-kobo',
} }
@@ -80,7 +80,7 @@ watchEffect(() => {
:error :error
:empty="isEmpty" :empty="isEmpty"
:empty-title="t('statistics.charts.sourceDistribution.emptyTitle')" :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%" /> <VChart :option autoresize style="height: 100%" />
</ChartCard> </ChartCard>
@@ -15,8 +15,8 @@ describe('breakdown', () => {
it('returns all source buckets for the source dimension', () => { it('returns all source buckets for the source dimension', () => {
const series = getBreakdownSeries('source', 'theme:violet', []) const series = getBreakdownSeries('source', 'theme:violet', [])
expect(series.map((s) => s.key)).toEqual(['bookorbit', 'ios', 'watchos', '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', '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', () => { 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-web': 'oklch(0.72 0.14 245)',
'--pill-ios': 'oklch(0.75 0.14 150)', '--pill-ios': 'oklch(0.75 0.14 150)',
'--pill-watchos': 'oklch(0.76 0.15 35)', '--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-koreader': 'oklch(0.72 0.17 295)',
'--pill-kobo': 'oklch(0.74 0.1 195)', '--pill-kobo': 'oklch(0.74 0.1 195)',
}) as Record<string, string> }) as Record<string, string>
@@ -30,6 +31,7 @@ describe('source-bucket-colors', () => {
bookorbit: '--pill-web', bookorbit: '--pill-web',
ios: '--pill-ios', ios: '--pill-ios',
watchos: '--pill-watchos', watchos: '--pill-watchos',
android: '--pill-android',
koreader: '--pill-koreader', koreader: '--pill-koreader',
kobo: '--pill-kobo', 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', () => { it('converts oklch tokens to a zrender-parseable hex so bars do not vanish on hover', () => {
const colors = resolveSourceBucketColors('dark:violet') 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-web')
expect(mocks.readCssColor).toHaveBeenCalledWith('--pill-ios') expect(mocks.readCssColor).toHaveBeenCalledWith('--pill-ios')
expect(mocks.readCssColor).toHaveBeenCalledWith('--pill-watchos') expect(mocks.readCssColor).toHaveBeenCalledWith('--pill-watchos')
expect(mocks.readCssColor).toHaveBeenCalledWith('--pill-android')
expect(mocks.readCssColor).toHaveBeenCalledWith('--pill-koreader') expect(mocks.readCssColor).toHaveBeenCalledWith('--pill-koreader')
expect(mocks.readCssColor).toHaveBeenCalledWith('--pill-kobo') 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', () => { 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', bookorbit: '--pill-web',
ios: '--pill-ios', ios: '--pill-ios',
watchos: '--pill-watchos', watchos: '--pill-watchos',
android: '--pill-android',
koreader: '--pill-koreader', koreader: '--pill-koreader',
kobo: '--pill-kobo', kobo: '--pill-kobo',
} }
@@ -43,6 +44,7 @@ export function resolveSourceBucketColors(themeKey: string): Record<ReadingSessi
bookorbit: toParseableColor(readCssColor(SOURCE_BUCKET_COLOR_TOKENS.bookorbit)), bookorbit: toParseableColor(readCssColor(SOURCE_BUCKET_COLOR_TOKENS.bookorbit)),
ios: toParseableColor(readCssColor(SOURCE_BUCKET_COLOR_TOKENS.ios)), ios: toParseableColor(readCssColor(SOURCE_BUCKET_COLOR_TOKENS.ios)),
watchos: toParseableColor(readCssColor(SOURCE_BUCKET_COLOR_TOKENS.watchos)), watchos: toParseableColor(readCssColor(SOURCE_BUCKET_COLOR_TOKENS.watchos)),
android: toParseableColor(readCssColor(SOURCE_BUCKET_COLOR_TOKENS.android)),
koreader: toParseableColor(readCssColor(SOURCE_BUCKET_COLOR_TOKENS.koreader)), koreader: toParseableColor(readCssColor(SOURCE_BUCKET_COLOR_TOKENS.koreader)),
kobo: toParseableColor(readCssColor(SOURCE_BUCKET_COLOR_TOKENS.kobo)), kobo: toParseableColor(readCssColor(SOURCE_BUCKET_COLOR_TOKENS.kobo)),
} }
+3 -1
View File
@@ -3642,6 +3642,7 @@
"web": "Web reader", "web": "Web reader",
"ios": "iOS app", "ios": "iOS app",
"watchos": "Apple Watch", "watchos": "Apple Watch",
"android": "Android app",
"koreader": "KOReader", "koreader": "KOReader",
"manual": "Manual", "manual": "Manual",
"kobo": "Kobo", "kobo": "Kobo",
@@ -9826,7 +9827,8 @@
"title": "Where You Read", "title": "Where You Read",
"emptyTitle": "No reading activity yet", "emptyTitle": "No reading activity yet",
"emptyDescription": "Read on the web, KOReader, or Kobo to see your source split.", "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."
} }
} }
}, },
@@ -16,9 +16,10 @@ describe("toReadingSessionSourceBucket", () => {
expect(toReadingSessionSourceBucket("manual")).toBe("bookorbit"); expect(toReadingSessionSourceBucket("manual")).toBe("bookorbit");
}); });
it("keeps iOS and Apple Watch in distinct buckets", () => { it("keeps iOS, Apple Watch and Android in distinct buckets", () => {
expect(toReadingSessionSourceBucket("ios")).toBe("ios"); expect(toReadingSessionSourceBucket("ios")).toBe("ios");
expect(toReadingSessionSourceBucket("watchos")).toBe("watchos"); expect(toReadingSessionSourceBucket("watchos")).toBe("watchos");
expect(toReadingSessionSourceBucket("android")).toBe("android");
}); });
it("maps koreader to koreader", () => { it("maps koreader to koreader", () => {
@@ -36,8 +37,8 @@ describe("toReadingSessionSourceBucket", () => {
}); });
describe("reading session source bucket constants", () => { describe("reading session source bucket constants", () => {
it("exposes all five display buckets", () => { it("exposes all six display buckets", () => {
expect(READING_SESSION_SOURCE_BUCKETS).toEqual(["bookorbit", "ios", "watchos", "koreader", "kobo"]); expect(READING_SESSION_SOURCE_BUCKETS).toEqual(["bookorbit", "ios", "watchos", "android", "koreader", "kobo"]);
}); });
it("labels every bucket", () => { it("labels every bucket", () => {
@@ -45,12 +46,13 @@ describe("reading session source bucket constants", () => {
bookorbit: "BookOrbit", bookorbit: "BookOrbit",
ios: "iOS app", ios: "iOS app",
watchos: "Apple Watch", watchos: "Apple Watch",
android: "Android app",
koreader: "KOReader", koreader: "KOReader",
kobo: "Kobo", kobo: "Kobo",
}); });
}); });
it("builds a zero-filled record", () => { it("builds a zero-filled record", () => {
expect(emptySourceBucketRecord()).toEqual({ bookorbit: 0, ios: 0, watchos: 0, koreader: 0, kobo: 0 }); expect(emptySourceBucketRecord()).toEqual({ bookorbit: 0, ios: 0, watchos: 0, android: 0, koreader: 0, kobo: 0 });
}); });
}); });
@@ -1,26 +1,28 @@
import type { ReadingSessionSource } from "./reading-session"; import type { ReadingSessionSource } from "./reading-session";
export const READING_SESSION_SOURCE_BUCKETS = ["bookorbit", "ios", "watchos", "koreader", "kobo"] as const; export const READING_SESSION_SOURCE_BUCKETS = ["bookorbit", "ios", "watchos", "android", "koreader", "kobo"] as const;
export type ReadingSessionSourceBucket = (typeof READING_SESSION_SOURCE_BUCKETS)[number]; export type ReadingSessionSourceBucket = (typeof READING_SESSION_SOURCE_BUCKETS)[number];
export const READING_SESSION_SOURCE_BUCKET_LABELS: Record<ReadingSessionSourceBucket, string> = { export const READING_SESSION_SOURCE_BUCKET_LABELS: Record<ReadingSessionSourceBucket, string> = {
bookorbit: "BookOrbit", bookorbit: "BookOrbit",
ios: "iOS app", ios: "iOS app",
watchos: "Apple Watch", watchos: "Apple Watch",
android: "Android app",
koreader: "KOReader", koreader: "KOReader",
kobo: "Kobo", kobo: "Kobo",
}; };
// Web/manual and null/unknown history collapse into the general BookOrbit bucket. // Web/manual and null/unknown history collapse into the general BookOrbit bucket.
// Native Apple clients remain distinct so device attribution survives aggregation. // Native clients remain distinct so device attribution survives aggregation.
export function toReadingSessionSourceBucket(source: ReadingSessionSource | null | undefined): ReadingSessionSourceBucket { export function toReadingSessionSourceBucket(source: ReadingSessionSource | null | undefined): ReadingSessionSourceBucket {
if (source === "ios") return "ios"; if (source === "ios") return "ios";
if (source === "watchos") return "watchos"; if (source === "watchos") return "watchos";
if (source === "android") return "android";
if (source === "koreader") return "koreader"; if (source === "koreader") return "koreader";
if (source === "kobo") return "kobo"; if (source === "kobo") return "kobo";
return "bookorbit"; return "bookorbit";
} }
export function emptySourceBucketRecord(): Record<ReadingSessionSourceBucket, number> { export function emptySourceBucketRecord(): Record<ReadingSessionSourceBucket, number> {
return { bookorbit: 0, ios: 0, watchos: 0, koreader: 0, kobo: 0 }; return { bookorbit: 0, ios: 0, watchos: 0, android: 0, koreader: 0, kobo: 0 };
} }
+2 -2
View File
@@ -1,9 +1,9 @@
import type { ReadingSessionSourceBucket } from "./reading-session-source-bucket"; import type { ReadingSessionSourceBucket } from "./reading-session-source-bucket";
export const READING_SESSION_SOURCES = ["web", "ios", "watchos", "koreader", "manual", "kobo"] as const; export const READING_SESSION_SOURCES = ["web", "ios", "watchos", "android", "koreader", "manual", "kobo"] as const;
export type ReadingSessionSource = (typeof READING_SESSION_SOURCES)[number]; export type ReadingSessionSource = (typeof READING_SESSION_SOURCES)[number];
export const CLIENT_READING_SESSION_SOURCES = ["ios", "watchos"] as const; export const CLIENT_READING_SESSION_SOURCES = ["ios", "watchos", "android"] as const;
export type ClientReadingSessionSource = (typeof CLIENT_READING_SESSION_SOURCES)[number]; export type ClientReadingSessionSource = (typeof CLIENT_READING_SESSION_SOURCES)[number];
export interface BookReadingSession { export interface BookReadingSession {
@@ -0,0 +1,2 @@
ALTER TABLE "reading_sessions" DROP CONSTRAINT "reading_sessions_source_chk";--> statement-breakpoint
ALTER TABLE "reading_sessions" ADD CONSTRAINT "reading_sessions_source_chk" CHECK ("reading_sessions"."source" in ('web', 'ios', 'watchos', 'android', 'koreader', 'manual', 'kobo'));
File diff suppressed because it is too large Load Diff
@@ -673,6 +673,13 @@
"when": 1789949424697, "when": 1789949424697,
"tag": "0095_add_sabnzbd_and_prowlarr_support", "tag": "0095_add_sabnzbd_and_prowlarr_support",
"breakpoints": true "breakpoints": true
},
{
"idx": 96,
"version": "7",
"when": 1790119788880,
"tag": "0096_add_android_reading_session_source",
"breakpoints": true
} }
] ]
} }
+2 -2
View File
@@ -248,7 +248,7 @@ export const readingSessions = pgTable(
attemptId: integer('attempt_id').references(() => readingAttempts.id, { onDelete: 'set null' }), attemptId: integer('attempt_id').references(() => readingAttempts.id, { onDelete: 'set null' }),
// Client-generated UUID; used for idempotent retries. // Client-generated UUID; used for idempotent retries.
sessionId: varchar('session_id', { length: 64 }).notNull(), sessionId: varchar('session_id', { length: 64 }).notNull(),
// Browser, native Apple clients, external readers, or a user-entered session. // Browser, native iOS, watchOS and Android clients, external readers, or a user-entered session.
source: varchar('source', { length: 10 }).$type<ReadingSessionSource>(), source: varchar('source', { length: 10 }).$type<ReadingSessionSource>(),
sourceDeviceKey: varchar('source_device_key', { length: 128 }), sourceDeviceKey: varchar('source_device_key', { length: 128 }),
// 'read' (default, manual reading), 'tts' (text-to-speech), or 'listen' (audiobook playback) // 'read' (default, manual reading), 'tts' (text-to-speech), or 'listen' (audiobook playback)
@@ -270,7 +270,7 @@ export const readingSessions = pgTable(
index('rs_user_book_source_device_started_idx').on(t.userId, t.bookId, t.source, t.sourceDeviceKey, t.startedAt), index('rs_user_book_source_device_started_idx').on(t.userId, t.bookId, t.source, t.sourceDeviceKey, t.startedAt),
index('reading_sessions_book_id_idx').on(t.bookId), index('reading_sessions_book_id_idx').on(t.bookId),
index('rs_attempt_started_at_idx').on(t.attemptId, t.startedAt), index('rs_attempt_started_at_idx').on(t.attemptId, t.startedAt),
check('reading_sessions_source_chk', sql`${t.source} in ('web', 'ios', 'watchos', 'koreader', 'manual', 'kobo')`), check('reading_sessions_source_chk', sql`${t.source} in ('web', 'ios', 'watchos', 'android', 'koreader', 'manual', 'kobo')`),
check('reading_sessions_duration_seconds_nonnegative_chk', sql`${t.durationSeconds} >= 0`), check('reading_sessions_duration_seconds_nonnegative_chk', sql`${t.durationSeconds} >= 0`),
check('reading_sessions_end_progress_range_chk', sql`${t.endProgress} is null or (${t.endProgress} >= 0 and ${t.endProgress} <= 100)`), check('reading_sessions_end_progress_range_chk', sql`${t.endProgress} is null or (${t.endProgress} >= 0 and ${t.endProgress} <= 100)`),
check('reading_sessions_ended_after_started_chk', sql`${t.endedAt} >= ${t.startedAt}`), check('reading_sessions_ended_after_started_chk', sql`${t.endedAt} >= ${t.startedAt}`),
@@ -1211,7 +1211,7 @@ export class AchievementRepository {
SELECT COUNT(DISTINCT src)::int AS source_count FROM ( SELECT COUNT(DISTINCT src)::int AS source_count FROM (
SELECT source AS src SELECT source AS src
FROM reading_sessions FROM reading_sessions
WHERE user_id = ${userId} AND source IN ('web', 'ios', 'watchos') WHERE user_id = ${userId} AND source IN ('web', 'ios', 'watchos', 'android')
UNION UNION
SELECT 'koreader' AS src SELECT 'koreader' AS src
FROM koreader_device_progress FROM koreader_device_progress
@@ -1232,7 +1232,7 @@ export class AchievementRepository {
SELECT book_id, COUNT(DISTINCT src) AS src_count FROM ( SELECT book_id, COUNT(DISTINCT src) AS src_count FROM (
SELECT rs.book_id, rs.source AS src SELECT rs.book_id, rs.source AS src
FROM reading_sessions rs FROM reading_sessions rs
WHERE rs.user_id = ${userId} AND rs.source IN ('web', 'ios', 'watchos') WHERE rs.user_id = ${userId} AND rs.source IN ('web', 'ios', 'watchos', 'android')
UNION UNION
SELECT bf.book_id, 'koreader' AS src SELECT bf.book_id, 'koreader' AS src
FROM koreader_device_progress kdp JOIN book_files bf ON bf.id = kdp.book_file_id FROM koreader_device_progress kdp JOIN book_files bf ON bf.id = kdp.book_file_id
@@ -28,6 +28,13 @@ describe('SaveReadingSessionDto', () => {
durationSeconds: 120, durationSeconds: 120,
source: 'ios', source: 'ios',
}); });
const android = plainToInstance(SaveReadingSessionDto, {
sessionId: 'android-session',
startedAt: '2026-04-15T10:00:00.000Z',
endedAt: '2026-04-15T10:02:00.000Z',
durationSeconds: 120,
source: 'android',
});
const integration = plainToInstance(SaveReadingSessionDto, { const integration = plainToInstance(SaveReadingSessionDto, {
sessionId: 'spoofed-integration', sessionId: 'spoofed-integration',
startedAt: '2026-04-15T10:00:00.000Z', startedAt: '2026-04-15T10:00:00.000Z',
@@ -37,6 +44,7 @@ describe('SaveReadingSessionDto', () => {
}); });
expect(await validate(ios)).toEqual([]); expect(await validate(ios)).toEqual([]);
expect(await validate(android)).toEqual([]);
expect(await validate(integration)).not.toEqual([]); expect(await validate(integration)).not.toEqual([]);
}); });
@@ -1,5 +1,5 @@
import { IsDateString, IsIn, IsInt, IsNumber, IsOptional, IsString, Max, MaxLength, Min, MinLength } from 'class-validator'; import { IsDateString, IsIn, IsInt, IsNumber, IsOptional, IsString, Max, MaxLength, Min, MinLength } from 'class-validator';
import type { ClientReadingSessionSource } from '@bookorbit/types'; import { CLIENT_READING_SESSION_SOURCES, type ClientReadingSessionSource } from '@bookorbit/types';
export class SaveReadingSessionDto { export class SaveReadingSessionDto {
@IsString() @IsString()
@@ -39,6 +39,6 @@ export class SaveReadingSessionDto {
// first-party platform; integration and manual sources remain server-owned. // first-party platform; integration and manual sources remain server-owned.
@IsOptional() @IsOptional()
@IsString() @IsString()
@IsIn(['ios', 'watchos']) @IsIn(CLIENT_READING_SESSION_SOURCES)
source?: ClientReadingSessionSource; source?: ClientReadingSessionSource;
} }
@@ -126,7 +126,7 @@ describe('UserStatisticsService', () => {
readingSeconds: 1800, readingSeconds: 1800,
listeningSeconds: 0, listeningSeconds: 0,
sessionsCount: 1, sessionsCount: 1,
bySource: { bookorbit: 0, ios: 0, watchos: 0, koreader: 1800, kobo: 0 }, bySource: { bookorbit: 0, ios: 0, watchos: 0, android: 0, koreader: 1800, kobo: 0 },
}); });
expect(result.sessions[0]).toEqual(expect.objectContaining({ durationOnDaySeconds: 1800, hasCover: true, sourceBucket: 'koreader' })); expect(result.sessions[0]).toEqual(expect.objectContaining({ durationOnDaySeconds: 1800, hasCover: true, sourceBucket: 'koreader' }));
}); });
@@ -321,21 +321,21 @@ describe('UserStatisticsService', () => {
readingSeconds: 120, readingSeconds: 120,
progressDelta: 1.2346, progressDelta: 1.2346,
eventsCount: 2, eventsCount: 2,
bySource: { bookorbit: 60, ios: 10, watchos: 20, koreader: 0, kobo: 30 }, bySource: { bookorbit: 60, ios: 10, watchos: 20, android: 0, koreader: 0, kobo: 30 },
}, },
{ {
day: '2026-04-07', day: '2026-04-07',
readingSeconds: 0, readingSeconds: 0,
progressDelta: 0, progressDelta: 0,
eventsCount: 0, eventsCount: 0,
bySource: { bookorbit: 0, ios: 0, watchos: 0, koreader: 0, kobo: 0 }, bySource: { bookorbit: 0, ios: 0, watchos: 0, android: 0, koreader: 0, kobo: 0 },
}, },
{ {
day: '2026-04-08', day: '2026-04-08',
readingSeconds: 0, readingSeconds: 0,
progressDelta: 0, progressDelta: 0,
eventsCount: 0, eventsCount: 0,
bySource: { bookorbit: 0, ios: 0, watchos: 0, koreader: 0, kobo: 0 }, bySource: { bookorbit: 0, ios: 0, watchos: 0, android: 0, koreader: 0, kobo: 0 },
}, },
]); ]);
}); });
@@ -420,7 +420,7 @@ describe('UserStatisticsService', () => {
hour: 8, hour: 8,
readingSeconds: 1000, readingSeconds: 1000,
eventsCount: 5, eventsCount: 5,
bySource: { bookorbit: 400, ios: 100, watchos: 300, koreader: 0, kobo: 200 }, bySource: { bookorbit: 400, ios: 100, watchos: 300, android: 0, koreader: 0, kobo: 200 },
}), }),
); );
expect(result[21]).toEqual( expect(result[21]).toEqual(
@@ -428,11 +428,16 @@ describe('UserStatisticsService', () => {
hour: 21, hour: 21,
readingSeconds: 900, readingSeconds: 900,
eventsCount: 4, eventsCount: 4,
bySource: { bookorbit: 0, ios: 0, watchos: 0, koreader: 900, kobo: 0 }, bySource: { bookorbit: 0, ios: 0, watchos: 0, android: 0, koreader: 900, kobo: 0 },
}), }),
); );
expect(result[0]).toEqual( expect(result[0]).toEqual(
expect.objectContaining({ hour: 0, readingSeconds: 0, eventsCount: 0, bySource: { bookorbit: 0, ios: 0, watchos: 0, koreader: 0, kobo: 0 } }), expect.objectContaining({
hour: 0,
readingSeconds: 0,
eventsCount: 0,
bySource: { bookorbit: 0, ios: 0, watchos: 0, android: 0, koreader: 0, kobo: 0 },
}),
); );
}); });
@@ -468,14 +473,14 @@ describe('UserStatisticsService', () => {
readingSeconds: 1800, readingSeconds: 1800,
eventsCount: 6, eventsCount: 6,
byFormat: { EPUB: 1200, PDF: 600 }, byFormat: { EPUB: 1200, PDF: 600 },
bySource: { bookorbit: 600, ios: 0, watchos: 0, koreader: 1200, kobo: 0 }, bySource: { bookorbit: 600, ios: 0, watchos: 0, android: 0, koreader: 1200, kobo: 0 },
}); });
expect(result[0]).toEqual({ expect(result[0]).toEqual({
dayOfWeek: 0, dayOfWeek: 0,
readingSeconds: 0, readingSeconds: 0,
eventsCount: 0, eventsCount: 0,
byFormat: {}, byFormat: {},
bySource: { bookorbit: 0, ios: 0, watchos: 0, koreader: 0, kobo: 0 }, bySource: { bookorbit: 0, ios: 0, watchos: 0, android: 0, koreader: 0, kobo: 0 },
}); });
}); });
@@ -943,7 +948,7 @@ describe('UserStatisticsService', () => {
await expect(service.getSessionArchetypes(user, { libraryIds: [1] })).resolves.toEqual([{ hour: 9, durationMinutes: 20, dayOfWeek: 2 }]); await expect(service.getSessionArchetypes(user, { libraryIds: [1] })).resolves.toEqual([{ hour: 9, durationMinutes: 20, dayOfWeek: 2 }]);
await expect(service.getGenreReadingTime(user, { libraryIds: [1] })).resolves.toEqual([ await expect(service.getGenreReadingTime(user, { libraryIds: [1] })).resolves.toEqual([
{ genre: 'Sci-Fi', readingSeconds: 300, bySource: { bookorbit: 200, ios: 0, watchos: 0, koreader: 0, kobo: 100 } }, { genre: 'Sci-Fi', readingSeconds: 300, bySource: { bookorbit: 200, ios: 0, watchos: 0, android: 0, koreader: 0, kobo: 100 } },
]); ]);
await expect(service.getReadingPace(user, { libraryIds: [1] })).resolves.toEqual([ await expect(service.getReadingPace(user, { libraryIds: [1] })).resolves.toEqual([
{ durationSeconds: 240, progressDelta: 1.4, bucket: 'bookorbit', format: 'EPUB' }, { durationSeconds: 240, progressDelta: 1.4, bucket: 'bookorbit', format: 'EPUB' },