diff --git a/.env.example b/.env.example index ae45a6f..63af57d 100644 --- a/.env.example +++ b/.env.example @@ -28,8 +28,10 @@ AUTH_SECRET= # GITHUB_CLIENT_ID= # GITHUB_CLIENT_SECRET= -# Google Calendar integration. OAuth Web client (Calendar API enabled) with -# redirect URI http://localhost:3002/v1/integrations/google/callback +# Google integrations (Calendar + Play Books). One OAuth Web client, with both +# APIs enabled and both redirect URIs registered: +# http://localhost:3002/v1/integrations/google/callback +# http://localhost:3002/v1/integrations/google-books/callback # GOOGLE_CLIENT_ID= # GOOGLE_CLIENT_SECRET= diff --git a/apps/api/Dockerfile b/apps/api/Dockerfile index 86ff726..0538f8a 100644 --- a/apps/api/Dockerfile +++ b/apps/api/Dockerfile @@ -12,6 +12,7 @@ COPY packages/events/package.json ./packages/events/package.json COPY packages/metrics/package.json ./packages/metrics/package.json COPY packages/integrations/github/package.json ./packages/integrations/github/package.json COPY packages/integrations/google-calendar/package.json ./packages/integrations/google-calendar/package.json +COPY packages/integrations/google-books/package.json ./packages/integrations/google-books/package.json RUN pnpm install --frozen-lockfile --filter @baseline/api... @@ -25,6 +26,7 @@ COPY packages/events ./packages/events COPY packages/metrics ./packages/metrics COPY packages/integrations/github ./packages/integrations/github COPY packages/integrations/google-calendar ./packages/integrations/google-calendar +COPY packages/integrations/google-books ./packages/integrations/google-books COPY tsconfig.base.json turbo.json ./ ENV NEXT_TELEMETRY_DISABLED=1 diff --git a/apps/api/app/v1/integrations/[id]/route.ts b/apps/api/app/v1/integrations/[id]/route.ts index 92f5c0c..8f2ff93 100644 --- a/apps/api/app/v1/integrations/[id]/route.ts +++ b/apps/api/app/v1/integrations/[id]/route.ts @@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { db, integrations } from '@baseline/db'; import { eq, and } from 'drizzle-orm'; import { getCurrentUserId } from '../../../../lib/user'; -import { revokeIntegrations } from '../../../../lib/revoke-integrations'; +import { revokeIntegrations, isGoogleProvider } from '../../../../lib/revoke-integrations'; export async function DELETE( _request: NextRequest, @@ -25,7 +25,20 @@ export async function DELETE( ); } - await revokeIntegrations([existing]); + // Disconnecting one Google source must not revoke the shared grant while another + // is still connected — that would silently break the one the user kept. Their own + // tokens are cleared below either way. + let shouldRevoke = true; + if (isGoogleProvider(existing.provider)) { + const connected = await db + .select({ provider: integrations.provider }) + .from(integrations) + .where(and(eq(integrations.userId, userId), eq(integrations.status, 'connected'))); + shouldRevoke = !connected.some( + (o) => isGoogleProvider(o.provider) && o.provider !== existing.provider, + ); + } + if (shouldRevoke) await revokeIntegrations([existing]); const [row] = await db .update(integrations) diff --git a/apps/api/app/v1/integrations/github/repos/route.ts b/apps/api/app/v1/integrations/github/repos/route.ts new file mode 100644 index 0000000..b97da88 --- /dev/null +++ b/apps/api/app/v1/integrations/github/repos/route.ts @@ -0,0 +1,114 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { db, integrations, events } from '@baseline/db'; +import { eq, and, desc } from 'drizzle-orm'; +import { fetchRepositories } from '@baseline/integrations-github'; +import { getCurrentUserId } from '../../../../../lib/user'; + +const PROVIDER = 'github'; + +interface Settings { + /** `owner/name` repos to sync. Absent means all of them. */ + tracked_repos?: string[]; +} + +async function loadIntegration(userId: string) { + const [row] = await db + .select() + .from(integrations) + .where(and(eq(integrations.userId, userId), eq(integrations.provider, PROVIDER))); + return row; +} + +/** Event counts per repo, so the page can show what each one has contributed. */ +async function eventCountsByRepo(userId: string) { + const rows = await db + .select({ payload: events.payload }) + .from(events) + .where(and(eq(events.userId, userId), eq(events.source, PROVIDER))) + .orderBy(desc(events.occurredAt)); + + const counts = new Map(); + for (const r of rows) { + const repo = (r.payload as Record | null)?.repo as string | undefined; + if (repo) counts.set(repo, (counts.get(repo) ?? 0) + 1); + } + return counts; +} + +export async function GET() { + const userId = await getCurrentUserId(); + const integration = await loadIntegration(userId); + + if (!integration || integration.status !== 'connected' || !integration.accessToken) { + return NextResponse.json( + { error: 'GitHub not connected', code: 'NOT_CONNECTED' }, + { status: 404 }, + ); + } + + const tracked = ((integration.settings ?? {}) as Settings).tracked_repos; + + let repos; + try { + repos = await fetchRepositories(integration.accessToken); + } catch (err) { + const message = err instanceof Error ? err.message : ''; + if (message === 'GITHUB_TOKEN_INVALID') { + return NextResponse.json( + { error: 'Reconnect GitHub', code: 'TOKEN_INVALID' }, + { status: 409 }, + ); + } + throw err; + } + + const counts = await eventCountsByRepo(userId); + + return NextResponse.json({ + repos: repos.map((r) => ({ + name: r.nameWithOwner, + is_private: r.isPrivate, + is_fork: r.isFork, + is_archived: r.isArchived, + description: r.description, + language: r.primaryLanguage, + pushed_at: r.pushedAt, + // Absent settings means everything is tracked. + tracked: tracked ? tracked.includes(r.nameWithOwner) : true, + event_count: counts.get(r.nameWithOwner) ?? 0, + })), + last_synced_at: integration.lastSyncedAt, + }); +} + +export async function PUT(request: NextRequest) { + const userId = await getCurrentUserId(); + const integration = await loadIntegration(userId); + + if (!integration || integration.status !== 'connected') { + return NextResponse.json( + { error: 'GitHub not connected', code: 'NOT_CONNECTED' }, + { status: 404 }, + ); + } + + let body: { tracked_repos?: unknown }; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: 'Invalid JSON body', code: 'INVALID_BODY' }, { status: 400 }); + } + + const names = body.tracked_repos; + if (!Array.isArray(names) || names.some((v) => typeof v !== 'string')) { + return NextResponse.json( + { error: 'tracked_repos must be an array of strings', code: 'INVALID_BODY' }, + { status: 400 }, + ); + } + + const settings = { ...((integration.settings ?? {}) as Settings), tracked_repos: names as string[] }; + await db.update(integrations).set({ settings }).where(eq(integrations.id, integration.id)); + + return NextResponse.json({ tracked_repos: names }); +} diff --git a/apps/api/app/v1/integrations/google-books/authorize/route.ts b/apps/api/app/v1/integrations/google-books/authorize/route.ts new file mode 100644 index 0000000..99bc1c0 --- /dev/null +++ b/apps/api/app/v1/integrations/google-books/authorize/route.ts @@ -0,0 +1,33 @@ +import { NextResponse } from 'next/server'; +import { cookies } from 'next/headers'; +import { buildAuthorizationUrl } from '@baseline/integrations-google-books'; +import { getCurrentUserId } from '../../../../../lib/user'; +import { resolvePublicOrigin } from '../../../../../lib/origin'; + +export async function GET() { + await getCurrentUserId(); + + const clientId = process.env.GOOGLE_CLIENT_ID; + if (!clientId) { + return NextResponse.json( + { error: 'Google OAuth not configured', code: 'NOT_CONFIGURED' }, + { status: 500 }, + ); + } + + const state = crypto.randomUUID(); + const redirectUri = `${await resolvePublicOrigin()}/v1/integrations/google-books/callback`; + + const cookieStore = await cookies(); + // Distinct from the Calendar state cookie so connecting one cannot clobber a + // consent flow already in progress for the other. + cookieStore.set('google_books_oauth_state', state, { + httpOnly: true, + sameSite: 'lax', + maxAge: 600, + path: '/', + }); + + const url = buildAuthorizationUrl(clientId, redirectUri, state); + return NextResponse.redirect(url); +} diff --git a/apps/api/app/v1/integrations/google-books/callback/route.ts b/apps/api/app/v1/integrations/google-books/callback/route.ts new file mode 100644 index 0000000..cf59a88 --- /dev/null +++ b/apps/api/app/v1/integrations/google-books/callback/route.ts @@ -0,0 +1,80 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { cookies } from 'next/headers'; +import { db, integrations } from '@baseline/db'; +import { eq, and } from 'drizzle-orm'; +import { exchangeCodeForToken, fetchGoogleUser } from '@baseline/integrations-google-books'; +import { getCurrentUserId } from '../../../../../lib/user'; +import { resolvePublicOrigin } from '../../../../../lib/origin'; + +const PROVIDER = 'google_books'; + +export async function GET(request: NextRequest) { + const searchParams = request.nextUrl.searchParams; + const code = searchParams.get('code'); + const state = searchParams.get('state'); + const webUrl = await resolvePublicOrigin(); + + const cookieStore = await cookies(); + const storedState = cookieStore.get('google_books_oauth_state')?.value; + cookieStore.delete('google_books_oauth_state'); + + let userId: string; + try { + userId = await getCurrentUserId(); + } catch { + return NextResponse.redirect(`${webUrl}/sign-in?error=not_signed_in`); + } + + const gError = searchParams.get('error'); + if (gError) { + return NextResponse.redirect(`${webUrl}/sources?error=${encodeURIComponent(gError)}`); + } + + if (!code || !state || state !== storedState) { + return NextResponse.redirect(`${webUrl}/sources?error=invalid_state`); + } + + const clientId = process.env.GOOGLE_CLIENT_ID!; + const clientSecret = process.env.GOOGLE_CLIENT_SECRET!; + const redirectUri = `${webUrl}/v1/integrations/google-books/callback`; + + try { + const tokenData = await exchangeCodeForToken(clientId, clientSecret, code, redirectUri); + const googleUser = await fetchGoogleUser(tokenData.access_token); + const tokenExpiresAt = new Date(Date.now() + tokenData.expires_in * 1000); + + const [existing] = await db + .select() + .from(integrations) + .where(and(eq(integrations.userId, userId), eq(integrations.provider, PROVIDER))); + + if (existing) { + await db + .update(integrations) + .set({ + status: 'connected', + accessToken: tokenData.access_token, + refreshToken: tokenData.refresh_token ?? existing.refreshToken, + tokenExpiresAt, + externalAccountId: googleUser.email, + connectedAt: new Date(), + }) + .where(eq(integrations.id, existing.id)); + } else { + await db.insert(integrations).values({ + userId, + provider: PROVIDER, + status: 'connected', + accessToken: tokenData.access_token, + refreshToken: tokenData.refresh_token ?? null, + tokenExpiresAt, + externalAccountId: googleUser.email, + }); + } + + return NextResponse.redirect(`${webUrl}/sources?success=google_books`); + } catch (err) { + console.error('Google Books OAuth callback error:', err); + return NextResponse.redirect(`${webUrl}/sources?error=oauth_failed`); + } +} diff --git a/apps/api/app/v1/integrations/google-books/volumes/route.ts b/apps/api/app/v1/integrations/google-books/volumes/route.ts new file mode 100644 index 0000000..b5f005e --- /dev/null +++ b/apps/api/app/v1/integrations/google-books/volumes/route.ts @@ -0,0 +1,128 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { db, integrations, events } from '@baseline/db'; +import { eq, and, desc } from 'drizzle-orm'; +import { fetchLibrary } from '@baseline/integrations-google-books'; +import { getCurrentUserId } from '../../../../../lib/user'; +import { ensureGoogleAccessToken } from '../../../../../lib/ingestion'; + +const PROVIDER = 'google_books'; + +interface Settings { + /** Volume ids to sync. Absent means every book — so connecting a source and + * never opening this page keeps collecting everything. */ + tracked_volume_ids?: string[]; +} + +async function loadIntegration(userId: string) { + const [row] = await db + .select() + .from(integrations) + .where(and(eq(integrations.userId, userId), eq(integrations.provider, PROVIDER))); + return row; +} + +/** Latest non-deleted bookmark per volume — the current position in each book. */ +async function currentPositions(userId: string) { + const rows = await db + .select({ occurredAt: events.occurredAt, payload: events.payload }) + .from(events) + .where(and(eq(events.userId, userId), eq(events.source, PROVIDER))) + .orderBy(desc(events.occurredAt)); + + const latest = new Map }>(); + for (const r of rows) { + const p = (r.payload ?? {}) as Record; + const volumeId = p.volume_id as string | undefined; + // A removed bookmark marks somewhere you read, but it is not where you are now. + if (!volumeId || p.deleted === true || latest.has(volumeId)) continue; + latest.set(volumeId, { occurredAt: r.occurredAt, payload: p }); + } + return latest; +} + +export async function GET() { + const userId = await getCurrentUserId(); + const integration = await loadIntegration(userId); + + if (!integration || integration.status !== 'connected') { + return NextResponse.json( + { error: 'Google Books not connected', code: 'NOT_CONNECTED' }, + { status: 404 }, + ); + } + + const settings = (integration.settings ?? {}) as Settings; + const tracked = settings.tracked_volume_ids; + + let token: string; + try { + token = await ensureGoogleAccessToken(integration); + } catch { + return NextResponse.json( + { error: 'Reconnect Google Books', code: 'TOKEN_INVALID' }, + { status: 409 }, + ); + } + + const [library, positions] = await Promise.all([fetchLibrary(token), currentPositions(userId)]); + + return NextResponse.json({ + volumes: library.map((v) => { + const at = positions.get(v.id); + const p = at?.payload; + return { + volume_id: v.id, + title: v.title, + authors: v.authors, + page_count: v.pageCount, + thumbnail: v.thumbnail, + acquire_method: v.acquireMethod, + is_sample: v.acquireMethod === 'SAMPLE', + // Absent settings means everything is tracked. + tracked: tracked ? tracked.includes(v.id) : true, + progress: p + ? { + page_label: p.page_label as string, + page_kind: p.page_kind as string, + page_number: (p.page_number ?? null) as number | null, + percent: (p.percent ?? null) as number | null, + read_at: at!.occurredAt, + } + : null, + }; + }), + last_synced_at: integration.lastSyncedAt, + }); +} + +export async function PUT(request: NextRequest) { + const userId = await getCurrentUserId(); + const integration = await loadIntegration(userId); + + if (!integration || integration.status !== 'connected') { + return NextResponse.json( + { error: 'Google Books not connected', code: 'NOT_CONNECTED' }, + { status: 404 }, + ); + } + + let body: { tracked_volume_ids?: unknown }; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: 'Invalid JSON body', code: 'INVALID_BODY' }, { status: 400 }); + } + + const ids = body.tracked_volume_ids; + if (!Array.isArray(ids) || ids.some((v) => typeof v !== 'string')) { + return NextResponse.json( + { error: 'tracked_volume_ids must be an array of strings', code: 'INVALID_BODY' }, + { status: 400 }, + ); + } + + const settings = { ...((integration.settings ?? {}) as Settings), tracked_volume_ids: ids as string[] }; + await db.update(integrations).set({ settings }).where(eq(integrations.id, integration.id)); + + return NextResponse.json({ tracked_volume_ids: ids }); +} diff --git a/apps/api/app/v1/integrations/route.ts b/apps/api/app/v1/integrations/route.ts index 761dfad..56818e4 100644 --- a/apps/api/app/v1/integrations/route.ts +++ b/apps/api/app/v1/integrations/route.ts @@ -43,7 +43,7 @@ export async function POST(request: NextRequest) { ); } - if (!body.provider || !['github', 'gcal'].includes(body.provider)) { + if (!body.provider || !['github', 'gcal', 'google_books'].includes(body.provider)) { return NextResponse.json( { error: 'Invalid provider', code: 'INVALID_PROVIDER' }, { status: 400 }, diff --git a/apps/api/app/v1/metrics/books/overview/route.ts b/apps/api/app/v1/metrics/books/overview/route.ts new file mode 100644 index 0000000..853d84b --- /dev/null +++ b/apps/api/app/v1/metrics/books/overview/route.ts @@ -0,0 +1,54 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { dayKeyInTz, computeDelta, longestDayStreak } from '@baseline/metrics'; +import { getCurrentUserId, getUserTimezone } from '../../../../../lib/user'; +import { periodBounds, isPeriod, offsetNow, parseOffset } from '../../../../../lib/period'; +import { loadProgressSteps, loadBookmarkEvents, type ProgressStep } from '../../../../../lib/book-progress'; + +const within = (s: ProgressStep[], from: Date, to: Date) => s.filter((v) => v.at >= from && v.at < to); +const pages = (s: ProgressStep[]) => s.reduce((a, v) => a + v.pages, 0); + +// GET /v1/metrics/books/overview?period=week|month|year — reading metrics derived +// from bookmark positions, with a delta vs the same elapsed slice of the prior period. +export async function GET(request: NextRequest) { + const userId = await getCurrentUserId(); + const tz = await getUserTimezone(userId); + const periodParam = request.nextUrl.searchParams.get('period') || 'week'; + if (!isPeriod(periodParam)) { + return NextResponse.json({ error: 'Invalid period', code: 'INVALID_PERIOD' }, { status: 400 }); + } + + const now = new Date(); + const offset = parseOffset(request.nextUrl.searchParams.get('offset')); + const b = periodBounds(periodParam, offsetNow(periodParam, now, tz, offset), tz); + const currEnd = now < b.end ? now : b.end; + const elapsedMs = currEnd.getTime() - b.start.getTime(); + const prevEnd = new Date(b.prevStart.getTime() + elapsedMs); + + // Steps need the whole trail, not just the window — see loadProgressSteps. + const [steps, marks] = await Promise.all([ + loadProgressSteps(userId, currEnd), + loadBookmarkEvents(userId, b.prevStart, currEnd), + ]); + + const currSteps = within(steps, b.start, currEnd); + const prevSteps = within(steps, b.prevStart, prevEnd); + const marksIn = (from: Date, to: Date) => marks.filter((m) => m.at >= from && m.at < to); + const currMarks = marksIn(b.start, currEnd); + const prevMarks = marksIn(b.prevStart, prevEnd); + + const dayKeys = (m: typeof marks) => m.map((v) => dayKeyInTz(v.at, tz)); + const readingDays = (m: typeof marks) => new Set(dayKeys(m)).size; + const bestStreak = (m: typeof marks) => longestDayStreak(dayKeys(m)); + const booksRead = (m: typeof marks) => new Set(m.map((v) => v.volumeId)).size; + const mk = (cv: number, pv: number, unit: string) => ({ value: cv, delta: computeDelta(cv, pv), unit, prev: pv }); + + return NextResponse.json({ + period: periodParam, + metrics: { + pages_advanced: mk(pages(currSteps), pages(prevSteps), 'pages'), + books_read: mk(booksRead(currMarks), booksRead(prevMarks), 'books'), + reading_days: mk(readingDays(currMarks), readingDays(prevMarks), 'days'), + reading_streak: mk(bestStreak(currMarks), bestStreak(prevMarks), 'days'), + }, + }); +} diff --git a/apps/api/app/v1/metrics/books/timeseries/route.ts b/apps/api/app/v1/metrics/books/timeseries/route.ts new file mode 100644 index 0000000..26477dd --- /dev/null +++ b/apps/api/app/v1/metrics/books/timeseries/route.ts @@ -0,0 +1,68 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { dayKeyInTz, longestDayStreak } from '@baseline/metrics'; +import { getCurrentUserId, getUserTimezone } from '../../../../../lib/user'; +import { periodBounds, periodBuckets, isPeriod, offsetNow, parseOffset } from '../../../../../lib/period'; +import { loadProgressSteps, loadBookmarkEvents } from '../../../../../lib/book-progress'; + +const METRICS = ['pages_advanced', 'books_read', 'reading_days', 'reading_streak']; + +// GET /v1/metrics/books/timeseries?metric=&period= — the metric per natural bucket +// across the period, for the bar chart. +export async function GET(request: NextRequest) { + const userId = await getCurrentUserId(); + const tz = await getUserTimezone(userId); + const params = request.nextUrl.searchParams; + const metricsParam = params.get('metrics'); + const metric = params.get('metric') || ''; + const periodParam = params.get('period') || 'week'; + + const requested = metricsParam ? metricsParam.split(',').filter(Boolean) : metric ? [metric] : []; + if (requested.length === 0 || requested.some((mk) => !METRICS.includes(mk))) { + return NextResponse.json({ error: 'Invalid metric', code: 'INVALID_METRIC' }, { status: 400 }); + } + if (!isPeriod(periodParam)) { + return NextResponse.json({ error: 'Invalid period', code: 'INVALID_PERIOD' }, { status: 400 }); + } + + const now = new Date(); + const offset = parseOffset(params.get('offset')); + const b = periodBounds(periodParam, offsetNow(periodParam, now, tz, offset), tz); + const buckets = periodBuckets(periodParam, b.start, b.end, tz); + + const [steps, marks] = await Promise.all([ + loadProgressSteps(userId, b.end), + loadBookmarkEvents(userId, b.start, b.end), + ]); + + const valueFor = (mk: string, from: Date, to: Date): number => { + const s = steps.filter((v) => v.at >= from && v.at < to); + const m = marks.filter((v) => v.at >= from && v.at < to); + switch (mk) { + case 'pages_advanced': + return s.reduce((a, v) => a + v.pages, 0); + case 'books_read': + return new Set(m.map((v) => v.volumeId)).size; + case 'reading_days': + return new Set(m.map((v) => dayKeyInTz(v.at, tz))).size; + // Per bucket, so a daily bar is 0 or 1 — the streak is only meaningful at + // month/year granularity, same as the GitHub tile. + case 'reading_streak': + return longestDayStreak(m.map((v) => dayKeyInTz(v.at, tz))); + default: + return 0; + } + }; + + const series: Record> = {}; + for (const mk of requested) { + series[mk] = buckets.map((bk) => ({ + date: dayKeyInTz(bk.start, tz), + value: valueFor(mk, bk.start, bk.end), + })); + } + + if (metricsParam) { + return NextResponse.json({ period: periodParam, series }); + } + return NextResponse.json({ metric: requested[0], period: periodParam, data: series[requested[0]] }); +} diff --git a/apps/api/lib/book-progress.ts b/apps/api/lib/book-progress.ts new file mode 100644 index 0000000..9c55215 --- /dev/null +++ b/apps/api/lib/book-progress.ts @@ -0,0 +1,115 @@ +import { db, events } from '@baseline/db'; +import { eq, and, lt } from 'drizzle-orm'; + +const SOURCE = 'google_books'; + +export interface ProgressStep { + /** When the later bookmark of the pair was placed. */ + at: Date; + volumeId: string; + /** Pages advanced since the previous bookmark in this book. */ + pages: number; +} + +interface Bookmark { + at: Date; + volumeId: string; + kind: string; + number: number | null; +} + +/** + * Turn a book's bookmark trail into per-step advances. + * + * Play Books never reports pages read, but consecutive bookmarks in the same book + * do: PA5 followed by PA6 is one page. Each step is credited to the *later* + * bookmark, since that is when the reading had happened by. + * + * Deliberate limits: + * - The first bookmark in a book scores nothing. We do not know where you started, + * and assuming page one would invent a chunk of reading. + * - Steps between different page-number schemes score nothing. PR8 to PA5 is real + * reading, but Roman front matter and Arabic body pages are separate scales and + * subtracting one from the other is meaningless. + * - Backwards steps score zero rather than negative — re-reading is not un-reading. + * + * Deleted bookmarks still count: a removed bookmark marks a position you reached, + * and dropping them would erase most of the trail. + */ +export function stepsFromBookmarks(bookmarks: Bookmark[]): ProgressStep[] { + const byVolume = new Map(); + for (const b of bookmarks) { + const list = byVolume.get(b.volumeId); + if (list) list.push(b); + else byVolume.set(b.volumeId, [b]); + } + + const steps: ProgressStep[] = []; + for (const [volumeId, list] of byVolume) { + list.sort((a, b) => a.at.getTime() - b.at.getTime()); + for (let i = 1; i < list.length; i++) { + const prev = list[i - 1]; + const curr = list[i]; + if (prev.kind !== curr.kind) continue; + + // A percentage would just be this over the book's page count — the same + // number scaled — so pages are the only thing worth carrying. + const pages = + prev.number !== null && curr.number !== null ? Math.max(0, curr.number - prev.number) : 0; + if (pages === 0) continue; + + steps.push({ at: curr.at, volumeId, pages }); + } + } + + return steps.sort((a, b) => a.at.getTime() - b.at.getTime()); +} + +/** + * Every progress step up to `end`. + * + * Reads the whole trail rather than only the reporting window: the first bookmark + * inside a window needs its predecessor from before it, or a week that continues a + * book already in progress would score zero for its first session. + */ +export async function loadProgressSteps(userId: string, end: Date): Promise { + const rows = await db + .select({ occurredAt: events.occurredAt, payload: events.payload }) + .from(events) + .where(and(eq(events.userId, userId), eq(events.source, SOURCE), lt(events.occurredAt, end))); + + const bookmarks: Bookmark[] = []; + for (const r of rows) { + const p = (r.payload ?? {}) as Record; + const volumeId = p.volume_id as string | undefined; + if (!volumeId) continue; + bookmarks.push({ + at: r.occurredAt, + volumeId, + kind: (p.page_kind as string) ?? 'unknown', + number: (p.page_number ?? null) as number | null, + }); + } + + return stepsFromBookmarks(bookmarks); +} + +/** Bookmarks themselves, for metrics that count events rather than advances. */ +export async function loadBookmarkEvents( + userId: string, + start: Date, + end: Date, +): Promise> { + const rows = await db + .select({ occurredAt: events.occurredAt, payload: events.payload }) + .from(events) + .where(and(eq(events.userId, userId), eq(events.source, SOURCE), lt(events.occurredAt, end))); + + return rows + .filter((r) => r.occurredAt >= start) + .map((r) => ({ + at: r.occurredAt, + volumeId: ((r.payload ?? {}) as Record).volume_id as string, + })) + .filter((r) => Boolean(r.volumeId)); +} diff --git a/apps/api/lib/ingestion.ts b/apps/api/lib/ingestion.ts index dd1db34..39e25a1 100644 --- a/apps/api/lib/ingestion.ts +++ b/apps/api/lib/ingestion.ts @@ -14,6 +14,11 @@ import { fetchCalendarEvents, normalizeCalendarEvents, } from '@baseline/integrations-google-calendar'; +import { + fetchLibrary, + fetchBookmarks, + normalizeBookmarks, +} from '@baseline/integrations-google-books'; type Integration = InferSelectModel; @@ -31,6 +36,10 @@ export async function syncIntegration(integrationId: string): Promise { return syncGoogleCalendar(integration); } + if (integration.provider === 'google_books') { + return syncGoogleBooks(integration); + } + if (!integration.accessToken) { throw new Error('Integration not connected or missing token'); } @@ -52,11 +61,16 @@ export async function syncIntegration(integrationId: string): Promise { const startTime = Date.now(); let totalEvents = 0; + // Absent settings means every repo, so an already-connected source keeps + // collecting what it did before this setting existed. + const trackedRepos = (integration.settings as { tracked_repos?: string[] } | null) + ?.tracked_repos; + try { const [commits, prs, reviews] = await Promise.all([ - fetchUserCommits(integration.accessToken, username, since), - fetchUserPullRequests(integration.accessToken, username, since), - fetchUserReviews(integration.accessToken, username, since), + fetchUserCommits(integration.accessToken, username, since, trackedRepos), + fetchUserPullRequests(integration.accessToken, username, since, trackedRepos), + fetchUserReviews(integration.accessToken, username, since, trackedRepos), ]); const allRows = [ @@ -114,8 +128,9 @@ export async function syncIntegration(integrationId: string): Promise { } // Return a valid Google access token, refreshing (and persisting) it first if the -// stored one is missing or within 60s of expiry. -async function ensureGoogleAccessToken(integration: Integration): Promise { +// stored one is missing or within 60s of expiry. Shared by every Google source — +// the refresh exchange is the same regardless of which scopes the grant carries. +export async function ensureGoogleAccessToken(integration: Integration): Promise { const clientId = process.env.GOOGLE_CLIENT_ID; const clientSecret = process.env.GOOGLE_CLIENT_SECRET; if (!clientId || !clientSecret) { @@ -197,6 +212,86 @@ async function syncGoogleCalendar(integration: Integration): Promise { } } +async function syncGoogleBooks(integration: Integration): Promise { + // Bookmarks are sparse, so a wide backfill is cheap and picks up anything placed + // before the integration was connected. + const FULL_BACKFILL_MS = 365 * 24 * 60 * 60 * 1000; + const OVERLAP_MS = 3 * 24 * 60 * 60 * 1000; + const since = integration.lastSyncedAt + ? new Date(integration.lastSyncedAt.getTime() - OVERLAP_MS) + : new Date(Date.now() - FULL_BACKFILL_MS); + const startTime = Date.now(); + + try { + const token = await ensureGoogleAccessToken(integration); + const library = await fetchLibrary(token); + + // Absent settings means every book, so connecting without visiting the config + // page keeps collecting everything. + const trackedIds = (integration.settings as { tracked_volume_ids?: string[] } | null) + ?.tracked_volume_ids; + const tracked = trackedIds ? library.filter((v) => trackedIds.includes(v.id)) : library; + + // The annotations endpoint rejects any call without a volumeId, so there is no + // library-wide incremental sync — one request per volume is the only option. + // Narrowing to tracked books is what keeps that bounded on a large library. + const rows = []; + for (const volume of tracked) { + const bookmarks = await fetchBookmarks(token, volume.id, since); + rows.push(...normalizeBookmarks(bookmarks, volume, integration.userId)); + } + + // Unlike a commit or a calendar entry, a bookmark is mutable: deleting one keeps + // its annotation id and only flips `deleted`, and its page can move too. With + // onConflictDoNothing the row would keep whatever we first saw, so a deletion + // could never propagate. Upsert the payload instead. occurredAt is left alone — + // it is the bookmark's creation time and does not change. + for (let i = 0; i < rows.length; i += 100) { + await db + .insert(events) + .values(rows.slice(i, i + 100)) + .onConflictDoUpdate({ + target: [events.userId, events.source, events.sourceId, events.eventType], + set: { payload: sql`excluded.payload`, ingestedAt: new Date() }, + }); + } + + await db + .update(integrations) + .set({ lastSyncedAt: new Date(), status: 'connected' }) + .where(eq(integrations.id, integration.id)); + + console.log( + JSON.stringify({ + msg: 'sync_complete', + integration_id: integration.id, + provider: 'google_books', + volumes: tracked.length, + volumes_in_library: library.length, + events: rows.length, + duration_ms: Date.now() - startTime, + }), + ); + + return rows.length; + } catch (err) { + const message = err instanceof Error ? err.message : 'Unknown error'; + if (message === 'GOOGLE_TOKEN_INVALID') { + await db.update(integrations).set({ status: 'error' }).where(eq(integrations.id, integration.id)); + } + console.error( + JSON.stringify({ + msg: 'sync_failed', + integration_id: integration.id, + provider: 'google_books', + error: message, + duration_ms: Date.now() - startTime, + }), + ); + throw err; + } +} + export interface SyncBatchOptions { /** Max integrations synced at once. Keeps DB/GitHub load bounded. */ concurrency?: number; diff --git a/apps/api/lib/revoke-integrations.ts b/apps/api/lib/revoke-integrations.ts index 0db81f7..7597a25 100644 --- a/apps/api/lib/revoke-integrations.ts +++ b/apps/api/lib/revoke-integrations.ts @@ -1,6 +1,16 @@ import { revokeGoogleToken } from '@baseline/integrations-google-calendar'; import { revokeGitHubGrant } from '@baseline/integrations-github'; +// Every Google source is one OAuth client, and consent is extended rather than +// duplicated (include_granted_scopes), so they share a single grant. Revoking any of +// them revokes all of them — callers disconnecting one source must check none of the +// others is still connected before asking for a revoke. +const GOOGLE_PROVIDERS = new Set(['google_calendar', 'google_books']); + +export function isGoogleProvider(provider: string): boolean { + return GOOGLE_PROVIDERS.has(provider); +} + interface IntegrationTokens { provider: string; accessToken: string | null; @@ -14,7 +24,7 @@ export async function revokeIntegrations(rows: IntegrationTokens[]): Promise { try { - if (r.provider === 'google_calendar') { + if (GOOGLE_PROVIDERS.has(r.provider)) { const token = r.refreshToken || r.accessToken; // refresh token revokes the whole grant if (token) await revokeGoogleToken(token); } else if (r.provider === 'github') { diff --git a/apps/api/package.json b/apps/api/package.json index ac66a34..20364cc 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -14,6 +14,7 @@ "@baseline/db": "workspace:*", "@baseline/events": "workspace:*", "@baseline/integrations-github": "workspace:*", + "@baseline/integrations-google-books": "workspace:*", "@baseline/integrations-google-calendar": "workspace:*", "@baseline/metrics": "workspace:*", "@node-rs/bcrypt": "^1.10.7", diff --git a/apps/web/src/app/(dashboard)/history/page.tsx b/apps/web/src/app/(dashboard)/history/page.tsx index 347d4d8..b002e7f 100644 --- a/apps/web/src/app/(dashboard)/history/page.tsx +++ b/apps/web/src/app/(dashboard)/history/page.tsx @@ -5,6 +5,7 @@ import { apiFetch } from '../../../lib/api'; import { useTimezone } from '../../../lib/use-timezone'; import { fmtDuration } from '../../../lib/time-units'; import { useTimeUnit } from '../../../lib/use-time-unit'; +import { SourceDropdown } from '../../components/source-dropdown'; interface EventItem { id: string; @@ -19,12 +20,10 @@ interface EventsResponse { next_cursor: string | null; } -type SourceFilter = 'all' | 'manual' | 'github'; -const FILTERS: { key: SourceFilter; label: string }[] = [ - { key: 'all', label: 'All' }, - { key: 'manual', label: 'Time entries' }, - { key: 'github', label: 'GitHub' }, -]; +// Ids only — names and icons come from SOURCE_META, so a source is labelled the same +// here as it is on the metrics page. +const SOURCE_FILTERS = ['all', 'manual', 'github', 'google_calendar', 'google_books'] as const; +type SourceFilter = (typeof SOURCE_FILTERS)[number]; function eventMeta(type: string): { label: string; icon: string; color: string } { switch (type) { @@ -36,11 +35,46 @@ function eventMeta(type: string): { label: string; icon: string; color: string } return { label: 'PR merged', icon: '⊕', color: 'bg-violet-500/10 text-violet-600 dark:text-violet-400' }; case 'github.pr.reviewed': return { label: 'Review', icon: '⊙', color: 'bg-cyan-500/10 text-cyan-600 dark:text-cyan-400' }; + case 'google_calendar.event': + return { label: 'Calendar', icon: '▤', color: 'bg-amber-500/10 text-amber-600 dark:text-amber-400' }; + case 'google_books.progress': + return { label: 'Reading', icon: '▥', color: 'bg-sky-500/10 text-sky-600 dark:text-sky-400' }; default: return { label: type.split('.').pop() || type, icon: '◻', color: 'bg-neutral-500/10 text-neutral-500' }; } } +// Reading position reads differently depending on how the book is anchored: printed +// pages divide into a page count, reflowable positions do not, and front matter sits +// before page 1. Showing a percentage for the latter two would invent one. +function bookProgressSummary(p: Record): string { + const title = (p.title as string) || 'Book'; + const kind = p.page_kind as string | undefined; + const n = p.page_number as number | null; + const total = p.page_count as number | null; + const pct = p.percent as number | null; + // A removed bookmark still marks a real position you read to — it usually means + // you moved the bookmark on. Say so rather than hiding the row or implying the + // bookmark is still there. + const removed = p.deleted === true ? ' · bookmark removed' : ''; + + // PA and PT both index into the volume; only PA is a printed page number, so the + // noun differs but both get a percentage. + if ((kind === 'body' || kind === 'reflowable') && n !== null) { + const noun = kind === 'body' ? 'page' : 'position'; + const of = total ? ` of ${total}` : ''; + const share = pct !== null ? ` · ${pct}%` : ''; + return `${title} — ${noun} ${n}${of}${share}${removed}`; + } + // Keep the number: without it consecutive front-matter bookmarks render + // identically and the trail looks like duplicates. + if (kind === 'front_matter') { + return `${title} — front matter${n !== null ? ` page ${n}` : ''}${removed}`; + } + if (n !== null) return `${title} — position ${n}${removed}`; + return `${title}${removed}`; +} + function summary(ev: EventItem): string { const p = ev.payload ?? {}; switch (ev.event_type) { @@ -52,6 +86,10 @@ function summary(ev: EventItem): string { return (p.title as string) || ''; case 'github.pr.reviewed': return `Review on #${p.pr_number}`; + case 'google_calendar.event': + return (p.summary as string) || ''; + case 'google_books.progress': + return bookProgressSummary(p); default: return ''; } @@ -153,20 +191,12 @@ export default function History() {

