From b170333f92f91c2527c2ec61323a15759ac36568 Mon Sep 17 00:00:00 2001 From: default Date: Tue, 11 Aug 2026 09:11:32 +0000 Subject: [PATCH 01/15] Add a Google Books API probe Establishes empirically what the API will give us before we build against it. Reading position is unavailable: /mylibrary/readingpositions/{id} returns HTTP 501 'not implemented' for every volume, including public-domain ones, so it is disabled server-side rather than missing data. It is still in the discovery document, which is why the docs imply it works. What does work: /volumes/mybooks?acquireMethod=... returns real Play Books content (bookshelves returns zero for everything and is a red herring), and mylibrary/annotations returns bookmarks and highlights carrying pageIds plus a created timestamp - enough to derive dated progress against pageCount. --- scripts/probe-google-books.mjs | 89 ++++++++++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 scripts/probe-google-books.mjs 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.`); From 80e7f1f48224bf06392c77e2b2ca0213c7c90c61 Mon Sep 17 00:00:00 2001 From: default Date: Tue, 11 Aug 2026 09:54:19 +0000 Subject: [PATCH 02/15] Add Google Play Books as a source Tracks reading progress from the bookmarks you leave in Play Books. Progress is not where the API documentation suggests. /mylibrary/readingpositions returns HTTP 501 for every volume including public-domain ones, so it is disabled server-side rather than empty; userInfo.readingPosition is always null and userInfo.updated tracks acquisition, not reading; and mylibrary/bookshelves reports zero volumes because Play Books content never lands there. Bookmarks are the only reading signal the API exposes, and they carry a page id and a timestamp. Two constraints worth knowing: Page ids are prefixed and the prefix decides whether the number means anything. PA25 is a printed body page and divides into pageCount. PR7 is Roman front matter and reads as 0%, not 7/76. PT64 is a position in reflowable text with no printed equivalent, so we return null rather than inventing a percentage. The prefix is not predictable from volume metadata - volumes with identical readingModes produced PA in one case and PT in another - so the parser handles whatever arrives. The annotations endpoint rejects any request without a volumeId, so there is no library-wide incremental sync and we poll per volume. Deleted bookmarks are kept: moving a bookmark forward deletes the old one, and those tombstones are what make a reading trail reconstructable instead of only ever seeing the latest position. Also adds scripts/probe-google-books.mjs, which is how the above was established. --- .env.example | 6 +- .../google-books/authorize/route.ts | 33 ++++ .../google-books/callback/route.ts | 80 ++++++++++ apps/api/app/v1/integrations/route.ts | 2 +- apps/api/lib/ingestion.ts | 73 ++++++++- apps/api/package.json | 1 + apps/web/src/app/(dashboard)/sources/page.tsx | 9 +- apps/web/src/app/components/source-badge.tsx | 15 ++ packages/events/src/index.ts | 1 + packages/events/src/types.ts | 19 +++ .../integrations/google-books/package.json | 17 ++ .../integrations/google-books/src/client.ts | 148 ++++++++++++++++++ .../integrations/google-books/src/index.ts | 22 +++ .../google-books/src/normalizer.ts | 111 +++++++++++++ .../integrations/google-books/src/oauth.ts | 99 ++++++++++++ .../integrations/google-books/tsconfig.json | 8 + pnpm-lock.yaml | 13 ++ 17 files changed, 652 insertions(+), 5 deletions(-) create mode 100644 apps/api/app/v1/integrations/google-books/authorize/route.ts create mode 100644 apps/api/app/v1/integrations/google-books/callback/route.ts create mode 100644 packages/integrations/google-books/package.json create mode 100644 packages/integrations/google-books/src/client.ts create mode 100644 packages/integrations/google-books/src/index.ts create mode 100644 packages/integrations/google-books/src/normalizer.ts create mode 100644 packages/integrations/google-books/src/oauth.ts create mode 100644 packages/integrations/google-books/tsconfig.json 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/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/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/lib/ingestion.ts b/apps/api/lib/ingestion.ts index dd1db34..13d0829 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'); } @@ -114,7 +123,8 @@ 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. +// 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. async function ensureGoogleAccessToken(integration: Integration): Promise { const clientId = process.env.GOOGLE_CLIENT_ID; const clientSecret = process.env.GOOGLE_CLIENT_SECRET; @@ -197,6 +207,67 @@ 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); + + // 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. + const rows = []; + for (const volume of library) { + const bookmarks = await fetchBookmarks(token, volume.id, since); + rows.push(...normalizeBookmarks(bookmarks, volume, integration.userId)); + } + + for (let i = 0; i < rows.length; i += 100) { + await db.insert(events).values(rows.slice(i, i + 100)).onConflictDoNothing(); + } + + 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: 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/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)/sources/page.tsx b/apps/web/src/app/(dashboard)/sources/page.tsx index e27d0e1..4263e4f 100644 --- a/apps/web/src/app/(dashboard)/sources/page.tsx +++ b/apps/web/src/app/(dashboard)/sources/page.tsx @@ -2,7 +2,7 @@ import { useState, useEffect, type ReactNode } from 'react'; import { apiFetch, API_URL } from '../../../lib/api'; -import { GoogleCalendarIcon } from '../../components/source-badge'; +import { GoogleCalendarIcon, GooglePlayBooksIcon } from '../../components/source-badge'; interface Integration { id: string; @@ -35,6 +35,13 @@ const CONNECTABLE: Array<{ provider: string; authorize: string; name: string; bl blurb: 'Meeting load, focus time, and schedule patterns', icon: , }, + { + provider: 'google_books', + authorize: 'google-books', + name: 'Google Play Books', + blurb: 'Reading progress, from the bookmarks you leave', + icon: , + }, ]; const UPCOMING_SOURCES = [ diff --git a/apps/web/src/app/components/source-badge.tsx b/apps/web/src/app/components/source-badge.tsx index 7623557..d5161e3 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 = { @@ -39,6 +50,10 @@ export const SOURCE_META: Record = { label: 'Google Calendar', icon: , }, + google_books: { + label: 'Google Play Books', + icon: , + }, }; export function SourceBadge({ provider }: { provider: string }) { 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/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..50422f3 --- /dev/null +++ b/packages/integrations/google-books/src/normalizer.ts @@ -0,0 +1,111 @@ +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. + * + * Only body pages are measurable. Front matter is 0 by definition. Reflowable and + * unknown positions return null so the UI shows a position instead of a number + * that looks authoritative and isn't. + */ +export function progressPercent(page: ParsedPage, pageCount: number | null): number | null { + if (page.kind === 'front_matter') return 0; + if (page.kind !== 'body' || 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/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': From f967fb52568e1eb04a26190569a53ada7d5d04f5 Mon Sep 17 00:00:00 2001 From: default Date: Thu, 13 Aug 2026 01:49:14 +0000 Subject: [PATCH 03/15] Send the browser's origin for every integration callback The proxy only injected x-public-origin for two hardcoded provider paths, so /v1/integrations/google-books/ fell through to resolvePublicOrigin's dev default and sent http://localhost:3002 as the redirect_uri regardless of where the browser actually was. Off localhost that fails as redirect_uri_mismatch, or worse succeeds and bounces the browser to an address it cannot reach. Matching on the /v1/integrations/ prefix means the next provider does not repeat it. Also strips any inbound x-public-origin. Previously a client could set it on any path the proxy did not overwrite it for, and origin.ts trusts that header - which the OAuth callback then redirects to. --- apps/web/src/lib/api-proxy.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) 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}`); } } From 72ce0dbed678d8525cf36e1402623ffd26130272 Mon Sep 17 00:00:00 2001 From: default Date: Thu, 13 Aug 2026 02:15:17 +0000 Subject: [PATCH 04/15] History: render reading and calendar events Both eventMeta and summary switch on event_type with no case for google_books.progress or google_calendar.event, so reading events showed as a bare 'progress' with an empty summary and all calendar events as 'event'. Reading rows now carry the book, the page and - only for print-anchored positions - a percentage; reflowable positions show a position instead of a fabricated percent, and front matter keeps its page number so consecutive bookmarks are distinguishable rather than looking like duplicates. Deleted bookmarks are marked rather than hidden. A removed bookmark is still a position that was read to, usually meaning the bookmark moved on, so the row says so instead of disappearing or implying it still exists. Adds Calendar and Reading to the source filter, which only offered All, Time entries and GitHub. The events endpoint matches source as a plain column value, so no API change was needed. --- .../app/v1/integrations/debug-origin/route.ts | 40 +++++++++++++++++++ apps/web/src/app/(dashboard)/history/page.tsx | 40 ++++++++++++++++++- 2 files changed, 79 insertions(+), 1 deletion(-) create mode 100644 apps/api/app/v1/integrations/debug-origin/route.ts diff --git a/apps/api/app/v1/integrations/debug-origin/route.ts b/apps/api/app/v1/integrations/debug-origin/route.ts new file mode 100644 index 0000000..9cfe9cd --- /dev/null +++ b/apps/api/app/v1/integrations/debug-origin/route.ts @@ -0,0 +1,40 @@ +import { NextResponse } from 'next/server'; +import { headers } from 'next/headers'; +import { resolvePublicOrigin } from '../../../../lib/origin'; + +/** + * TEMPORARY — diagnosing redirect_uri_mismatch. Delete once OAuth connects. + * + * Reports the origin this API resolves for OAuth redirects, which is the value + * that has to match the OAuth client's registered redirect URIs exactly. Sits + * under /v1/integrations/ deliberately: that is the prefix the web proxy injects + * x-public-origin for, so this exercises the same path the real flow does. + * + * Unauthenticated, and returns nothing the caller's own browser doesn't already + * know — but it should not outlive the bug. + */ +export async function GET() { + const h = await headers(); + const origin = await resolvePublicOrigin(); + + return NextResponse.json({ + resolved_origin: origin, + register_these_exactly: [ + `${origin}/v1/integrations/google-books/callback`, + `${origin}/v1/integrations/google/callback`, + `${origin}/v1/integrations/github/callback`, + ], + how_it_was_resolved: process.env.WEB_URL + ? 'WEB_URL env var' + : h.get('x-public-origin') + ? 'x-public-origin injected by the web proxy' + : 'dev fallback (http://localhost:3002) — the proxy did not inject a header', + seen_headers: { + 'x-public-origin': h.get('x-public-origin'), + 'x-forwarded-host': h.get('x-forwarded-host'), + 'x-forwarded-proto': h.get('x-forwarded-proto'), + host: h.get('host'), + }, + web_url_env: process.env.WEB_URL ?? null, + }); +} diff --git a/apps/web/src/app/(dashboard)/history/page.tsx b/apps/web/src/app/(dashboard)/history/page.tsx index 347d4d8..8c95e14 100644 --- a/apps/web/src/app/(dashboard)/history/page.tsx +++ b/apps/web/src/app/(dashboard)/history/page.tsx @@ -19,11 +19,13 @@ interface EventsResponse { next_cursor: string | null; } -type SourceFilter = 'all' | 'manual' | 'github'; +type SourceFilter = 'all' | 'manual' | 'github' | 'google_calendar' | 'google_books'; const FILTERS: { key: SourceFilter; label: string }[] = [ { key: 'all', label: 'All' }, { key: 'manual', label: 'Time entries' }, { key: 'github', label: 'GitHub' }, + { key: 'google_calendar', label: 'Calendar' }, + { key: 'google_books', label: 'Reading' }, ]; function eventMeta(type: string): { label: string; icon: string; color: string } { @@ -36,11 +38,43 @@ 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' : ''; + + if (kind === 'body' && n !== null) { + const of = total ? ` of ${total}` : ''; + const share = pct !== null ? ` · ${pct}%` : ''; + return `${title} — page ${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 ''; } From 8c15c444bba0bb3b5b2e3d9b238a36773cecbd0c Mon Sep 17 00:00:00 2001 From: default Date: Thu, 13 Aug 2026 02:16:47 +0000 Subject: [PATCH 05/15] Remove the debug-origin endpoint Diagnostic for the redirect_uri_mismatch during Play Books setup. OAuth connects now, so it goes: it was unauthenticated by intent and reported request headers, which has no place in a shipped API. --- .../app/v1/integrations/debug-origin/route.ts | 40 ------------------- 1 file changed, 40 deletions(-) delete mode 100644 apps/api/app/v1/integrations/debug-origin/route.ts diff --git a/apps/api/app/v1/integrations/debug-origin/route.ts b/apps/api/app/v1/integrations/debug-origin/route.ts deleted file mode 100644 index 9cfe9cd..0000000 --- a/apps/api/app/v1/integrations/debug-origin/route.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { NextResponse } from 'next/server'; -import { headers } from 'next/headers'; -import { resolvePublicOrigin } from '../../../../lib/origin'; - -/** - * TEMPORARY — diagnosing redirect_uri_mismatch. Delete once OAuth connects. - * - * Reports the origin this API resolves for OAuth redirects, which is the value - * that has to match the OAuth client's registered redirect URIs exactly. Sits - * under /v1/integrations/ deliberately: that is the prefix the web proxy injects - * x-public-origin for, so this exercises the same path the real flow does. - * - * Unauthenticated, and returns nothing the caller's own browser doesn't already - * know — but it should not outlive the bug. - */ -export async function GET() { - const h = await headers(); - const origin = await resolvePublicOrigin(); - - return NextResponse.json({ - resolved_origin: origin, - register_these_exactly: [ - `${origin}/v1/integrations/google-books/callback`, - `${origin}/v1/integrations/google/callback`, - `${origin}/v1/integrations/github/callback`, - ], - how_it_was_resolved: process.env.WEB_URL - ? 'WEB_URL env var' - : h.get('x-public-origin') - ? 'x-public-origin injected by the web proxy' - : 'dev fallback (http://localhost:3002) — the proxy did not inject a header', - seen_headers: { - 'x-public-origin': h.get('x-public-origin'), - 'x-forwarded-host': h.get('x-forwarded-host'), - 'x-forwarded-proto': h.get('x-forwarded-proto'), - host: h.get('host'), - }, - web_url_env: process.env.WEB_URL ?? null, - }); -} From 6b28809a7c95037b347cd72103d451aa9b312da4 Mon Sep 17 00:00:00 2001 From: default Date: Thu, 13 Aug 2026 02:28:29 +0000 Subject: [PATCH 06/15] Configure pages for GitHub and Play Books Connected sources now expose settings, backed by a settings jsonb column on integrations. Absent settings means everything, so adding a setting never silently narrows what an already-connected source collects. GitHub: pick repositories, public or private - the grant already asks for repo scope, so private ones were always visible, just not selectable. Filtering happens before the per-repo commit calls, each of which is its own REST request, so a narrow selection cuts real work rather than just rows. Search and public/private filters with a bulk toggle over what is on screen, since selecting a handful out of many is the common case. Play Books: pick books and see current position per book, taken from the latest non-deleted bookmark. Progress bars appear only where a percentage is real - reflowable books say why they have none instead of showing a misleading bar. Also removes the coming-soon placeholder sources. The migration is hand-written: drizzle-kit generate cannot run here because the meta snapshots stop at 0019 while migrations run to 0023, so it diffs against a stale snapshot and prompts. Recent migrations in this repo are hand-authored anyway. --- .../app/v1/integrations/github/repos/route.ts | 114 +++++++++ .../google-books/volumes/route.ts | 128 +++++++++++ apps/api/lib/ingestion.ts | 25 +- .../app/(dashboard)/sources/github/page.tsx | 216 ++++++++++++++++++ .../(dashboard)/sources/google-books/page.tsx | 213 +++++++++++++++++ apps/web/src/app/(dashboard)/sources/page.tsx | 85 ++----- .../db/drizzle/0024_integration_settings.sql | 1 + packages/db/drizzle/meta/_journal.json | 7 + packages/db/src/schema.ts | 4 + packages/integrations/github/src/client.ts | 120 +++++++++- packages/integrations/github/src/index.ts | 2 + 11 files changed, 833 insertions(+), 82 deletions(-) create mode 100644 apps/api/app/v1/integrations/github/repos/route.ts create mode 100644 apps/api/app/v1/integrations/google-books/volumes/route.ts create mode 100644 apps/web/src/app/(dashboard)/sources/github/page.tsx create mode 100644 apps/web/src/app/(dashboard)/sources/google-books/page.tsx create mode 100644 packages/db/drizzle/0024_integration_settings.sql 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/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/lib/ingestion.ts b/apps/api/lib/ingestion.ts index 13d0829..89fedbf 100644 --- a/apps/api/lib/ingestion.ts +++ b/apps/api/lib/ingestion.ts @@ -61,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 = [ @@ -125,7 +130,7 @@ 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. Shared by every Google source — // the refresh exchange is the same regardless of which scopes the grant carries. -async function ensureGoogleAccessToken(integration: Integration): Promise { +export async function ensureGoogleAccessToken(integration: Integration): Promise { const clientId = process.env.GOOGLE_CLIENT_ID; const clientSecret = process.env.GOOGLE_CLIENT_SECRET; if (!clientId || !clientSecret) { @@ -221,10 +226,17 @@ async function syncGoogleBooks(integration: Integration): Promise { 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 library) { + for (const volume of tracked) { const bookmarks = await fetchBookmarks(token, volume.id, since); rows.push(...normalizeBookmarks(bookmarks, volume, integration.userId)); } @@ -243,7 +255,8 @@ async function syncGoogleBooks(integration: Integration): Promise { msg: 'sync_complete', integration_id: integration.id, provider: 'google_books', - volumes: library.length, + volumes: tracked.length, + volumes_in_library: library.length, events: rows.length, duration_ms: Date.now() - startTime, }), 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..a8b4048 --- /dev/null +++ b/apps/web/src/app/(dashboard)/sources/google-books/page.tsx @@ -0,0 +1,213 @@ +'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; +} + +// Only printed pages divide into a page count. Reflowable positions have no printed +// equivalent, so there is no honest percentage to show for them. +function positionLabel(v: Volume): string { + const p = v.progress; + if (!p) return 'No bookmark yet'; + if (p.page_kind === 'body' && p.page_number !== null) { + return v.page_count ? `Page ${p.page_number} of ${v.page_count}` : `Page ${p.page_number}`; + } + if (p.page_kind === 'front_matter') return 'In the front matter'; + if (p.page_number !== null) return `Position ${p.page_number}`; + return p.page_label; +} + +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}% + )} +
+ + {/* A bar only where a percentage is real. Reflowable positions get the + label above and nothing here, rather than a misleading bar. */} + {v.progress?.percent !== null && v.progress?.percent !== undefined ? ( +
+
+
+ ) : v.progress ? ( +

+ This book is reflowable, so there is no page count to measure against. +

+ ) : 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 4263e4f..56cea01 100644 --- a/apps/web/src/app/(dashboard)/sources/page.tsx +++ b/apps/web/src/app/(dashboard)/sources/page.tsx @@ -1,6 +1,7 @@ 'use client'; import { useState, useEffect, type ReactNode } from 'react'; +import Link from 'next/link'; import { apiFetch, API_URL } from '../../../lib/api'; import { GoogleCalendarIcon, GooglePlayBooksIcon } from '../../components/source-badge'; @@ -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: ( @@ -41,45 +51,7 @@ const CONNECTABLE: Array<{ provider: string; authorize: string; name: string; bl name: 'Google Play Books', blurb: 'Reading progress, from the bookmarks you leave', 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: ( - - - - ), + configure: 'google-books', }, ]; @@ -168,6 +140,14 @@ export default function Sources() { {integration ? ( <> + {src.configure && ( + + Configure + + )}
); 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/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 { From ede7d69b8c90d52ecec7874a6ca0e52c5a711dd8 Mon Sep 17 00:00:00 2001 From: default Date: Thu, 13 Aug 2026 02:33:24 +0000 Subject: [PATCH 07/15] Show progress for unnumbered pages too PT positions were being suppressed on the grounds that they had no printed-page equivalent. That was wrong: PA and PT are both page indices into the volume and the only difference is whether the page carries a printed number, so both divide into pageCount. Observed values bear this out - PT 45 to 65 against a 461-page volume, comfortably inside the range. Clamped at 100 so an index on an unexpected scale saturates rather than producing nonsense. Labels distinguish the two: 'page 6 of 76' for a printed page, 'position 47 of 461' for an unnumbered one, so neither claims a page number the book does not have. Existing reflowable rows were backfilled, since percent was stored at write time. --- apps/web/src/app/(dashboard)/history/page.tsx | 7 ++++-- .../(dashboard)/sources/google-books/page.tsx | 22 ++++++++----------- .../google-books/src/normalizer.ts | 11 ++++++---- 3 files changed, 21 insertions(+), 19 deletions(-) diff --git a/apps/web/src/app/(dashboard)/history/page.tsx b/apps/web/src/app/(dashboard)/history/page.tsx index 8c95e14..434265b 100644 --- a/apps/web/src/app/(dashboard)/history/page.tsx +++ b/apps/web/src/app/(dashboard)/history/page.tsx @@ -61,10 +61,13 @@ function bookProgressSummary(p: Record): string { // bookmark is still there. const removed = p.deleted === true ? ' · bookmark removed' : ''; - if (kind === 'body' && n !== null) { + // 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} — page ${n}${of}${share}${removed}`; + return `${title} — ${noun} ${n}${of}${share}${removed}`; } // Keep the number: without it consecutive front-matter bookmarks render // identically and the trail looks like duplicates. diff --git a/apps/web/src/app/(dashboard)/sources/google-books/page.tsx b/apps/web/src/app/(dashboard)/sources/google-books/page.tsx index a8b4048..677586b 100644 --- a/apps/web/src/app/(dashboard)/sources/google-books/page.tsx +++ b/apps/web/src/app/(dashboard)/sources/google-books/page.tsx @@ -28,17 +28,19 @@ interface VolumesResponse { last_synced_at: string | null; } -// Only printed pages divide into a page count. Reflowable positions have no printed -// equivalent, so there is no honest percentage to show for them. +// `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 === 'body' && p.page_number !== null) { - return v.page_count ? `Page ${p.page_number} of ${v.page_count}` : `Page ${p.page_number}`; - } if (p.page_kind === 'front_matter') return 'In the front matter'; - if (p.page_number !== null) return `Position ${p.page_number}`; - return p.page_label; + 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() { @@ -182,8 +184,6 @@ export default function GoogleBooksConfig() { )}
- {/* A bar only where a percentage is real. Reflowable positions get the - label above and nothing here, rather than a misleading bar. */} {v.progress?.percent !== null && v.progress?.percent !== undefined ? (
- ) : v.progress ? ( -

- This book is reflowable, so there is no page count to measure against. -

) : null} {v.progress && ( diff --git a/packages/integrations/google-books/src/normalizer.ts b/packages/integrations/google-books/src/normalizer.ts index 50422f3..ae5cb4a 100644 --- a/packages/integrations/google-books/src/normalizer.ts +++ b/packages/integrations/google-books/src/normalizer.ts @@ -48,13 +48,16 @@ export function parsePageId(pageId: string): ParsedPage { /** * How far through the book this position is, or null when that cannot be known. * - * Only body pages are measurable. Front matter is 0 by definition. Reflowable and - * unknown positions return null so the UI shows a position instead of a number - * that looks authoritative and isn't. + * `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.number === null) return null; + 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); } From d4e1a1f28a9e777c6787142210dd79febd1f9fb9 Mon Sep 17 00:00:00 2001 From: default Date: Tue, 18 Aug 2026 04:49:08 +0000 Subject: [PATCH 08/15] Reading metrics from bookmark positions Play Books never reports pages read, but consecutive bookmarks in a book do: PA5 followed by PA6 is one page, PT45 to PT49 is four. Each advance is credited to the later bookmark, since that is when the reading had happened by. Four metrics, following the calendar source's shape: pages advanced, progress gained in percentage points, books read, and reading days. What deliberately scores nothing: - the first bookmark in a book, because we do not know where you started and assuming page one would invent a chunk of reading - steps between different numbering schemes, since Roman front matter and Arabic body pages are separate scales - backwards steps, which clamp to zero - re-reading is not un-reading Steps are computed over the whole trail rather than the reporting window, or a week continuing a book already in progress would score zero for its first session. Deleted bookmarks still count: a removed one marks a position that was reached, and dropping them would erase most of the trail. Verified against real data: 12 bookmarks reduce to 6 steps, 8 pages, 2.3%. Also fixes bookmark deletions never propagating - onConflictDoNothing meant a re-synced annotation kept whatever we first saw, so the deleted flag never flipped - and matches the delete-account card to its siblings, keeping the red outline on the button rather than the whole panel. --- .../app/v1/metrics/books/overview/route.ts | 54 ++++++++ .../app/v1/metrics/books/timeseries/route.ts | 67 ++++++++++ apps/api/lib/book-progress.ts | 119 ++++++++++++++++++ apps/api/lib/ingestion.ts | 13 +- apps/web/src/app/(dashboard)/metrics/page.tsx | 10 +- .../web/src/app/components/delete-account.tsx | 4 +- 6 files changed, 263 insertions(+), 4 deletions(-) create mode 100644 apps/api/app/v1/metrics/books/overview/route.ts create mode 100644 apps/api/app/v1/metrics/books/timeseries/route.ts create mode 100644 apps/api/lib/book-progress.ts 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..b00b03f --- /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 } 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 round1 = (n: number) => Math.round(n * 10) / 10; +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); +const percent = (s: ProgressStep[]) => s.reduce((a, v) => a + v.percent, 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 readingDays = (m: typeof marks) => new Set(m.map((v) => dayKeyInTz(v.at, tz))).size; + 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'), + progress_gained: mk(round1(percent(currSteps)), round1(percent(prevSteps)), '%'), + books_read: mk(booksRead(currMarks), booksRead(prevMarks), 'books'), + reading_days: mk(readingDays(currMarks), readingDays(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..2be7f26 --- /dev/null +++ b/apps/api/app/v1/metrics/books/timeseries/route.ts @@ -0,0 +1,67 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { dayKeyInTz } 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', 'progress_gained', 'books_read', 'reading_days']; +const round1 = (n: number) => Math.round(n * 10) / 10; + +// 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 'progress_gained': + return round1(s.reduce((a, v) => a + v.percent, 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; + 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..7634acc --- /dev/null +++ b/apps/api/lib/book-progress.ts @@ -0,0 +1,119 @@ +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; + /** Percentage points gained since the previous bookmark in this book. */ + percent: number; +} + +interface Bookmark { + at: Date; + volumeId: string; + kind: string; + number: number | null; + percent: 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; + + const pages = + prev.number !== null && curr.number !== null ? Math.max(0, curr.number - prev.number) : 0; + const percent = + prev.percent !== null && curr.percent !== null ? Math.max(0, curr.percent - prev.percent) : 0; + if (pages === 0 && percent === 0) continue; + + steps.push({ at: curr.at, volumeId, pages, percent: Math.round(percent * 10) / 10 }); + } + } + + 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, + percent: (p.percent ?? 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 89fedbf..39e25a1 100644 --- a/apps/api/lib/ingestion.ts +++ b/apps/api/lib/ingestion.ts @@ -241,8 +241,19 @@ async function syncGoogleBooks(integration: Integration): Promise { 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)).onConflictDoNothing(); + 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 diff --git a/apps/web/src/app/(dashboard)/metrics/page.tsx b/apps/web/src/app/(dashboard)/metrics/page.tsx index 74d5c85..f8299ec 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: 'progress_gained', metric: 'progress_gained', label: 'Progress Gained', unit: '%', source: 'google_books', suffix: '%' }, + { 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: '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) => 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.

From 593419ccc5ea01d313fe095155a0d6f6a81fd39b Mon Sep 17 00:00:00 2001 From: default Date: Tue, 18 Aug 2026 04:53:53 +0000 Subject: [PATCH 09/15] Drop Progress Gained, keep Pages Advanced The percentage was pages advanced over the book's page count - the same number scaled - so it added a tile without adding information, and summing percentages across books of different lengths made a 461-page study guide and a 76-page paper count the same. Pages are the honest aggregate. Removes the percent field from ProgressStep rather than leaving it computed and unused. Behaviour is unchanged: percent derives from the page number, so the two were always zero or non-zero together. Verified: still 6 steps, 8 pages. --- apps/api/app/v1/metrics/books/overview/route.ts | 3 --- apps/api/app/v1/metrics/books/timeseries/route.ts | 5 +---- apps/api/lib/book-progress.ts | 12 ++++-------- apps/web/src/app/(dashboard)/metrics/page.tsx | 1 - 4 files changed, 5 insertions(+), 16 deletions(-) diff --git a/apps/api/app/v1/metrics/books/overview/route.ts b/apps/api/app/v1/metrics/books/overview/route.ts index b00b03f..2238c0a 100644 --- a/apps/api/app/v1/metrics/books/overview/route.ts +++ b/apps/api/app/v1/metrics/books/overview/route.ts @@ -4,10 +4,8 @@ import { getCurrentUserId, getUserTimezone } from '../../../../../lib/user'; import { periodBounds, isPeriod, offsetNow, parseOffset } from '../../../../../lib/period'; import { loadProgressSteps, loadBookmarkEvents, type ProgressStep } from '../../../../../lib/book-progress'; -const round1 = (n: number) => Math.round(n * 10) / 10; 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); -const percent = (s: ProgressStep[]) => s.reduce((a, v) => a + v.percent, 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. @@ -46,7 +44,6 @@ export async function GET(request: NextRequest) { period: periodParam, metrics: { pages_advanced: mk(pages(currSteps), pages(prevSteps), 'pages'), - progress_gained: mk(round1(percent(currSteps)), round1(percent(prevSteps)), '%'), books_read: mk(booksRead(currMarks), booksRead(prevMarks), 'books'), reading_days: mk(readingDays(currMarks), readingDays(prevMarks), 'days'), }, diff --git a/apps/api/app/v1/metrics/books/timeseries/route.ts b/apps/api/app/v1/metrics/books/timeseries/route.ts index 2be7f26..00250f9 100644 --- a/apps/api/app/v1/metrics/books/timeseries/route.ts +++ b/apps/api/app/v1/metrics/books/timeseries/route.ts @@ -4,8 +4,7 @@ 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', 'progress_gained', 'books_read', 'reading_days']; -const round1 = (n: number) => Math.round(n * 10) / 10; +const METRICS = ['pages_advanced', 'books_read', 'reading_days']; // GET /v1/metrics/books/timeseries?metric=&period= — the metric per natural bucket // across the period, for the bar chart. @@ -41,8 +40,6 @@ export async function GET(request: NextRequest) { switch (mk) { case 'pages_advanced': return s.reduce((a, v) => a + v.pages, 0); - case 'progress_gained': - return round1(s.reduce((a, v) => a + v.percent, 0)); case 'books_read': return new Set(m.map((v) => v.volumeId)).size; case 'reading_days': diff --git a/apps/api/lib/book-progress.ts b/apps/api/lib/book-progress.ts index 7634acc..9c55215 100644 --- a/apps/api/lib/book-progress.ts +++ b/apps/api/lib/book-progress.ts @@ -9,8 +9,6 @@ export interface ProgressStep { volumeId: string; /** Pages advanced since the previous bookmark in this book. */ pages: number; - /** Percentage points gained since the previous bookmark in this book. */ - percent: number; } interface Bookmark { @@ -18,7 +16,6 @@ interface Bookmark { volumeId: string; kind: string; number: number | null; - percent: number | null; } /** @@ -55,13 +52,13 @@ export function stepsFromBookmarks(bookmarks: Bookmark[]): ProgressStep[] { 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; - const percent = - prev.percent !== null && curr.percent !== null ? Math.max(0, curr.percent - prev.percent) : 0; - if (pages === 0 && percent === 0) continue; + if (pages === 0) continue; - steps.push({ at: curr.at, volumeId, pages, percent: Math.round(percent * 10) / 10 }); + steps.push({ at: curr.at, volumeId, pages }); } } @@ -91,7 +88,6 @@ export async function loadProgressSteps(userId: string, end: Date): Promise Date: Tue, 18 Aug 2026 04:57:34 +0000 Subject: [PATCH 10/15] Best Streak for reading Mirrors the GitHub tile: longest run of consecutive days with a bookmark, within the selected period. streakDaysV1 could not be reused - it filters on github.commit.pushed - so the run-length half is extracted as longestDayStreak, which takes day keys and lets each source decide what an active day is. streakDaysV1 now delegates to it, so GitHub's numbers are unchanged; verified across gaps, month and February boundaries, unsorted input and duplicate days. --- .../app/v1/metrics/books/overview/route.ts | 7 +++-- .../app/v1/metrics/books/timeseries/route.ts | 8 ++++-- apps/web/src/app/(dashboard)/metrics/page.tsx | 1 + packages/metrics/src/commit-activity.ts | 22 ++------------- packages/metrics/src/index.ts | 1 + packages/metrics/src/streak.ts | 28 +++++++++++++++++++ 6 files changed, 43 insertions(+), 24 deletions(-) create mode 100644 packages/metrics/src/streak.ts diff --git a/apps/api/app/v1/metrics/books/overview/route.ts b/apps/api/app/v1/metrics/books/overview/route.ts index 2238c0a..853d84b 100644 --- a/apps/api/app/v1/metrics/books/overview/route.ts +++ b/apps/api/app/v1/metrics/books/overview/route.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from 'next/server'; -import { dayKeyInTz, computeDelta } from '@baseline/metrics'; +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'; @@ -36,7 +36,9 @@ export async function GET(request: NextRequest) { const currMarks = marksIn(b.start, currEnd); const prevMarks = marksIn(b.prevStart, prevEnd); - const readingDays = (m: typeof marks) => new Set(m.map((v) => dayKeyInTz(v.at, tz))).size; + 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 }); @@ -46,6 +48,7 @@ export async function GET(request: NextRequest) { 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 index 00250f9..26477dd 100644 --- a/apps/api/app/v1/metrics/books/timeseries/route.ts +++ b/apps/api/app/v1/metrics/books/timeseries/route.ts @@ -1,10 +1,10 @@ import { NextRequest, NextResponse } from 'next/server'; -import { dayKeyInTz } from '@baseline/metrics'; +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']; +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. @@ -44,6 +44,10 @@ export async function GET(request: NextRequest) { 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; } diff --git a/apps/web/src/app/(dashboard)/metrics/page.tsx b/apps/web/src/app/(dashboard)/metrics/page.tsx index f3bfdd3..ce35944 100644 --- a/apps/web/src/app/(dashboard)/metrics/page.tsx +++ b/apps/web/src/app/(dashboard)/metrics/page.tsx @@ -58,6 +58,7 @@ const METRICS: Array<{ { 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' }, 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; +} From 401cf95676091a3b451e1496fa6d9e4819699a08 Mon Sep 17 00:00:00 2001 From: default Date: Tue, 18 Aug 2026 05:01:19 +0000 Subject: [PATCH 11/15] Consistency score for reading The card picked its metric from a hardcoded per-source chain that fell through to GitHub's active days for anything unlisted. Play Books therefore resolved to a metric outside its own view and the card hid itself. The METRICS entries already carry a days flag meaning exactly 'this is the active-days metric', so the card now derives from that instead. A new integration gets the card by setting the flag rather than by editing a chain someone has to remember exists. Verified against every source: all, github, calendar and baseline resolve exactly as before; google_books goes from hidden to reading_days. --- apps/web/src/app/(dashboard)/metrics/page.tsx | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/apps/web/src/app/(dashboard)/metrics/page.tsx b/apps/web/src/app/(dashboard)/metrics/page.tsx index ce35944..fabe5ab 100644 --- a/apps/web/src/app/(dashboard)/metrics/page.tsx +++ b/apps/web/src/app/(dashboard)/metrics/page.tsx @@ -179,15 +179,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); From 50085e43cbcc6c4ed9d67bd83d7bdf6d9ab402c8 Mon Sep 17 00:00:00 2001 From: default Date: Tue, 18 Aug 2026 05:04:51 +0000 Subject: [PATCH 12/15] Fetch every source's overview, not three hardcoded ones Play Books tiles and its consistency ring showed '-' because the overview fetch listed github, google_calendar and baseline by hand while the series fetch beside it already derived its list from the metric definitions. The result was charts that loaded and tile values that did not, which reads as missing data rather than missing wiring. Both now come off the same grouping, so a new integration is wired by adding its metrics and a SOURCE_BASE entry. Audited every tile against its routes: all seventeen have an overview and a timeseries entry, and each source has exactly one days-flagged metric driving its consistency card. --- apps/web/src/app/(dashboard)/metrics/page.tsx | 23 +++++++++++++++---- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/apps/web/src/app/(dashboard)/metrics/page.tsx b/apps/web/src/app/(dashboard)/metrics/page.tsx index fabe5ab..1979f2f 100644 --- a/apps/web/src/app/(dashboard)/metrics/page.tsx +++ b/apps/web/src/app/(dashboard)/metrics/page.tsx @@ -148,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)) @@ -160,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) From 536c32f0cbd6e76a059e20e23fd9633473780491 Mon Sep 17 00:00:00 2001 From: default Date: Tue, 18 Aug 2026 05:08:50 +0000 Subject: [PATCH 13/15] Ship the google-books package in the API image The Dockerfile enumerates workspace packages one COPY at a time, in both the deps and builder stages, and google-books was in neither - so the image installed and built without it and next build failed resolving the import from lib/ingestion. Typecheck and lint pass locally because they run against the full workspace, which is why this only surfaced in CI. Also wires google_books into revokeIntegrations, which still only knew about google_calendar and github: disconnecting Play Books left Baseline in the user's Google authorized-apps list with the books scope intact. That revoke needs care. Every Google source shares one OAuth client, and consent is extended rather than duplicated (include_granted_scopes), so they share a single grant - revoking either would silently break the other. Disconnect now skips the revoke while another Google source is still connected, and clears our own tokens regardless. --- apps/api/Dockerfile | 2 ++ apps/api/app/v1/integrations/[id]/route.ts | 17 +++++++++++++++-- apps/api/lib/revoke-integrations.ts | 12 +++++++++++- 3 files changed, 28 insertions(+), 3 deletions(-) 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/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') { From a3d93db671a717925e6ede6b62621a13bfaed05f Mon Sep 17 00:00:00 2001 From: default Date: Tue, 18 Aug 2026 05:21:46 +0000 Subject: [PATCH 14/15] History filter becomes a dropdown Replaces the pill row with the SourceDropdown already used on the metrics page, so both filters look and behave the same. Labels now come from SOURCE_META instead of a second hardcoded list, which had drifted: the same source read 'Calendar' here and 'Google Calendar' there. That meant adding entries for 'manual', a real value in events.source that had no name, and teaching the dropdown about 'all' - it documented an 'All sources' option it never actually offered, because 'all' is a filter rather than a source and has no registry entry. Also shortens the Play Books label, which is a chip in both places. --- apps/web/src/app/(dashboard)/history/page.tsx | 33 +++++++------------ apps/web/src/app/components/source-badge.tsx | 8 ++++- .../src/app/components/source-dropdown.tsx | 7 +++- 3 files changed, 24 insertions(+), 24 deletions(-) diff --git a/apps/web/src/app/(dashboard)/history/page.tsx b/apps/web/src/app/(dashboard)/history/page.tsx index 434265b..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,14 +20,10 @@ interface EventsResponse { next_cursor: string | null; } -type SourceFilter = 'all' | 'manual' | 'github' | 'google_calendar' | 'google_books'; -const FILTERS: { key: SourceFilter; label: string }[] = [ - { key: 'all', label: 'All' }, - { key: 'manual', label: 'Time entries' }, - { key: 'github', label: 'GitHub' }, - { key: 'google_calendar', label: 'Calendar' }, - { key: 'google_books', label: 'Reading' }, -]; +// 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) { @@ -194,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/components/source-badge.tsx b/apps/web/src/app/components/source-badge.tsx index d5161e3..765aec8 100644 --- a/apps/web/src/app/components/source-badge.tsx +++ b/apps/web/src/app/components/source-badge.tsx @@ -38,6 +38,12 @@ export const SOURCE_META: Record = { label: 'Baseline', icon: , }, + // Time entries you logged yourself — a real value in events.source, so it needs an + // entry to be nameable in filters. + manual: { + label: 'Time entries', + icon: , + }, github: { label: 'GitHub', icon: ( @@ -51,7 +57,7 @@ export const SOURCE_META: Record = { icon: , }, google_books: { - label: 'Google Play Books', + label: 'Play Books', icon: , }, }; 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 ( From 8af166b1eda2691d61cb1899e1996b0b37e498a7 Mon Sep 17 00:00:00 2001 From: default Date: Tue, 18 Aug 2026 05:24:41 +0000 Subject: [PATCH 15/15] History filter: 'Time entries' becomes 'Baseline' Names the origin rather than the one event type it currently produces, matching how the metrics filter already labels Baseline's own data. The registry now holds two entries reading 'Baseline' - 'baseline' is the metrics source id, 'manual' is the events.source value - so the comment says why. Metrics filters on the former and history on the latter, and neither list contains both, so no dropdown can show it twice. --- apps/web/src/app/components/source-badge.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/apps/web/src/app/components/source-badge.tsx b/apps/web/src/app/components/source-badge.tsx index 765aec8..408d834 100644 --- a/apps/web/src/app/components/source-badge.tsx +++ b/apps/web/src/app/components/source-badge.tsx @@ -38,10 +38,12 @@ export const SOURCE_META: Record = { label: 'Baseline', icon: , }, - // Time entries you logged yourself — a real value in events.source, so it needs an - // entry to be nameable in filters. + // 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: 'Time entries', + label: 'Baseline', icon: , }, github: {