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
@@ -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,
"tag": "0095_add_sabnzbd_and_prowlarr_support",
"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' }),
// Client-generated UUID; used for idempotent retries.
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>(),
sourceDeviceKey: varchar('source_device_key', { length: 128 }),
// '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('reading_sessions_book_id_idx').on(t.bookId),
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_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}`),
@@ -1211,7 +1211,7 @@ export class AchievementRepository {
SELECT COUNT(DISTINCT src)::int AS source_count FROM (
SELECT source AS src
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
SELECT 'koreader' AS src
FROM koreader_device_progress
@@ -1232,7 +1232,7 @@ export class AchievementRepository {
SELECT book_id, COUNT(DISTINCT src) AS src_count FROM (
SELECT rs.book_id, rs.source AS src
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
SELECT bf.book_id, 'koreader' AS src
FROM koreader_device_progress kdp JOIN book_files bf ON bf.id = kdp.book_file_id
@@ -28,6 +28,13 @@ describe('SaveReadingSessionDto', () => {
durationSeconds: 120,
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, {
sessionId: 'spoofed-integration',
startedAt: '2026-04-15T10:00:00.000Z',
@@ -37,6 +44,7 @@ describe('SaveReadingSessionDto', () => {
});
expect(await validate(ios)).toEqual([]);
expect(await validate(android)).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 type { ClientReadingSessionSource } from '@bookorbit/types';
import { CLIENT_READING_SESSION_SOURCES, type ClientReadingSessionSource } from '@bookorbit/types';
export class SaveReadingSessionDto {
@IsString()
@@ -39,6 +39,6 @@ export class SaveReadingSessionDto {
// first-party platform; integration and manual sources remain server-owned.
@IsOptional()
@IsString()
@IsIn(['ios', 'watchos'])
@IsIn(CLIENT_READING_SESSION_SOURCES)
source?: ClientReadingSessionSource;
}
@@ -126,7 +126,7 @@ describe('UserStatisticsService', () => {
readingSeconds: 1800,
listeningSeconds: 0,
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' }));
});
@@ -321,21 +321,21 @@ describe('UserStatisticsService', () => {
readingSeconds: 120,
progressDelta: 1.2346,
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',
readingSeconds: 0,
progressDelta: 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',
readingSeconds: 0,
progressDelta: 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,
readingSeconds: 1000,
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(
@@ -428,11 +428,16 @@ describe('UserStatisticsService', () => {
hour: 21,
readingSeconds: 900,
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.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,
eventsCount: 6,
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({
dayOfWeek: 0,
readingSeconds: 0,
eventsCount: 0,
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.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([
{ durationSeconds: 240, progressDelta: 1.4, bucket: 'bookorbit', format: 'EPUB' },