History

Everything that's happened, most recent first

-
- {FILTERS.map((f) => ( - - ))} +
+ setSource(v as SourceFilter)} + sources={[...SOURCE_FILTERS]} + />
diff --git a/apps/web/src/app/(dashboard)/metrics/page.tsx b/apps/web/src/app/(dashboard)/metrics/page.tsx index 74d5c85..1979f2f 100644 --- a/apps/web/src/app/(dashboard)/metrics/page.tsx +++ b/apps/web/src/app/(dashboard)/metrics/page.tsx @@ -55,6 +55,10 @@ const METRICS: Array<{ { key: 'events', metric: 'events', label: 'Events', unit: 'events', source: 'google_calendar' }, { key: 'avg_length', metric: 'avg_length', label: 'Avg Length', unit: 'min', source: 'google_calendar', suffix: 'm' }, { key: 'busy_days', metric: 'busy_days', label: 'Busy Days', unit: 'days', source: 'google_calendar', sub: 'elapsed', days: true }, + { key: 'pages_advanced', metric: 'pages_advanced', label: 'Pages Advanced', unit: 'pages', source: 'google_books' }, + { key: 'books_read', metric: 'books_read', label: 'Books Read', unit: 'books', source: 'google_books' }, + { key: 'reading_days', metric: 'reading_days', label: 'Reading Days', unit: 'days', source: 'google_books', sub: 'elapsed', days: true }, + { key: 'reading_streak', metric: 'reading_streak', label: 'Best Streak', unit: 'days', source: 'google_books', suffix: 'd' }, { key: 'tasks_completed', metric: 'tasks_completed', label: 'Tasks Completed', unit: 'tasks', source: 'baseline' }, { key: 'goals_completed', metric: 'goals_completed', label: 'Goals Completed', unit: 'goals', source: 'baseline' }, { key: 'hours_tracked', metric: 'hours_tracked', label: 'Hours Tracked', unit: 'hrs', source: 'baseline', suffix: 'h' }, @@ -66,7 +70,11 @@ const METRICS: Array<{ const SOURCES = [...new Set(['baseline', ...METRICS.map((m) => m.source)])]; // Where each source's overview/timeseries live. -const SOURCE_BASE: Record = { google_calendar: '/v1/metrics/calendar', baseline: '/v1/metrics/baseline' }; +const SOURCE_BASE: Record = { + google_calendar: '/v1/metrics/calendar', + google_books: '/v1/metrics/books', + baseline: '/v1/metrics/baseline', +}; const overviewUrl = (source: string, period: string, offset: number) => `${SOURCE_BASE[source] ?? '/v1/metrics'}/overview?period=${period}&offset=${offset}`; const timeseriesBatchUrl = (source: string, metrics: string[], period: string, offset: number) => @@ -140,10 +148,16 @@ export default function Metrics() { // Group tiles by source so each source's series load in a single batched request. const bySource = new Map(); for (const d of METRICS) bySource.set(d.source, [...(bySource.get(d.source) ?? []), d]); + // Overviews are fetched per source off the same grouping as the series below — + // a hardcoded list here silently leaves a new integration's tiles empty while + // its charts still load, which reads as missing data rather than missing wiring. + const sources = [...bySource.keys()]; Promise.all([ - apiFetch(overviewUrl('github', period, offset)).catch(() => empty), - apiFetch(overviewUrl('google_calendar', period, offset)).catch(() => empty), - apiFetch(overviewUrl('baseline', period, offset)).catch(() => empty), + Promise.all( + sources.map((src) => + apiFetch(overviewUrl(src, period, offset)).catch(() => empty), + ), + ), Promise.all( [...bySource.entries()].map(([src, list]) => apiFetch(timeseriesBatchUrl(src, list.map((d) => d.metric), period, offset)) @@ -152,8 +166,15 @@ export default function Metrics() { ), ), ]) - .then(([gh, cal, base, seriesGroups]) => { - setOverview({ period: gh.period ?? period, since: base.since, metrics: { ...gh.metrics, ...cal.metrics, ...base.metrics } }); + .then(([overviews, seriesGroups]) => { + const bySrc = new Map(sources.map((src, i) => [src, overviews[i]])); + const metrics = Object.assign({}, ...overviews.map((o) => o.metrics)); + setOverview({ + period: bySrc.get('github')?.period ?? period, + // `since` is the account-creation day behind the Baseline averages. + since: bySrc.get('baseline')?.since, + metrics, + }); setSeriesMap(Object.fromEntries(seriesGroups.flat())); }) .catch(console.error) @@ -171,15 +192,13 @@ export default function Metrics() { const visibleMetrics = source === 'all' ? METRICS : METRICS.filter((d) => d.source === source); - // The consistency card tracks the active-days-style metric for the current view: - // busy days for calendar, active days otherwise (incl. the combined "all" view). - const consistencyDef = - source === 'google_calendar' - ? METRICS.find((d) => d.key === 'busy_days') - : source === 'baseline' - ? METRICS.find((d) => d.key === 'tracked_days') - : METRICS.find((d) => d.key === 'active_days'); - const showConsistency = !!consistencyDef && visibleMetrics.some((d) => d.key === consistencyDef.key); + // The consistency card tracks whichever metric in view is the active-days one — + // that is what `days` marks. Derived rather than listed per source, so a new + // integration gets the card by setting the flag instead of editing a chain here. + // For the combined "all" view this picks the first such metric, which is GitHub's + // active days, matching how it has always behaved. + const consistencyDef = visibleMetrics.find((d) => d.days); + const showConsistency = !!consistencyDef; function changeSource(s: string) { setSource(s); diff --git a/apps/web/src/app/(dashboard)/sources/github/page.tsx b/apps/web/src/app/(dashboard)/sources/github/page.tsx new file mode 100644 index 0000000..7b5a9c6 --- /dev/null +++ b/apps/web/src/app/(dashboard)/sources/github/page.tsx @@ -0,0 +1,216 @@ +'use client'; + +import { useEffect, useMemo, useState } from 'react'; +import Link from 'next/link'; +import { apiFetch } from '../../../../lib/api'; + +interface Repo { + name: string; + is_private: boolean; + is_fork: boolean; + is_archived: boolean; + description: string | null; + language: string | null; + pushed_at: string | null; + tracked: boolean; + event_count: number; +} + +interface ReposResponse { + repos: Repo[]; + last_synced_at: string | null; +} + +type Visibility = 'all' | 'public' | 'private'; + +export default function GitHubConfig() { + const [repos, setRepos] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [saving, setSaving] = useState(false); + const [query, setQuery] = useState(''); + const [visibility, setVisibility] = useState('all'); + + useEffect(() => { + apiFetch('/v1/integrations/github/repos') + .then((d) => setRepos(d.repos)) + .catch(() => setError('Could not load your repositories. Try reconnecting GitHub.')) + .finally(() => setLoading(false)); + }, []); + + async function persist(next: Repo[]) { + const previous = repos; + setRepos(next); + setSaving(true); + try { + await apiFetch('/v1/integrations/github/repos', { + method: 'PUT', + body: JSON.stringify({ + tracked_repos: next.filter((r) => r.tracked).map((r) => r.name), + }), + }); + } catch { + setRepos(previous); + setError('Could not save. Your selection was not changed.'); + } finally { + setSaving(false); + } + } + + const visible = useMemo(() => { + const q = query.trim().toLowerCase(); + return repos.filter((r) => { + if (visibility === 'public' && r.is_private) return false; + if (visibility === 'private' && !r.is_private) return false; + return !q || r.name.toLowerCase().includes(q); + }); + }, [repos, query, visibility]); + + const trackedCount = repos.filter((r) => r.tracked).length; + // Bulk actions apply to what is on screen, so a search plus "Track all" is how you + // select a subset without clicking through every row. + const allVisibleTracked = visible.length > 0 && visible.every((r) => r.tracked); + + function toggleAllVisible() { + const names = new Set(visible.map((r) => r.name)); + persist(repos.map((r) => (names.has(r.name) ? { ...r, tracked: !allVisibleTracked } : r))); + } + + if (loading) { + return ( +
+
+
+ {[1, 2, 3, 4, 5].map((i) => ( +
+ ))} +
+
+ ); + } + + return ( +
+ + ← Sources + + +
+ +

GitHub

+
+

+ Commits, merged pull requests and reviews from the repositories you choose. Private repos + are included — your GitHub grant already covers them. +

+ + {error && ( +
+ {error} +
+ )} + +
+ setQuery(e.target.value)} + placeholder="Search repositories" + className="flex-1 text-sm px-3 py-2 rounded-lg border border-neutral-200 dark:border-neutral-700 bg-transparent focus:outline-none focus:ring-1 focus:ring-neutral-400" + /> +
+ {(['all', 'public', 'private'] as Visibility[]).map((v) => ( + + ))} +
+
+ +
+ + {trackedCount}{' '} + of {repos.length} tracked + {saving && Saving…} + + {visible.length > 0 && ( + + )} +
+ + {visible.length === 0 ? ( +

No repositories match.

+ ) : ( +
+ {visible.map((r) => ( +
+
+
+

{r.name}

+ {r.is_private && ( + + Private + + )} + {r.is_fork && ( + + Fork + + )} + {r.is_archived && ( + + Archived + + )} +
+

+ {[ + r.language, + r.event_count > 0 ? `${r.event_count} event${r.event_count === 1 ? '' : 's'}` : null, + r.pushed_at ? `pushed ${new Date(r.pushed_at).toLocaleDateString()}` : null, + ] + .filter(Boolean) + .join(' · ') || 'No activity recorded'} +

+
+ + +
+ ))} +
+ )} +
+ ); +} diff --git a/apps/web/src/app/(dashboard)/sources/google-books/page.tsx b/apps/web/src/app/(dashboard)/sources/google-books/page.tsx new file mode 100644 index 0000000..677586b --- /dev/null +++ b/apps/web/src/app/(dashboard)/sources/google-books/page.tsx @@ -0,0 +1,209 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import Link from 'next/link'; +import { apiFetch } from '../../../../lib/api'; +import { GooglePlayBooksIcon } from '../../../components/source-badge'; + +interface Volume { + volume_id: string; + title: string; + authors: string[]; + page_count: number | null; + thumbnail: string | null; + acquire_method: string; + is_sample: boolean; + tracked: boolean; + progress: { + page_label: string; + page_kind: string; + page_number: number | null; + percent: number | null; + read_at: string; + } | null; +} + +interface VolumesResponse { + volumes: Volume[]; + last_synced_at: string | null; +} + +// `PA` pages carry a printed number; `PT` pages do not, though both index into the +// volume. Word them differently so the label never claims a printed page the book +// does not have, while still giving the position. +function positionLabel(v: Volume): string { + const p = v.progress; + if (!p) return 'No bookmark yet'; + if (p.page_kind === 'front_matter') return 'In the front matter'; + if (p.page_number === null) return p.page_label; + + const noun = p.page_kind === 'body' ? 'Page' : 'Position'; + return v.page_count + ? `${noun} ${p.page_number} of ${v.page_count}` + : `${noun} ${p.page_number}`; +} + +export default function GoogleBooksConfig() { + const [volumes, setVolumes] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [saving, setSaving] = useState(false); + + useEffect(() => { + apiFetch('/v1/integrations/google-books/volumes') + .then((d) => setVolumes(d.volumes)) + .catch(() => setError('Could not load your library. Try reconnecting Google Play Books.')) + .finally(() => setLoading(false)); + }, []); + + async function toggle(volumeId: string) { + // Optimistic: the list is the source of truth for what we send, so flip first. + const next = volumes.map((v) => (v.volume_id === volumeId ? { ...v, tracked: !v.tracked } : v)); + setVolumes(next); + setSaving(true); + try { + await apiFetch('/v1/integrations/google-books/volumes', { + method: 'PUT', + body: JSON.stringify({ + tracked_volume_ids: next.filter((v) => v.tracked).map((v) => v.volume_id), + }), + }); + } catch { + setVolumes(volumes); // revert + setError('Could not save. Your selection was not changed.'); + } finally { + setSaving(false); + } + } + + const tracked = volumes.filter((v) => v.tracked); + const started = tracked.filter((v) => v.progress); + + if (loading) { + return ( +
+
+
+ {[1, 2, 3].map((i) => ( +
+ ))} +
+
+ ); + } + + return ( +
+ + ← Sources + + +
+ +

Google Play Books

+
+

+ Progress comes from the bookmarks you leave while reading — Play Books does not expose a + reading position any other way. Bookmark where you stop and it shows up here. +

+ + {error && ( +
+ {error} +
+ )} + +
+ + {tracked.length}{' '} + of {volumes.length} tracked + + + {started.length}{' '} + started + + {saving && Saving…} +
+ +
+ {volumes.map((v, idx) => ( +
+
+ {v.thumbnail ? ( + // eslint-disable-next-line @next/next/no-img-element + + ) : ( +
+ )} + +
+
+
+

{v.title}

+

+ {v.authors.join(', ') || 'Unknown author'} + {v.is_sample && ( + + Sample + + )} +

+
+ + +
+ +
+
+ {positionLabel(v)} + {v.progress?.percent !== null && v.progress?.percent !== undefined && ( + {v.progress.percent}% + )} +
+ + {v.progress?.percent !== null && v.progress?.percent !== undefined ? ( +
+
+
+ ) : null} + + {v.progress && ( +

+ Last read {new Date(v.progress.read_at).toLocaleDateString()} +

+ )} +
+
+
+
+ ))} +
+
+ ); +} diff --git a/apps/web/src/app/(dashboard)/sources/page.tsx b/apps/web/src/app/(dashboard)/sources/page.tsx index e27d0e1..56cea01 100644 --- a/apps/web/src/app/(dashboard)/sources/page.tsx +++ b/apps/web/src/app/(dashboard)/sources/page.tsx @@ -1,8 +1,9 @@ 'use client'; import { useState, useEffect, type ReactNode } from 'react'; +import Link from 'next/link'; import { apiFetch, API_URL } from '../../../lib/api'; -import { GoogleCalendarIcon } from '../../components/source-badge'; +import { GoogleCalendarIcon, GooglePlayBooksIcon } from '../../components/source-badge'; interface Integration { id: string; @@ -16,12 +17,21 @@ interface Integration { // Providers the user can actually connect today. `authorize` is the OAuth route // segment (/v1/integrations/{authorize}/authorize); `provider` matches the stored // integration + event source. -const CONNECTABLE: Array<{ provider: string; authorize: string; name: string; blurb: string; icon: ReactNode }> = [ +const CONNECTABLE: Array<{ + provider: string; + authorize: string; + name: string; + blurb: string; + icon: ReactNode; + /** Route segment under /sources for providers with settings. */ + configure?: string; +}> = [ { provider: 'github', authorize: 'github', name: 'GitHub', blurb: 'Commits, PRs, and code reviews', + configure: 'github', icon: ( @@ -35,44 +45,13 @@ const CONNECTABLE: Array<{ provider: string; authorize: string; name: string; bl blurb: 'Meeting load, focus time, and schedule patterns', icon: , }, -]; - -const UPCOMING_SOURCES = [ - { - name: 'Jira', - description: 'Sprint velocity, ticket cycle time, and backlog health', - icon: ( - - - - ), - }, - { - name: 'Linear', - description: 'Issue throughput, project progress, and team workload', - icon: ( - - - - ), - }, { - name: 'Slack', - description: 'Communication patterns, response time, and focus interruptions', - icon: ( - - - - ), - }, - { - name: 'Notion', - description: 'Documentation activity, knowledge base contributions', - icon: ( - - - - ), + provider: 'google_books', + authorize: 'google-books', + name: 'Google Play Books', + blurb: 'Reading progress, from the bookmarks you leave', + icon: , + configure: 'google-books', }, ]; @@ -161,6 +140,14 @@ export default function Sources() { {integration ? ( <> + {src.configure && ( + + Configure + + )}
); diff --git a/apps/web/src/app/components/delete-account.tsx b/apps/web/src/app/components/delete-account.tsx index e35f616..c811483 100644 --- a/apps/web/src/app/components/delete-account.tsx +++ b/apps/web/src/app/components/delete-account.tsx @@ -47,8 +47,8 @@ export function DeleteAccount() { } return ( -
-

Delete account

+
+

Delete account

Permanently delete your account and all associated data. This cannot be undone.

diff --git a/apps/web/src/app/components/source-badge.tsx b/apps/web/src/app/components/source-badge.tsx index 7623557..408d834 100644 --- a/apps/web/src/app/components/source-badge.tsx +++ b/apps/web/src/app/components/source-badge.tsx @@ -20,6 +20,17 @@ export function GoogleCalendarIcon({ className = 'w-4 h-4' }: { className?: stri ); } +// The official Google Play Books mark (2023). Source viewBox is taller than it is +// wide, so the box is padded horizontally to keep it centred at square sizes. +export function GooglePlayBooksIcon({ className = 'w-4 h-4' }: { className?: string }) { + return ( + + ); +} + // Registry of metric sources (label + icon), shared by the source badge and the // source filter. Add an entry here when a new integration starts producing metrics. export const SOURCE_META: Record = { @@ -27,6 +38,14 @@ export const SOURCE_META: Record = { label: 'Baseline', icon: , }, + // Events Baseline creates itself, currently time entries. Deliberately shares the + // 'Baseline' name with the entry above: `baseline` is the metrics source id and + // `manual` is the events.source value, two spellings for the same origin. They + // never appear in one list — metrics filters on the former, history on the latter. + manual: { + label: 'Baseline', + icon: , + }, github: { label: 'GitHub', icon: ( @@ -39,6 +58,10 @@ export const SOURCE_META: Record = { label: 'Google Calendar', icon: , }, + google_books: { + label: 'Play Books', + icon: , + }, }; export function SourceBadge({ provider }: { provider: string }) { diff --git a/apps/web/src/app/components/source-dropdown.tsx b/apps/web/src/app/components/source-dropdown.tsx index 1a14118..4c3e2ba 100644 --- a/apps/web/src/app/components/source-dropdown.tsx +++ b/apps/web/src/app/components/source-dropdown.tsx @@ -25,7 +25,12 @@ export function SourceDropdown({ value, onChange, sources }: SourceDropdownProps return () => document.removeEventListener('pointerdown', onDoc); }, [open]); - const options = sources.map((s) => ({ id: s, label: SOURCE_META[s]?.label ?? s, icon: SOURCE_META[s]?.icon ?? null as React.ReactNode })); + // 'all' is a filter, not a source, so it has no SOURCE_META entry of its own. + const options = sources.map((s) => ({ + id: s, + label: s === 'all' ? 'All sources' : (SOURCE_META[s]?.label ?? s), + icon: s === 'all' ? null : (SOURCE_META[s]?.icon ?? (null as React.ReactNode)), + })); const current = options.find((o) => o.id === value) ?? options[0]; return ( diff --git a/apps/web/src/lib/api-proxy.ts b/apps/web/src/lib/api-proxy.ts index 83ad830..c7cb7e0 100644 --- a/apps/web/src/lib/api-proxy.ts +++ b/apps/web/src/lib/api-proxy.ts @@ -55,6 +55,10 @@ export async function proxyToApi(request: Request): Promise { headers.delete('host'); headers.delete('connection'); headers.delete('content-length'); // fetch recomputes from the body + // Only this proxy may assert the public origin. Otherwise a client-supplied value + // reaches resolvePublicOrigin() untouched on any path we don't overwrite it for, + // and the OAuth callback redirects to whatever that resolves to. + headers.delete('x-public-origin'); // Preserve the browser-facing origin so the API (behind AUTH_TRUST_HOST) builds // correct absolute URLs for Auth.js and OAuth redirects. @@ -63,10 +67,11 @@ export async function proxyToApi(request: Request): Promise { if (host) { headers.set('x-forwarded-host', host); headers.set('x-forwarded-proto', scheme); - if ( - url.pathname.startsWith('/v1/integrations/github/') || - url.pathname.startsWith('/v1/integrations/google/') - ) { + // Every integration's OAuth dance needs the browser-facing origin to build a + // redirect_uri that matches what is registered. Matched by prefix rather than + // per-provider: an unlisted provider silently falls back to the dev default and + // fails with redirect_uri_mismatch off localhost, which is easy to miss. + if (url.pathname.startsWith('/v1/integrations/')) { headers.set('x-public-origin', `${scheme}://${host}`); } } diff --git a/packages/db/drizzle/0024_integration_settings.sql b/packages/db/drizzle/0024_integration_settings.sql new file mode 100644 index 0000000..9a1dd74 --- /dev/null +++ b/packages/db/drizzle/0024_integration_settings.sql @@ -0,0 +1 @@ +ALTER TABLE "integrations" ADD COLUMN IF NOT EXISTS "settings" jsonb; diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json index 8a2c7a8..3dfb5f4 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -169,6 +169,13 @@ "when": 1785500000000, "tag": "0023_add_todo_position", "breakpoints": true + }, + { + "idx": 24, + "version": "7", + "when": 1786000000000, + "tag": "0024_integration_settings", + "breakpoints": true } ] } diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index 090f4bb..a3356c3 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -95,6 +95,10 @@ export const integrations = pgTable( externalAccountId: text('external_account_id'), connectedAt: timestamp('connected_at', { withTimezone: true }).defaultNow().notNull(), lastSyncedAt: timestamp('last_synced_at', { withTimezone: true }), + // Per-provider configuration, e.g. which Play Books volumes to track. Absent + // means "not configured yet" — providers decide their own default so adding a + // setting never silently changes what an already-connected source collects. + settings: jsonb('settings'), }, (table) => [ uniqueIndex('integrations_user_provider_idx').on(table.userId, table.provider), diff --git a/packages/events/src/index.ts b/packages/events/src/index.ts index c37342e..5b17761 100644 --- a/packages/events/src/index.ts +++ b/packages/events/src/index.ts @@ -3,6 +3,7 @@ export { prPayload, reviewPayload, manualTimeEntryPayload, + bookProgressPayload, EVENT_TYPES, type CommitPayload, type PrPayload, diff --git a/packages/events/src/types.ts b/packages/events/src/types.ts index 0d99e61..81bcfeb 100644 --- a/packages/events/src/types.ts +++ b/packages/events/src/types.ts @@ -52,6 +52,24 @@ export const gcalEventPayload = z.object({ end: z.string().optional(), }); +// A bookmark in Play Books. `percent` is null when the position cannot be mapped to +// a printed page (reflowable text), rather than guessed. +export const bookProgressPayload = z.object({ + category: z.string(), + volume_id: z.string(), + title: z.string(), + authors: z.array(z.string()).optional(), + page_label: z.string(), + page_kind: z.enum(['body', 'front_matter', 'reflowable', 'unknown']), + page_number: z.number().nullable(), + page_count: z.number().nullable(), + percent: z.number().nullable(), + layer: z.string().optional(), + is_sample: z.boolean().optional(), + acquire_method: z.string().optional(), + deleted: z.boolean().optional(), +}); + export type CommitPayload = z.infer; export type PrPayload = z.infer; export type ReviewPayload = z.infer; @@ -64,4 +82,5 @@ export const EVENT_TYPES = { PR_REVIEWED: 'github.pr.reviewed', MANUAL_TIME_ENTRY_CREATED: 'manual.time_entry.created', GCAL_EVENT: 'google_calendar.event', + BOOK_PROGRESS: 'google_books.progress', } as const; diff --git a/packages/integrations/github/src/client.ts b/packages/integrations/github/src/client.ts index 1141cdf..d96ee37 100644 --- a/packages/integrations/github/src/client.ts +++ b/packages/integrations/github/src/client.ts @@ -40,6 +40,8 @@ export async function fetchUserCommits( token: string, username: string, since: Date, + /** Restrict to these `owner/name` repos. Undefined means every contributed repo. */ + trackedRepos?: string[], ): Promise> { // First get repos the user has contributed to recently const repos = await graphql<{ @@ -60,9 +62,11 @@ export async function fetchUserCommits( } }`); - const repoNames = repos.viewer.contributionsCollection.commitContributionsByRepository.map( - (r) => r.repository.nameWithOwner, - ); + // Narrow before the per-repo commit calls below — each one is its own REST + // request, so filtering here is what actually saves work. + const repoNames = repos.viewer.contributionsCollection.commitContributionsByRepository + .map((r) => r.repository.nameWithOwner) + .filter((name) => !trackedRepos || trackedRepos.includes(name)); // Fetch actual commits per repo const commits: Array<{ sha: string; message: string; repo: string; occurred_at: string }> = []; @@ -102,6 +106,8 @@ export async function fetchUserPullRequests( token: string, _username: string, since: Date, + /** Restrict to these `owner/name` repos. Undefined means every repo. */ + trackedRepos?: string[], ): Promise< Array<{ number: number; @@ -157,6 +163,7 @@ export async function fetchUserPullRequests( return data.viewer.contributionsCollection.pullRequestContributions.nodes .filter((n) => n.pullRequest.mergedAt) + .filter((n) => !trackedRepos || trackedRepos.includes(n.pullRequest.repository.nameWithOwner)) .map((n) => ({ number: n.pullRequest.number, title: n.pullRequest.title, @@ -176,6 +183,8 @@ export async function fetchUserReviews( token: string, _username: string, since: Date, + /** Restrict to these `owner/name` repos. Undefined means every repo. */ + trackedRepos?: string[], ): Promise< Array<{ review_id: number; @@ -226,12 +235,101 @@ export async function fetchUserReviews( } }`); - return data.viewer.contributionsCollection.pullRequestReviewContributions.nodes.map((n) => ({ - review_id: n.pullRequestReview.databaseId, - pr_number: n.pullRequestReview.pullRequest.number, - repo: n.pullRequestReview.pullRequest.repository.nameWithOwner, - state: n.pullRequestReview.state, - body: n.pullRequestReview.body || '', - occurred_at: n.pullRequestReview.createdAt, - })); + return data.viewer.contributionsCollection.pullRequestReviewContributions.nodes + .filter( + (n) => + !trackedRepos || + trackedRepos.includes(n.pullRequestReview.pullRequest.repository.nameWithOwner), + ) + .map((n) => ({ + review_id: n.pullRequestReview.databaseId, + pr_number: n.pullRequestReview.pullRequest.number, + repo: n.pullRequestReview.pullRequest.repository.nameWithOwner, + state: n.pullRequestReview.state, + body: n.pullRequestReview.body || '', + occurred_at: n.pullRequestReview.createdAt, + })); +} + +// ── Repositories ─────────────────────────────────────────────────────────── + +export interface GitHubRepo { + nameWithOwner: string; + isPrivate: boolean; + isFork: boolean; + isArchived: boolean; + description: string | null; + primaryLanguage: string | null; + pushedAt: string | null; +} + +/** + * Every repo the user can see, for choosing which ones to track. + * + * affiliations covers repos they own, collaborate on, and reach through an org. + * Private repos are included — the OAuth grant asks for `repo`, which covers them — + * so this list is only as broad as what the user already authorised. + */ +export async function fetchRepositories(token: string): Promise { + const out: GitHubRepo[] = []; + let cursor: string | null = null; + + do { + const data: { + viewer: { + repositories: { + pageInfo: { hasNextPage: boolean; endCursor: string | null }; + nodes: Array<{ + nameWithOwner: string; + isPrivate: boolean; + isFork: boolean; + isArchived: boolean; + description: string | null; + primaryLanguage: { name: string } | null; + pushedAt: string | null; + }>; + }; + }; + } = await graphql( + token, + `query($cursor: String) { + viewer { + repositories( + first: 100 + after: $cursor + affiliations: [OWNER, COLLABORATOR, ORGANIZATION_MEMBER] + orderBy: { field: PUSHED_AT, direction: DESC } + ) { + pageInfo { hasNextPage endCursor } + nodes { + nameWithOwner + isPrivate + isFork + isArchived + description + primaryLanguage { name } + pushedAt + } + } + } + }`, + { cursor }, + ); + + const page = data.viewer.repositories; + for (const n of page.nodes) { + out.push({ + nameWithOwner: n.nameWithOwner, + isPrivate: n.isPrivate, + isFork: n.isFork, + isArchived: n.isArchived, + description: n.description, + primaryLanguage: n.primaryLanguage?.name ?? null, + pushedAt: n.pushedAt, + }); + } + cursor = page.pageInfo.hasNextPage ? page.pageInfo.endCursor : null; + } while (cursor); + + return out; } diff --git a/packages/integrations/github/src/index.ts b/packages/integrations/github/src/index.ts index 8c62ede..9fcea3e 100644 --- a/packages/integrations/github/src/index.ts +++ b/packages/integrations/github/src/index.ts @@ -9,6 +9,8 @@ export { fetchUserCommits, fetchUserPullRequests, fetchUserReviews, + fetchRepositories, + type GitHubRepo, } from './client'; export { diff --git a/packages/integrations/google-books/package.json b/packages/integrations/google-books/package.json new file mode 100644 index 0000000..6500e3d --- /dev/null +++ b/packages/integrations/google-books/package.json @@ -0,0 +1,17 @@ +{ + "name": "@baseline/integrations-google-books", + "version": "0.1.0", + "private": true, + "main": "./src/index.ts", + "types": "./src/index.ts", + "scripts": { + "type-check": "tsc --noEmit", + "lint": "echo 'no lint configured'" + }, + "dependencies": { + "@baseline/events": "workspace:*" + }, + "devDependencies": { + "typescript": "^5" + } +} diff --git a/packages/integrations/google-books/src/client.ts b/packages/integrations/google-books/src/client.ts new file mode 100644 index 0000000..fb78624 --- /dev/null +++ b/packages/integrations/google-books/src/client.ts @@ -0,0 +1,148 @@ +const BOOKS_API = 'https://books.googleapis.com/books/v1'; + +// Play Books progress is not where the documentation suggests it is: +// +// /mylibrary/readingpositions/{id} returns HTTP 501 for every volume, including +// public-domain ones. It is in the discovery document but disabled server-side. +// /mylibrary/bookshelves reports 0 volumes for everything. Play Books +// content never appears there. +// userInfo.readingPosition always null; userInfo.updated tracks +// acquisition, not reading. +// +// What does work is bookmarks, which arrive as annotations carrying a page id and a +// timestamp. That is the only reading signal the API exposes, so it is what we read. + +export interface BooksVolume { + id: string; + title: string; + authors: string[]; + pageCount: number | null; + /** How it entered the library — PURCHASED, SAMPLE, PUBLIC_DOMAIN, … */ + acquireMethod: string; + thumbnail: string | null; +} + +export interface BooksAnnotation { + id: string; + volumeId: string; + layerId: string; + /** e.g. ["PA25"] — see normalizer for what the prefixes mean. */ + pageIds: string[]; + created: string; + updated: string; + deleted: boolean; +} + +// Every way a volume can enter a library. `mybooks` requires an acquireMethod — +// omitting it returns totalItems: 0 rather than everything, which reads as an +// empty library and is easy to misdiagnose. +const ACQUIRE_METHODS = [ + 'PURCHASED', + 'PREORDERED', + 'RENTED', + 'PREVIOUSLY_RENTED', + 'SAMPLE', + 'UPLOADED', + 'PUBLIC_DOMAIN', + 'FAMILY_SHARED', +] as const; + +async function booksGet(token: string, path: string): Promise> { + const res = await fetch(`${BOOKS_API}${path}`, { + headers: { Authorization: `Bearer ${token}` }, + }); + if (res.status === 401) throw new Error('GOOGLE_TOKEN_INVALID'); + if (!res.ok) throw new Error(`Google Books API error: ${res.status} ${await res.text()}`); + return res.json(); +} + +/** Every volume in the user's library, across all acquisition methods. */ +export async function fetchLibrary(token: string): Promise { + const byId = new Map(); + + for (const method of ACQUIRE_METHODS) { + const data = await booksGet( + token, + `/volumes/mybooks?acquireMethod=${method}&maxResults=40`, + ); + for (const item of (data.items as Array>) ?? []) { + const v = item as unknown as { + id: string; + volumeInfo?: { + title?: string; + authors?: string[]; + pageCount?: number; + imageLinks?: { thumbnail?: string }; + }; + }; + if (!v.id || byId.has(v.id)) continue; + byId.set(v.id, { + id: v.id, + title: v.volumeInfo?.title ?? 'Untitled', + authors: v.volumeInfo?.authors ?? [], + pageCount: v.volumeInfo?.pageCount ?? null, + acquireMethod: method, + thumbnail: v.volumeInfo?.imageLinks?.thumbnail ?? null, + }); + } + } + + return [...byId.values()]; +} + +/** + * Bookmarks for one volume, changed since `since`. + * + * volumeId is effectively required — every call without it returns 400, so there + * is no library-wide incremental sync and we poll per volume. + * + * showDeleted is on because deleting a bookmark leaves a tombstone that still + * carries its page and timestamp. Those tombstones are how a reading trail is + * reconstructed when someone moves their bookmark forward as they read. + */ +export async function fetchBookmarks( + token: string, + volumeId: string, + since: Date, +): Promise { + const params = new URLSearchParams({ + volumeId, + updatedMin: since.toISOString(), + showDeleted: 'true', + maxResults: '40', + }); + + const out: BooksAnnotation[] = []; + let pageToken: string | undefined; + + do { + if (pageToken) params.set('pageToken', pageToken); + const data = await booksGet(token, `/mylibrary/annotations?${params.toString()}`); + + for (const item of (data.items as Array>) ?? []) { + const a = item as { + id?: string; + volumeId?: string; + layerId?: string; + pageIds?: string[]; + created?: string; + updated?: string; + deleted?: boolean; + }; + if (!a.id || !a.created) continue; + out.push({ + id: a.id, + volumeId: a.volumeId ?? volumeId, + layerId: a.layerId ?? 'unknown', + pageIds: a.pageIds ?? [], + created: a.created, + updated: a.updated ?? a.created, + deleted: a.deleted ?? false, + }); + } + + pageToken = data.nextPageToken as string | undefined; + } while (pageToken); + + return out; +} diff --git a/packages/integrations/google-books/src/index.ts b/packages/integrations/google-books/src/index.ts new file mode 100644 index 0000000..f42caed --- /dev/null +++ b/packages/integrations/google-books/src/index.ts @@ -0,0 +1,22 @@ +export { + buildAuthorizationUrl, + exchangeCodeForToken, + refreshAccessToken, + fetchGoogleUser, + type GoogleTokenResponse, +} from './oauth'; + +export { + fetchLibrary, + fetchBookmarks, + type BooksVolume, + type BooksAnnotation, +} from './client'; + +export { + normalizeBookmarks, + parsePageId, + progressPercent, + type PageKind, + type ParsedPage, +} from './normalizer'; diff --git a/packages/integrations/google-books/src/normalizer.ts b/packages/integrations/google-books/src/normalizer.ts new file mode 100644 index 0000000..ae5cb4a --- /dev/null +++ b/packages/integrations/google-books/src/normalizer.ts @@ -0,0 +1,114 @@ +import { EVENT_TYPES } from '@baseline/events'; +import type { BooksAnnotation, BooksVolume } from './client'; + +interface EventRow { + userId: string; + source: string; + sourceId: string; + eventType: string; + occurredAt: Date; + payload: Record; +} + +export type PageKind = 'body' | 'front_matter' | 'reflowable' | 'unknown'; + +export interface ParsedPage { + /** The raw label as Google returns it, e.g. "PA25". */ + label: string; + kind: PageKind; + /** The number after the prefix, when there is one. */ + number: number | null; +} + +/** + * Google page ids carry a prefix that decides whether the number means anything. + * + * PA25 a printed body page. Comparable to pageCount, so a percentage is real. + * PR7 Roman-numbered front matter. Sits before page 1, so it reads as 0%, + * NOT 7/pageCount. + * PT64 a position in reflowable text with no printed-page equivalent. Dividing + * it by pageCount invents a number, so we refuse to. + * + * The prefix is not predictable from volume metadata — observed volumes with + * identical readingModes produced PA in one case and PT in another — so this + * parses whatever arrives rather than deciding in advance. + */ +export function parsePageId(pageId: string): ParsedPage { + const match = /^(P[A-Z])(\d+)$/.exec(pageId); + if (!match) return { label: pageId, kind: 'unknown', number: null }; + + const [, prefix, digits] = match; + const number = Number(digits); + const kind: PageKind = + prefix === 'PA' ? 'body' : prefix === 'PR' ? 'front_matter' : prefix === 'PT' ? 'reflowable' : 'unknown'; + + return { label: pageId, kind, number }; +} + +/** + * How far through the book this position is, or null when that cannot be known. + * + * `PA` and `PT` are both page indices into the volume — the only difference is + * whether the page carries a printed number — so both divide into pageCount. + * Front matter precedes page one, so it reads as 0 rather than its own index. + * Clamped at 100 so an index on an unexpected scale saturates rather than + * producing something absurd. + */ +export function progressPercent(page: ParsedPage, pageCount: number | null): number | null { + if (page.kind === 'front_matter') return 0; + if (page.kind !== 'body' && page.kind !== 'reflowable') return null; + if (page.number === null) return null; + if (!pageCount || pageCount <= 0) return null; + return Math.min(100, Math.round((page.number / pageCount) * 1000) / 10); +} + +/** + * Bookmarks become reading events — one per bookmark, at the moment it was placed. + * + * Deleted bookmarks are kept. Moving a bookmark forward deletes the old one, and + * those tombstones are the reading trail: without them you would only ever see the + * latest position instead of the path through the book. + */ +export function normalizeBookmarks( + annotations: BooksAnnotation[], + volume: BooksVolume, + userId: string, +): EventRow[] { + const rows: EventRow[] = []; + + for (const a of annotations) { + // No page id means no position — nothing to record. + const pageId = a.pageIds[0]; + if (!pageId) continue; + + const page = parsePageId(pageId); + const percent = progressPercent(page, volume.pageCount); + + rows.push({ + userId, + source: 'google_books', + sourceId: a.id, + eventType: EVENT_TYPES.BOOK_PROGRESS, + occurredAt: new Date(a.created), + payload: { + category: 'Reading', + volume_id: volume.id, + title: volume.title, + authors: volume.authors, + page_label: page.label, + page_kind: page.kind, + page_number: page.number, + page_count: volume.pageCount, + percent, + layer: a.layerId, + // A sample reports the full book's pageCount while containing an excerpt, + // so its percentage understates how far through the sample you are. + is_sample: volume.acquireMethod === 'SAMPLE', + acquire_method: volume.acquireMethod, + deleted: a.deleted, + }, + }); + } + + return rows; +} diff --git a/packages/integrations/google-books/src/oauth.ts b/packages/integrations/google-books/src/oauth.ts new file mode 100644 index 0000000..14a1fdd --- /dev/null +++ b/packages/integrations/google-books/src/oauth.ts @@ -0,0 +1,99 @@ +const GOOGLE_AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth'; +const GOOGLE_TOKEN_URL = 'https://oauth2.googleapis.com/token'; +const GOOGLE_USERINFO_URL = 'https://www.googleapis.com/oauth2/v2/userinfo'; + +// The Books scope covers the whole library, including writes — Google does not +// publish a read-only variant. We only ever issue GETs. +const SCOPES = [ + 'https://www.googleapis.com/auth/books', + 'openid', + 'email', + 'profile', +]; + +export interface GoogleTokenResponse { + access_token: string; + expires_in: number; + refresh_token?: string; + scope: string; + token_type: string; + id_token?: string; +} + +// include_granted_scopes keeps any Calendar grant intact, so connecting Books +// does not make the user re-consent to a source they already connected. +export function buildAuthorizationUrl(clientId: string, redirectUri: string, state: string): string { + const params = new URLSearchParams({ + client_id: clientId, + redirect_uri: redirectUri, + response_type: 'code', + scope: SCOPES.join(' '), + access_type: 'offline', + prompt: 'consent', + include_granted_scopes: 'true', + state, + }); + return `${GOOGLE_AUTH_URL}?${params.toString()}`; +} + +export async function exchangeCodeForToken( + clientId: string, + clientSecret: string, + code: string, + redirectUri: string, +): Promise { + const res = await fetch(GOOGLE_TOKEN_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + client_id: clientId, + client_secret: clientSecret, + code, + grant_type: 'authorization_code', + redirect_uri: redirectUri, + }).toString(), + }); + + if (!res.ok) { + throw new Error(`Google token exchange failed: ${res.status} ${await res.text()}`); + } + return res.json(); +} + +export async function refreshAccessToken( + clientId: string, + clientSecret: string, + refreshToken: string, +): Promise { + const res = await fetch(GOOGLE_TOKEN_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + client_id: clientId, + client_secret: clientSecret, + grant_type: 'refresh_token', + refresh_token: refreshToken, + }).toString(), + }); + + if (res.status === 400 || res.status === 401) { + throw new Error('GOOGLE_TOKEN_INVALID'); + } + if (!res.ok) { + throw new Error(`Google token refresh failed: ${res.status}`); + } + return res.json(); +} + +export async function fetchGoogleUser( + accessToken: string, +): Promise<{ email: string; name: string | null }> { + const res = await fetch(GOOGLE_USERINFO_URL, { + headers: { Authorization: `Bearer ${accessToken}` }, + }); + if (!res.ok) { + throw new Error(`Google user fetch failed: ${res.status}`); + } + const d = await res.json(); + return { email: d.email, name: d.name ?? null }; +} diff --git a/packages/integrations/google-books/tsconfig.json b/packages/integrations/google-books/tsconfig.json new file mode 100644 index 0000000..e371893 --- /dev/null +++ b/packages/integrations/google-books/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist" + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/metrics/src/commit-activity.ts b/packages/metrics/src/commit-activity.ts index f87cff6..07526ef 100644 --- a/packages/metrics/src/commit-activity.ts +++ b/packages/metrics/src/commit-activity.ts @@ -1,5 +1,6 @@ import type { EventInput } from './types'; import { dayKeyInTz } from './tz'; +import { longestDayStreak } from './streak'; /** * Counts commits in the window. @@ -60,24 +61,5 @@ export function streakDaysV1( } } - if (days.size === 0) return 0; - - const sorted = Array.from(days).sort(); - let maxStreak = 1; - let currentStreak = 1; - - for (let i = 1; i < sorted.length; i++) { - const prev = new Date(sorted[i - 1] + 'T00:00:00Z'); - const curr = new Date(sorted[i] + 'T00:00:00Z'); - const diffDays = (curr.getTime() - prev.getTime()) / (1000 * 60 * 60 * 24); - - if (diffDays === 1) { - currentStreak++; - maxStreak = Math.max(maxStreak, currentStreak); - } else { - currentStreak = 1; - } - } - - return maxStreak; + return longestDayStreak(days); } diff --git a/packages/metrics/src/index.ts b/packages/metrics/src/index.ts index d06d59b..c3d8788 100644 --- a/packages/metrics/src/index.ts +++ b/packages/metrics/src/index.ts @@ -5,6 +5,7 @@ export { throughputTasksV1 } from './throughput'; // Activity metrics export { commitCountV1, activeDaysV1, streakDaysV1 } from './commit-activity'; +export { longestDayStreak } from './streak'; // Pattern metrics export { dayOfWeekDistributionV1, hourOfDayDistributionV1, peakDayV1 } from './patterns'; diff --git a/packages/metrics/src/streak.ts b/packages/metrics/src/streak.ts new file mode 100644 index 0000000..a188db4 --- /dev/null +++ b/packages/metrics/src/streak.ts @@ -0,0 +1,28 @@ +/** + * Longest run of consecutive calendar days in a set of day keys (YYYY-MM-DD). + * + * Takes day keys rather than events so any source can use it — what counts as an + * active day is the caller's business. + */ +export function longestDayStreak(dayKeys: Iterable): number { + const days = Array.from(new Set(dayKeys)).sort(); + if (days.length === 0) return 0; + + let maxStreak = 1; + let currentStreak = 1; + + for (let i = 1; i < days.length; i++) { + const prev = new Date(days[i - 1] + 'T00:00:00Z'); + const curr = new Date(days[i] + 'T00:00:00Z'); + const diffDays = (curr.getTime() - prev.getTime()) / (1000 * 60 * 60 * 24); + + if (diffDays === 1) { + currentStreak++; + maxStreak = Math.max(maxStreak, currentStreak); + } else { + currentStreak = 1; + } + } + + return maxStreak; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 89cb590..38b0aeb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,6 +35,9 @@ importers: '@baseline/integrations-github': specifier: workspace:* version: link:../../packages/integrations/github + '@baseline/integrations-google-books': + specifier: workspace:* + version: link:../../packages/integrations/google-books '@baseline/integrations-google-calendar': specifier: workspace:* version: link:../../packages/integrations/google-calendar @@ -204,6 +207,16 @@ importers: specifier: ^5 version: 5.9.3 + packages/integrations/google-books: + dependencies: + '@baseline/events': + specifier: workspace:* + version: link:../../events + devDependencies: + typescript: + specifier: ^5 + version: 5.9.3 + packages/integrations/google-calendar: dependencies: '@baseline/events': diff --git a/scripts/probe-google-books.mjs b/scripts/probe-google-books.mjs new file mode 100644 index 0000000..fdb119f --- /dev/null +++ b/scripts/probe-google-books.mjs @@ -0,0 +1,89 @@ +#!/usr/bin/env node +/** + * What the Google Books API will and will not tell you about your library. + * + * Findings from running this against a real Play Books account (2026-08-11): + * + * WORKS /volumes/mybooks?acquireMethod=PURCHASED|SAMPLE|PUBLIC_DOMAIN|... + * Returns real Play Books content with title, pageCount, acquiredTime. + * You MUST pass acquireMethod — omitting it returns totalItems: 0. + * + * WORKS /mylibrary/annotations?volumeId=... (highlights and bookmarks) + * + * EMPTY /mylibrary/bookshelves — every built-in shelf reports 0 volumes. + * Play Books content does not appear here. This is the trap: it looks + * like an empty account rather than the wrong endpoint. + * + * DEAD /mylibrary/readingpositions/{volumeId} -> HTTP 501 + * "Operation is not implemented, or supported, or enabled." + * Returned for every volume including public-domain ones, so it is + * switched off globally, not missing data. Still in the discovery + * document, which is why the docs suggest it should work. + * + * Conclusion: reading POSITION is unavailable. Library inventory is available. + * + * GOOGLE_BOOKS_TOKEN=ya29.... node scripts/probe-google-books.mjs + */ + +const TOKEN = process.env.GOOGLE_BOOKS_TOKEN; +if (!TOKEN) { + console.error('set GOOGLE_BOOKS_TOKEN (scope https://www.googleapis.com/auth/books)'); + process.exit(1); +} + +const API = 'https://books.googleapis.com/books/v1'; + +async function call(path) { + const res = await fetch(`${API}${path}`, { headers: { Authorization: `Bearer ${TOKEN}` } }); + const text = await res.text(); + let body = null; + try { body = JSON.parse(text); } catch { /* non-JSON error page */ } + return { status: res.status, body }; +} + +const ACQUIRE = ['PURCHASED', 'PREORDERED', 'RENTED', 'PREVIOUSLY_RENTED', + 'SAMPLE', 'UPLOADED', 'PUBLIC_DOMAIN', 'FAMILY_SHARED']; + +console.log('\n=== library, via /volumes/mybooks ==='); +const volumes = []; +for (const method of ACQUIRE) { + const { body } = await call(`/volumes/mybooks?acquireMethod=${method}&maxResults=40`); + const items = body?.items ?? []; + if (!items.length) continue; + console.log(`\n ${method} (${body.totalItems})`); + for (const v of items) { + const info = v.volumeInfo ?? {}; + const user = v.userInfo ?? {}; + volumes.push({ id: v.id, title: info.title, method }); + console.log( + ` ${(info.title ?? '?').slice(0, 52).padEnd(54)}` + + `pages=${String(info.pageCount ?? '?').padStart(5)} ` + + `acquired=${(user.acquiredTime ?? '—').slice(0, 10)}` + ); + } +} +if (!volumes.length) { + console.log(' nothing found — check the token belongs to the account holding your books'); +} + +console.log('\n=== reading position (the thing we actually wanted) ==='); +for (const v of volumes.slice(0, 3)) { + const { status, body } = await call(`/mylibrary/readingpositions/${v.id}`); + const note = status === 501 ? 'NOT IMPLEMENTED — endpoint is disabled server-side' + : status === 200 ? JSON.stringify(body) + : body?.error?.message ?? ''; + console.log(` ${status} ${(v.title ?? v.id).slice(0, 40).padEnd(42)} ${note}`); +} + +console.log('\n=== annotations (highlights/bookmarks — a proxy for reading activity) ==='); +let annotations = 0; +for (const v of volumes.slice(0, 5)) { + const { status, body } = await call(`/mylibrary/annotations?volumeId=${v.id}`); + const n = body?.totalItems ?? 0; + annotations += n; + console.log(` ${status} ${(v.title ?? v.id).slice(0, 40).padEnd(42)} ${n} annotation(s)`); +} + +console.log('\n=== verdict ==='); +console.log(` ${volumes.length} volumes readable. Reading position: unavailable (501).`); +console.log(` ${annotations} annotations found — usable as an activity signal only if you highlight as you read.`);