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:
@@ -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)),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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' },
|
||||||
|
|||||||
Reference in New Issue
Block a user