mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
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:
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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' },
|
||||
|
||||
Reference in New Issue
Block a user