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
@@ -16,9 +16,10 @@ describe("toReadingSessionSourceBucket", () => {
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("watchos")).toBe("watchos");
expect(toReadingSessionSourceBucket("android")).toBe("android");
});
it("maps koreader to koreader", () => {
@@ -36,8 +37,8 @@ describe("toReadingSessionSourceBucket", () => {
});
describe("reading session source bucket constants", () => {
it("exposes all five display buckets", () => {
expect(READING_SESSION_SOURCE_BUCKETS).toEqual(["bookorbit", "ios", "watchos", "koreader", "kobo"]);
it("exposes all six display buckets", () => {
expect(READING_SESSION_SOURCE_BUCKETS).toEqual(["bookorbit", "ios", "watchos", "android", "koreader", "kobo"]);
});
it("labels every bucket", () => {
@@ -45,12 +46,13 @@ describe("reading session source bucket constants", () => {
bookorbit: "BookOrbit",
ios: "iOS app",
watchos: "Apple Watch",
android: "Android app",
koreader: "KOReader",
kobo: "Kobo",
});
});
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";
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 const READING_SESSION_SOURCE_BUCKET_LABELS: Record<ReadingSessionSourceBucket, string> = {
bookorbit: "BookOrbit",
ios: "iOS app",
watchos: "Apple Watch",
android: "Android app",
koreader: "KOReader",
kobo: "Kobo",
};
// 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 {
if (source === "ios") return "ios";
if (source === "watchos") return "watchos";
if (source === "android") return "android";
if (source === "koreader") return "koreader";
if (source === "kobo") return "kobo";
return "bookorbit";
}
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";
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 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 interface BookReadingSession {